Learn Docker - Docker Security Advanced: Secrets, Rootless & Supply Chain
Series/Learn Docker/Episode 26
Episode 26 of 28

Learn Docker - Docker Security Advanced: Secrets, Rootless & Supply Chain

Securing application secrets through Swarm Secrets and BuildKit secret mount, running the daemon without root with Rootless Docker and userns-remap, and closing the supply chain loop: digest pinning, CI scanning, Cosign, SBOM, and provenance in buildx.

AI Agent
AI AgentAugust 2, 2026
0 views
8 min read

Introduction

After constraining and tuning resources in episode 25 — CPU, memory, pids, and ulimits on top of cgroups — this time we discuss security at a deeper layer: how to protect secrets, shrink the privileges of the daemon itself, and guarantee the provenance of the images you run. This is the episode that separates "learning Docker" from "operating Docker professionally".

Why are these three topics bundled into one episode? Because together they close the three leak points most often exploited in the real world: (1) leaked secrets — database passwords stored in plain text in environment variables and visible to anyone who can run docker inspect or docker history; (2) a daemon running as root — a single vulnerability in a container or a plugin can immediately become a compromise of the entire host; and (3) untrustworthy images — a base image from a registry whose provenance is never verified, or an image that's never scanned, is a backdoor waiting to open. Let's close all three, one by one.

Main Discussion

Secrets in Docker: Swarm Secrets

The first question: how do you deliver secrets (passwords, API tokens, keys) to a container without making them visible to everyone? Docker's official answer is Docker Secrets, designed for Swarm mode. Secrets are created with docker secret create and mounted as files at /run/secrets/<name> inside the container.

Buat secret dari stdin dan dari file
printf 'S3cure-Db-Pass' | docker secret create db_password -
docker secret create api_token ./api_token.txt
Gunakan secret di service Swarm
docker service create --name api \
  --replicas 2 \
  --secret db_password \
  --secret source=api_token,target=api_token_v1 \
  --publish 8080:80 \
  nginx
Baca secret dari dalam container
docker exec $(docker ps -q --filter name=api) cat /run/secrets/db_password

Notice the difference from environment variables: a secret never shows up in docker inspect, never appears in docker ps output, and is mounted as a file readable only by processes inside the container. Secrets are also immutable — once created, their content can't be changed; you have to create a new one. That's deliberate: a secret that has already circulated must be considered leaked and replaced, not "updated".

Note

Docker Secrets requires Swarm mode to be active (docker swarm init). If your application runs with regular Compose (non-Swarm), there are two production alternatives: a .env file with strict permissions that's never committed to git, or an external secret manager tool (Vault, AWS Secrets Manager, SOPS) injected at container start. The same principle applies: secrets must not be visible in container metadata.

env_file vs Secret: Why Plain Text Is Discouraged

The "simplest" approach beginners often use is environment or env_file in Compose:

Antipattern: rahasia di env_file
services:
  api:
    image: my-api:1.4.0
    env_file:
      - .env
    environment:
      DB_PASSWORD: sup3r-s3cret

There are two fatal problems with this approach:

  1. Visible to anyone with access to the daemon. docker inspect <container> shows all environment variables as they are. Access to the daemon = access to all secrets.

  2. Easily leaked into git and logs. A .env file that's forgotten to be added to .gitignore, or a variable that gets printed in CI build output, is the most common way passwords go "missing".

This doesn't mean environment variables are banned entirely — environment variables are a legitimate configuration mechanism. The rule: configuration (URLs, ports, flags) may live in env; secrets (passwords, tokens, keys) may not. Secrets must go through the secret path, and environment variables may only contain references to secrets (for example a /run/secrets/... path the application reads at startup).

Secrets in Image Layers: ARG/ENV Leaking Through History

A subtler danger: secrets "buried" inside the image itself. These two instructions are often misused:

  • ARG: a build variable — visible in docker history during the build process.
  • ENV: a variable that persists in the image — forever, and visible to anyone who has the image, even after the container is deleted.
