The core idea
A secret is anything that grants access: passwords, API tokens, private keys. There are two moments a container setup needs them, and each has a leak-free mechanism:
- At build time (say, a token to install private packages): build secrets, mounted for one
RUNstep and never written to a layer. - At run time (say, the database password): secret files, mounted into the container, read by the app, and
absent from
docker inspect.
The build-time leak
A common first attempt passes a token as a build argument and deletes the file in the same step:
FROM alpine
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > /root/.npmrc && echo "pretend npm install" && rm /root/.npmrcThe file never reaches a layer. The token still leaks, into the image's history:
$ docker build --build-arg NPM_TOKEN=npm_SuperSecret123 -t leak:arg .1 warning found (use docker --debug to expand):- SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG "NPM_TOKEN") (line 2)$ docker history --no-trunc leak:arg | grep -o 'NPM_TOKEN=[^ ]*'NPM_TOKEN=npm_SuperSecret123
Output trimmed. BuildKit even warns you. Anyone who can pull the image can read the token.
Build secrets
BuildKit can mount a file into a single RUN step:
# syntax=docker/dockerfile:1
FROM alpine
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc cat /root/.npmrc | sed "s/=.*/=***/" && echo "pretend npm install"
RUN ls -la /root/.npmrc$ docker build --secret id=npmrc,src=./npmrc -t leak:secret .#7 [2/3] RUN --mount=type=secret,id=npmrc,target=/root/.npmrc cat /root/.npmrc | sed "s/=.*/=***/" ...#7 0.104 //registry.npmjs.org/:_authToken=***#7 0.104 pretend npm install#8 [3/3] RUN ls -la /root/.npmrc#8 0.109 ls: /root/.npmrc: No such file or directory$ docker history --no-trunc leak:secret | grep -c SuperSecret0
The step that needed the token could read it. The next step can't see it, and it appears nowhere in the history. In
CI, the docker/build-push-action passes the same thing with its secrets: input.
Run-time secrets as files
Environment variables are readable by anyone with Docker access:
$ docker run -d --name envleak -e DB_PASSWORD=hunter2 alpine sleep 60$ docker inspect -f '{{.Config.Env}}' envleak[DB_PASSWORD=hunter2 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin]
Compose can mount a secret as a file at /run/secrets/NAME instead:
services:
api:
environment:
DATABASE_URL: postgres://notes@db:5432/notes
DATABASE_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]
db:
image: postgres:18
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]
secrets:
db_password:
file: ./secrets/db_password.txtThe _FILE convention is widely supported: official images for Postgres, MySQL and others accept
POSTGRES_PASSWORD_FILE in place of POSTGRES_PASSWORD. The notes API now does the same with
DATABASE_PASSWORD_FILE: the URL has no password, and the app reads it from the file at startup.
$ openssl rand -base64 24 > secrets/db_password.txt$ docker compose -f compose.prod.yaml exec api ls -l /run/secrets/-rw-r--r-- 1 node node 33 Nov 2 21:08 db_password$ docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' notes-prod-api-1 | grep DATABASEDATABASE_URL=postgres://notes@db:5432/notesDATABASE_PASSWORD_FILE=/run/secrets/db_password
docker inspect shows only the path. The password is in a file only the container can read.
Compose secrets from file: are a bind mount of that file. That's a clear improvement over env vars, not a vault: the
file still sits on the server's disk, so protect it (chmod 600, never commit it). Kubernetes Secrets, Docker Swarm
secrets and cloud secret managers build on the same "mounted file" idea with storage and access control behind it.
You need a GitHub token to npm install a private package during docker build. Which approach keeps the token out of the final image and its history?
Build the ARG version and find the token with docker history --no-trunc. Rebuild with a secret mount and confirm it's gone. Then run the production stack with a generated secrets/db_password.txt and show that docker inspect on the API reveals no password.
STUCK? ▸
If the secret build fails with "secret npmrc not found", the id in --secret id=... must match the id in the --mount.
Recap
Interview questions
How do you handle secrets in Docker, at build time and at runtime?