sehaj/learn
Docker/07 Production

07.02 · with code · 13 min

Secrets without leaks

Where secrets leak in Docker (build args, layers, environment variables) and the two mechanisms that don't: build secrets and secret files.

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 RUN step 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:

Dockerfile (don't)
FROM alpine
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > /root/.npmrc && echo "pretend npm install" && rm /root/.npmrc

The file never reaches a layer. The token still leaks, into the image's history:

❯terminal
$ 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:

Dockerfile
# 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
❯terminal
$ 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 SuperSecret
0

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:

❯terminal
$ 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:

compose.prod.yaml
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.txt

The _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.

❯terminal
$ 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 DATABASE
DATABASE_URL=postgres://notes@db:5432/notes
DATABASE_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.

Predict first

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?

Commit to an answer before you read on.
CheckpointFind the leak, then fix it

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

SeniorfreqMust-knowDesign

How do you handle secrets in Docker, at build time and at runtime?

Report a mistakeEdit this page