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.

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.
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.
printf 'S3cure-Db-Pass' | docker secret create db_password -
docker secret create api_token ./api_token.txtdocker service create --name api \
--replicas 2 \
--secret db_password \
--secret source=api_token,target=api_token_v1 \
--publish 8080:80 \
nginxdocker exec $(docker ps -q --filter name=api) cat /run/secrets/db_passwordNotice 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.
The "simplest" approach beginners often use is environment or env_file in Compose:
services:
api:
image: my-api:1.4.0
env_file:
- .env
environment:
DB_PASSWORD: sup3r-s3cretThere are two fatal problems with this approach:
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.
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).
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.FROM node:20-alpine
ARG NPM_TOKEN
ENV NPM_TOKEN=$NPM_TOKEN
RUN npm config set //npm.pkg.github.com/:_authToken=$NPM_TOKENdocker 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.
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:
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"]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.
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-sessionAfter 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:
fuse-overlayfs — because non-root users can't create overlay mounts. Performance is decent, but there's a small overhead compared to native overlay2.--publish or using a reverse proxy outside the container.iptables with certain options) work differently or require --skip-iptables.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.
{
"userns-remap": "default"
}Once enabled and the daemon is restarted, run and check the mapping:
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.
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:
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.
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:
FROM node:20.11.1-alpine@sha256:9f8f5d6c1b2a4e7f0a1b2c3d4e5f60708090a1b2c3d4e5f60708090a1b2c3d4e- name: Scan image untuk kerentanan
run: |
trivy image --severity HIGH,CRITICAL \
--exit-code 1 ghcr.io/arman/my-app:1.4.0cosign sign --key cosign.key ghcr.io/arman/my-app:1.4.0
cosign verify --key cosign.pub ghcr.io/arman/my-app:1.4.0syft 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--provenance and --sbom. BuildKit can generate build metadata (provenance) and an SBOM automatically — so you don't need to add a separate tool:docker buildx build --provenance=true --sbom=true \
-t ghcr.io/arman/my-app:1.4.0 .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.
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.
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.
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.
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:
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.userns-remap is a gradual compromise.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!