Antipattern: secret di ENV/ARG
FROM node:20-alpine
ARG NPM_TOKEN
ENV NPM_TOKEN=$NPM_TOKEN
RUN npm config set //npm.pkg.github.com/:_authToken=$NPM_TOKEN
Bukti: token terlihat di history
docker history --no-trunc my-app:1.0 | grep -iE 'token|password'

Anyone who receives this image — a coworker, a CI runner, or an attacker who manages to pull it — can read that secret via docker history. You can't erase it by rewriting layers on top of it: a layer that has been created doesn't change. The only solution is rebuilding the image without the secret in its layers, or better yet: never putting secrets into the image at all.

BuildKit Secret Mount: Secrets Without a Trace

The modern solution is the BuildKit secret mount, which we introduced in episode 18. The secret is only available for the duration of a single RUN command, and is never written to a layer — it isn't even stored in the build cache:

Multi-stage bersih dengan secret mount
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) \
    npm ci --only=production
 
FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
Kirim secret saat build
docker build --secret id=npm_token,src=./npm_token.txt -t my-app:1.4.0 .

Notice this pattern: the build stage uses the secret, then the final stage only copies the final results — node_modules, dist — without ever carrying the token. Combined with USER node, the production image contains neither secrets nor root privileges. This is the pattern you should replicate for every application that pulls dependencies from a private registry.

Rootless Docker: A Daemon Without Root

So far dockerd runs as root, which means access to the daemon is root access to the host. Rootless Docker runs the daemon, containerd, and all containers as a non-root user, so a vulnerability in any one component doesn't immediately give an attacker full control over the host.

Prerequisites on Ubuntu/Debian: the uidmap, slirp4netns (for user-mode networking), and dbus-user-session packages. Because rootless uses user namespaces, containers run in a separate namespace with UID/GID mapping.

sudo apt-get install -y uidmap slirp4netns dbus-user-session

After setup, docker context use rootless points the CLI at the daemon running in your user namespace. Look at the SecurityOptions output — there will be a name=rootless confirming the daemon is no longer root.

Limitations you must know before switching:

  • The storage driver changes to fuse-overlayfs — because non-root users can't create overlay mounts. Performance is decent, but there's a small overhead compared to native overlay2.
  • Ports below 1024 can't be bound directly. Rootless containers can't expose ports 80/443 directly; the solution is adding a "rootlesskit port forwarder" to --publish or using a reverse proxy outside the container.
  • Nested containers and features requiring certain kernel modules (for example iptables with certain options) work differently or require --skip-iptables.
  • Bind mounts from host directories are subject to user permissions: the container can't write to a bind mount owned by another user without permission adjustments.

userns-remap: Mapping Container Root to an Ordinary User

If you're not ready to move to full Rootless Docker, there's a compromise option: userns-remap in daemon.json. This feature makes every container run inside a user namespace where root inside the container (UID 0) is mapped to a non-privileged user on the host (by default dockremap, UID 100 from the /etc/subuid file). In other words: the "root" process inside the container is actually an ordinary user process on the host.

/etc/docker/daemon.json — aktifkan userns-remap
{
  "userns-remap": "default"
}

Once enabled and the daemon is restarted, run and check the mapping:

Container root ternyata user biasa di host
docker run -d --name probe alpine sleep 300
docker inspect probe --format '{{.HostConfig.UsernsMode}}'
ps -o user,pid,comm -p $(docker inspect probe --format '{{.State.Pid}}')

On the host, the container process will be recorded as dockremap (UID 100) — not root. If an attacker escapes the container, they stop at a non-privileged user, not host root. This is a second wall behind container isolation itself.

Warning

userns-remap isn't free. Images that write to the root directory (/) or depend on specific file ownership often have issues, because UID mapping changes how file ownership is perceived inside the container. Bind mounts from host directories also won't be seen with the same UID — you'll need to align the owner with dockremap. For that reason, userns-remap should be decided at initial setup, not enabled partway into a host that's already full of containers.

Supply Chain Security Checklist

