Runtime secrets, done right

Deliver secrets as files, not environment variables, and close the run-time leaks.

Advanced12 min · lesson 18 of 24
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (1 files, 1 KB): runsecrets.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-int/runsecrets.tar.gz && tar -xzf runsecrets.tar.gz, which creates ~/lab/runsecrets/. SHA-256: 6beb10d44a5d66af1e2e622c11d507108abddd48fe408d429e0c9bb7949f9b98

A password passed with -e DB_PASSWORD=... is readable by anyone who can reach the daemon, whatever else you did to the container. "Environment variables and configuration" (Docker for beginners) already warned that environment values are stored in the container config and read back by tooling; this lesson shows exactly where a secret passed that way turns up, moves it to a file instead, and names the ways a file-based secret can still leak at run time. Everything runs on the main lab VM (secopslog-docker), in ~/lab/runsecrets. All values are fake.

Where an environment secret shows up

Start one container with a secret in its environment, then read it back from outside the container and from the host:

ubuntu@secopslog-docker:~ · Docker 29.8.2
$ docker run -d --name lab-envleak -e DB_PASSWORD=lab-fake-password-env alpine:3.22 sleep 600
8b32ee4895f367445120f831ad9561f41d29f66aeba1b613ab6aeb7593182e9a
$ docker inspect -f '{{json .Config.Env}}' lab-envleak
["DB_PASSWORD=lab-fake-password-env","PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
$ P=$(docker inspect -f '{{.State.Pid}}' lab-envleak) sudo cat /proc/$P/environ | tr '\0' '\n' | grep DB_
DB_PASSWORD=lab-fake-password-env
$ docker exec lab-envleak env | grep DB_ docker exec lab-envleak sh -c 'sh -c "echo child process: \$DB_PASSWORD"'
DB_PASSWORD=lab-fake-password-env child process: lab-fake-password-env

docker inspect prints the value to anyone in the docker group, which "The Docker socket and daemon hardening" shows is the same as root on the host. /proc/<pid>/environ is the environment block the process was handed at start, read from its memory and readable by host root; unsetting the variable in code later does not clear that block. And every child process inherits it. Two of those three reads came from outside the container entirely, so the in-container user and a read-only root filesystem change nothing. There are two more exits most teams forget: a crash that dumps the environment, and the log it lands in.

ubuntu@secopslog-docker:~ · Docker 29.8.2
$ docker run --name lab-crash -e DB_PASSWORD=lab-fake-password-env alpine:3.22 \ sh -c 'echo "fatal: cannot connect, effective configuration:" >&2; env | sort >&2; exit 3'
fatal: cannot connect, effective configuration: DB_PASSWORD=lab-fake-password-env HOME=/root HOSTNAME=14e9f6e4b27d PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin PWD=/ SHLVL=1
$ docker logs lab-crash 2>&1 | grep DB_ sudo grep -c lab-fake-password-env "$(docker inspect -f '{{.LogPath}}' lab-crash)"
DB_PASSWORD=lab-fake-password-env 1

The process printed its configuration to stderr on the way down, Docker wrote that to the container's JSON log file, and the secret is now sitting on disk in the clear, counted once by grep. An error tracker that attaches the process environment to every exception report forwards it the same way. Before changing how you deliver secrets, find the ones already running. One sweep over every container's environment finds the secret-shaped variables:

ubuntu@secopslog-docker:~ · Docker 29.8.2
$ docker inspect -f '{{$n := .Name}}{{range .Config.Env}}{{$n}} {{.}}{{println}}{{end}}' $(docker ps -aq) | grep -iE '^[^ ]+ [^=]*(pass|secret|token|key)[^=]*='
/lab-crash DB_PASSWORD=lab-fake-password-env /lab-envleak DB_PASSWORD=lab-fake-password-env

Both containers carry the fake password; on a real host this list routinely turns up live API keys. Rotate what it finds, then fix the delivery so the replacement does not land in the same places.

Deliver it as a file

A mounted secret file never enters the environment, so docker inspect, /proc/<pid>/environ and child processes have nothing to show. Compose does this directly: you declare a secret backed by a file on disk, list it on the service, and Compose mounts it read-only under /run/secrets/. Many official images already expect a file through the *_FILE convention, so you point the image at the path instead of giving it the value:

compose.yaml
name: lab-runsecrets
services:
db:
image: postgres:18-alpine
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password # the image reads the file, once, at first start
secrets: [db_password]
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres"]
interval: 2s
retries: 15
report:
image: alpine:3.22
user: "10001:10001"
depends_on:
db: { condition: service_healthy }
secrets:
- source: db_password
target: db_password
uid: "10001"
gid: "10001"
mode: 0400
command: ["sh", "-c", "wc -c /run/secrets/db_password && exec sleep 3600"]
secrets:
db_password:
file: ./secrets/db_password.txt # plain text on the host: keep it out of Git and backups

The db service gets POSTGRES_PASSWORD_FILE rather than POSTGRES_PASSWORD; the report service reads the same secret as a non-root user. Create the backing file and bring it up:

ubuntu@secopslog-docker:~/lab/runsecrets · Docker 29.8.2
$ mkdir -p secrets printf 'lab-fake-db-password-1' > secrets/db_password.txt chmod 600 secrets/db_password.txt ls -ln secrets/
total 4 -rw------- 1 1000 1000 22 Oct 8 01:38 db_password.txt
$ docker compose --progress quiet up -d --wait db docker compose --progress quiet up -d report
time="2026-10-08T01:38:02+05:30" level=warning msg="service \"report\": secrets.db_password.gid: gid is not supported outside Swarm mode and will be ignored" time="2026-10-08T01:38:02+05:30" level=warning msg="service \"report\": secrets.db_password.mode: mode is not supported outside Swarm mode and will be ignored" time="2026-10-08T01:38:02+05:30" level=warning msg="service \"report\": secrets.db_password.uid: uid is not supported outside Swarm mode and will be ignored" time="2026-10-08T01:38:05+05:30" level=warning msg="service \"report\": secrets.db_password.gid: gid is not supported outside Swarm mode and will be ignored" time="2026-10-08T01:38:05+05:30" level=warning msg="service \"report\": secrets.db_password.mode: mode is not supported outside Swarm mode and will be ignored" time="2026-10-08T01:38:05+05:30" level=warning msg="service \"report\": secrets.db_password.uid: uid is not supported outside Swarm mode and will be ignored"
$ sleep 1; docker compose logs --no-log-prefix report
wc: /run/secrets/db_password: Permission denied

The report container could not read the file: its process runs as UID 10001, and the mounted file is owned by the host user that created it. That is the first thing to understand about Compose file secrets outside Swarm. The long-syntax uid, gid and mode keys are Swarm-only and are ignored with a warning (visible in the previous step), so the file arrives with its host ownership and mode, and the reader's UID must match. Look at what actually got mounted and how it reads from outside:

ubuntu@secopslog-docker:~/lab/runsecrets · Docker 29.8.2
$ docker compose exec -T db sh -c 'ls -ln /run/secrets; grep /run/secrets /proc/mounts' docker inspect lab-runsecrets-db-1 -f '{{range .Mounts}}{{.Type}} {{.Source}} -> {{.Destination}} rw={{.RW}}{{println}}{{end}}'
total 4 -rw------- 1 1000 1000 22 Oct 7 20:08 db_password /dev/sda1 /run/secrets/db_password ext4 ro,relatime,discard,errors=remount-ro,commit=30 0 0 bind /home/ubuntu/lab/runsecrets/secrets/db_password.txt -> /run/secrets/db_password rw=false volume /var/lib/docker/volumes/1a3fea624ba113f9767f26573aad5abc45e48859e5e0047ae30f37ed3012cefb/_data -> /var/lib/postgresql rw=true
$ docker inspect lab-runsecrets-db-1 -f '{{range .Config.Env}}{{println .}}{{end}}' | grep -i pass P=$(docker inspect -f '{{.State.Pid}}' lab-runsecrets-db-1) echo "PASS variables in the host's view of PID $P: $(sudo cat /proc/$P/environ | tr '\0' '\n' | grep -ci pass)" docker compose exec -T db grep -nE '^\s*(file_env|unset) .*POSTGRES_(PASSWORD|@)' /usr/local/bin/docker-entrypoint.sh
POSTGRES_PASSWORD_FILE=/run/secrets/db_password PASS variables in the host's view of PID 542923: 0 235: file_env 'POSTGRES_PASSWORD' 381: unset "${!POSTGRES_@}"

The secret is a read-only bind mount of the host file, not an encrypted object: "not in the environment" is what you bought, not "encrypted at rest", so the backing file needs the same care as any credential (keep it out of Git). The db container's config carries only POSTGRES_PASSWORD_FILE, a path, and the host's view of the postgres process environment holds no password: the entrypoint reads the file, exports the value into its own short-lived init process, and unsets it before the long-running server starts. That last part is image-specific. The postgres image clears it; "Recipe: PostgreSQL, MySQL and MongoDB" (Docker in depth) notes that MySQL's server keeps MYSQL_ROOT_PASSWORD in /proc/1/environ even when you pass it through _FILE. The _FILE convention moves the secret off the command line and out of your Compose file; whether it stays out of the running process's environment depends on the image.

Fix the ownership so the non-root reader can open it, and the report service reads the secret:

ubuntu@secopslog-docker:~/lab/runsecrets · Docker 29.8.2
$ sudo chown 10001:10001 secrets/db_password.txt sudo chmod 400 secrets/db_password.txt docker compose --progress quiet up -d --force-recreate report sleep 1; docker compose logs --no-log-prefix report
time="2026-10-08T01:38:08+05:30" level=warning msg="service \"report\": secrets.db_password.gid: gid is not supported outside Swarm mode and will be ignored" time="2026-10-08T01:38:08+05:30" level=warning msg="service \"report\": secrets.db_password.mode: mode is not supported outside Swarm mode and will be ignored" time="2026-10-08T01:38:08+05:30" level=warning msg="service \"report\": secrets.db_password.uid: uid is not supported outside Swarm mode and will be ignored" 22 /run/secrets/db_password

Read at startup, and what rotation needs

An image that reads a secret only at first initialisation will not notice a changed file. Query the database with the current secret, then change the backing file and restart the database. The database still has the old password, so a client that uses the file's new contents is rejected:

ubuntu@secopslog-docker:~/lab/runsecrets · Docker 29.8.2
$ docker compose --progress quiet run --rm -T --no-deps --entrypoint sh db -c \ 'PGPASSWORD="$(cat /run/secrets/db_password)" psql -h db -U postgres -tAc "select current_user"'
time="2026-10-08T01:38:10+05:30" level=warning msg="service \"report\": secrets.db_password.gid: gid is not supported outside Swarm mode and will be ignored" time="2026-10-08T01:38:10+05:30" level=warning msg="service \"report\": secrets.db_password.mode: mode is not supported outside Swarm mode and will be ignored" time="2026-10-08T01:38:10+05:30" level=warning msg="service \"report\": secrets.db_password.uid: uid is not supported outside Swarm mode and will be ignored" postgres
$ sudo sh -c "printf 'lab-fake-db-password-2' > secrets/db_password.txt" docker compose restart db >/dev/null 2>&1; docker compose --progress quiet up -d --wait db
time="2026-10-08T01:38:10+05:30" level=warning msg="service \"report\": secrets.db_password.gid: gid is not supported outside Swarm mode and will be ignored" time="2026-10-08T01:38:10+05:30" level=warning msg="service \"report\": secrets.db_password.mode: mode is not supported outside Swarm mode and will be ignored" time="2026-10-08T01:38:10+05:30" level=warning msg="service \"report\": secrets.db_password.uid: uid is not supported outside Swarm mode and will be ignored"
$ docker compose --progress quiet run --rm -T --no-deps --entrypoint sh db -c \ 'PGPASSWORD="$(cat /run/secrets/db_password)" psql -h db -U postgres -tAc "select current_user"'
time="2026-10-08T01:38:13+05:30" level=warning msg="service \"report\": secrets.db_password.gid: gid is not supported outside Swarm mode and will be ignored" time="2026-10-08T01:38:13+05:30" level=warning msg="service \"report\": secrets.db_password.mode: mode is not supported outside Swarm mode and will be ignored" time="2026-10-08T01:38:13+05:30" level=warning msg="service \"report\": secrets.db_password.uid: uid is not supported outside Swarm mode and will be ignored" psql: error: connection to server at "db" (172.18.0.2), port 5432 failed: FATAL: password authentication failed for user "postgres"

The first query authenticated; after rotating the file and restarting, a client using the file's new contents is rejected, because postgres set its password from the file only when it first initialised its data directory. Changing a secret file is not rotation on its own. Real rotation needs the credential changed at the source (the database, the cloud IAM system) and the consumer to re-read, which is why the stronger pattern keeps the source of truth outside the container: a secrets manager such as HashiCorp Vault issues short-lived credentials, and a sidecar writes each fresh one into the file the app already reads, on a tmpfs so it never reaches the disk. The credential expires on its own, every fetch is logged, and the container never holds a long-lived secret.

The export anti-pattern

A file-based secret stays out of the environment right up until a startup script copies it back in. This one line, common in hand-rolled entrypoints, undoes the whole thing:

ubuntu@secopslog-docker:~/lab/runsecrets · Docker 29.8.2
$ docker run -d --name lab-export -v "$PWD/secrets/db_password.txt:/run/secrets/db_password:ro" alpine:3.22 \ sh -c 'export DB_PASSWORD="$(cat /run/secrets/db_password)"; exec sleep 600' docker inspect -f '{{json .Config.Env}}' lab-export sudo cat /proc/$(docker inspect -f '{{.State.Pid}}' lab-export)/environ | tr '\0' '\n' | grep DB_
cb451185274a0a23f3e3b4d7819c1438590ea762867a01ce3f9eb0edb0c0fbac ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"] DB_PASSWORD=lab-fake-db-password-2

After export DB_PASSWORD="$(cat ...)", the config stays clean, but /proc/<pid>/environ carries the value again and every child inherits it. Have the application open the file itself. If a tool genuinely needs an environment variable, set it inline on that one process's exec line rather than exporting it into the shell, and know it is still readable in that single process's environ.

Keep it out of the image, too

A secret written into the container filesystem at run time lives in the writable layer, and docker commit captures that layer. A tmpfs mount does not, because it never touches the layer:

ubuntu@secopslog-docker:~ · Docker 29.8.2
$ docker run -d --name lab-fetch alpine:3.22 sh -c 'printf lab-fake-token > /etc/app-token; exec sleep 600' sleep 1; docker diff lab-fetch docker commit lab-fetch lab-fetch-copy:1 >/dev/null docker run --rm lab-fetch-copy:1 cat /etc/app-token; echo
11c0fda7bdfe3cb218c7a3ae35a4ade268063299988f193c18ff00d416cdc4dd C /etc A /etc/app-token lab-fake-token
$ docker run -d --name lab-tmpfs --read-only --tmpfs /run/secrets:size=64k,mode=0700 alpine:3.22 \ sh -c 'printf lab-fake-token > /run/secrets/token; exec sleep 600' sleep 1; docker exec lab-tmpfs grep /run/secrets /proc/mounts docker diff lab-tmpfs; echo "docker diff: $(docker diff lab-tmpfs | wc -l) changes" docker commit lab-tmpfs lab-tmpfs-copy:1 >/dev/null docker run --rm lab-tmpfs-copy:1 ls -A /run/secrets; echo "files in the committed /run/secrets: $(docker run --rm lab-tmpfs-copy:1 ls -A /run/secrets | wc -l)"
4e7d6c443320a7038a899617c8c5a6afe9ae70b94a2af8734cc4e3d3d3f64481 tmpfs /run/secrets tmpfs rw,nosuid,nodev,noexec,relatime,size=64k,mode=700,inode64 0 0 C /run A /run/secrets docker diff: 2 changes files in the committed /run/secrets: 0

The first container wrote a token to its root filesystem; docker diff shows the change, and the committed image carries the file for anyone who pulls it. The second wrote to a tmpfs at /run/secrets: docker diff shows only the mountpoint /run/secrets being created (C /run, A /run/secrets), not the token, and the committed image's /run/secrets is empty. Write secrets that a process fetches at run time to a tmpfs. Compose file secrets outside Swarm are not on one: they are read-only bind mounts of a host file, as the mount table above showed, so that file on the host disk is where the secret lives at rest and what you protect (Swarm secrets do land on a tmpfs). Keep build-time secrets out of image layers with BuildKit's --mount=type=secret, which "Build-time secrets and how images leak them" covers. A distroless image with an env-delivered secret still leaks it, because stripping the image does nothing for a password in the container config that docker inspect prints.

ubuntu@secopslog-docker:~/lab/runsecrets · Docker 29.8.2
$ docker compose --progress quiet down -v docker rm -f lab-envleak lab-crash lab-export lab-fetch lab-tmpfs >/dev/null docker rmi -f lab-fetch-copy:1 lab-tmpfs-copy:1 >/dev/null sudo rm -rf secrets
Quick check
01A teammate argues the application's DB_PASSWORD is safe because the container runs as a non-root user on a read-only root filesystem. It is still passed with -e. Where is the exposure?
Correct — The value sits in the container config and in the live process environment, both readable from outside the container; the process's UID and a read-only filesystem do not touch either.
Incorrect — Read-only blocks writes to the filesystem; the environment lives in process memory and is read normally.
Incorrect — Every process carries an environment whatever its UID; dropping privileges neither scrubs nor hides it.
Incorrect — Docker stores environment values in clear text in the container config; there is no encryption here.
02You define a Compose file secret with long-syntax uid: "10001" and mode: 0400 and run it with plain docker compose up (no Swarm). The service, running as UID 10001, cannot read the file. Why?
Incorrect — Compose file secrets are plain bind mounts, not encrypted objects; there is no key involved.
Incorrect — The _FILE convention is how some images find the path; it is unrelated to the file's ownership on the mount.
Correct — Those keys are Swarm-only and are ignored with a warning; the reader's UID must match the host file's owner, so you fix ownership on the backing file.
Incorrect — Read-only blocks writes, not reads; a non-root user with matching ownership reads it fine.
03You moved a secret to a file at /run/secrets/db_pw, but the image's entrypoint runs export DB_PASSWORD="$(cat /run/secrets/db_pw)" before launching the app. What does that do to the protection the file gave you?
Incorrect — Once exported, the value is in the process environment regardless of where it came from.
Correct — Exporting puts the secret into the one place the file mount existed to keep it out of; the application should open the file itself.
Incorrect — Read-only blocks writes to the mount, not reads; the cat succeeds and the export runs.
Incorrect — That is backwards: an exported variable appears in /proc/<pid>/environ, while docker inspect does not show it because it was never set in the config.

Try this

Work through “Keep it out of the image, too” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

If you keep one thing from runtime secrets, done right, keep “Keep it out of the image, too”. Decide now which check you will run when this shows up on a live system, and write it somewhere your team will find it.

Related