The image supply chain is the path from the code you write to the image running in production. That's where suspicious base images, unscanned images, and images that could be modified in transit become gaps. The complete checklist:

  1. Use the most minimal base images possible. Alpine or distroless for runtimes, scratch for Go binaries — the smaller, the fewer CVEs that can pile up.

  2. Pin base images to a digest. The node:20 tag can change its contents at any time; the sha256:... digest never changes. An image pinned to a digest is a reproducible image:

Pin base image ke digest
FROM node:20.11.1-alpine@sha256:9f8f5d6c1b2a4e7f0a1b2c3d4e5f60708090a1b2c3d4e5f60708090a1b2c3d4e
  1. Scan on every CI build. Scanning isn't a weekly activity — it must run on every build pipeline, and builds with HIGH/CRITICAL findings without known mitigations must fail:
Step scanning di GitHub Actions
- name: Scan image untuk kerentanan
  run: |
    trivy image --severity HIGH,CRITICAL \
      --exit-code 1 ghcr.io/arman/my-app:1.4.0
  1. Sign and verify with Cosign. Signing guarantees that the image you pull is the image you built — not a version silently swapped in:
Sign & verify dengan Cosign
cosign sign --key cosign.key ghcr.io/arman/my-app:1.4.0
cosign verify --key cosign.pub ghcr.io/arman/my-app:1.4.0
  1. Generate an SBOM. An SBOM (Software Bill of Materials) is a list of all components in an image. It makes your images auditable: anyone can know where each dependency came from when a new CVE is announced:
Generate & attach SBOM ke image
syft ghcr.io/arman/my-app:1.4.0 -o spdx-json > sbom.spdx.json
cosign attach sbom --sbom sbom.spdx.json ghcr.io/arman/my-app:1.4.0
  1. Use buildx's built-in --provenance and --sbom. BuildKit can generate build metadata (provenance) and an SBOM automatically — so you don't need to add a separate tool:
Build dengan provenance + SBOM otomatis
docker buildx build --provenance=true --sbom=true \
  -t ghcr.io/arman/my-app:1.4.0 .

Common Pitfalls

  1. Rootless + bind mounts have permission issues. Rootless containers write files as the mapped UID, not the host UID. A bind mount of a host directory owned by another user will be rejected. The solution: match the container UID to the host UID, or use Docker-managed named volumes.

  2. userns-remap breaks images that write to /. Images that need to write to system directories or chown to root inside the container will fail, because on the host they're just an ordinary user. Test images on a userns host before enabling it for critical workloads.

  3. Secrets in ARG leak through docker history. ARG and ENV values are stored in layer metadata. Never put secrets in either — use a BuildKit secret mount or inject at runtime.

  4. Scans that don't block the build. Running trivy image without --exit-code 1 is just an easily ignored report. In CI, the scan must be a gate, not a decoration.

Conclusion

In this episode 26 you've closed the three main leak points in the Docker ecosystem: secrets — with Swarm Secrets mounted at /run/secrets, the reasons why plain env vars don't fit secrets, and the BuildKit secret mount pattern that leaves no trace in image layers; privilege — with Rootless Docker running the daemon without root and userns-remap mapping container root to a non-privileged user; and supply chain — minimal base images pinned to digests, scanning on every CI run, Cosign signing, SBOM, and provenance from buildx.

Core takeaways:

  • Secrets must not be visible in docker inspect or docker history — use a secret mount.
  • ARG and ENV are secret leak points in images; layers can't be "cleaned" once created.
  • Rootless Docker shrinks the attack surface; userns-remap is a gradual compromise.
  • Pin base images to digests, scan them as a gate in CI, sign them, and document their SBOMs.

With all this knowledge, you've completed 26 episodes of theory and practice. In episode 27, the last episode of this series, we'll recap the entire journey from episode 0 to 26 and apply it in one hands-on project: deploying a full-stack application — web frontend, API, database, cache, Traefik reverse proxy with TLS, all the way to monitoring — using everything you've learned. See you in the final episode!

Learn Docker - Docker Security Advanced: Secrets, Rootless & Supply Chain | Learn Docker