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: 17fce9c7fa38c12c9e07116dc021e0061ca617a8b1649ce7419a2d5b4ec03d32

If you pass a password with -e DB_PASSWORD=..., anyone who can reach the daemon can read it, whatever else you did to the container. "Environment variables and configuration" (Docker for beginners) already warned that environment values sit in the container config, where tooling reads them back. Here you'll see exactly where a secret passed that way turns up. Then you'll move it to a file and meet 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.9.2
$ docker run -d --name lab-envleak -e DB_PASSWORD=lab-fake-password-env alpine:3.24 sleep 600
7b08d3b0c304a6c94d6392f3631b9182f23af59e9ed72d8a03bb1c0cfe77b2e6
$ 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. "The Docker socket and daemon hardening" shows that's the same as root on the host. /proc/<pid>/environ is the environment block the process was handed at start. It's read from the process's memory, and host root can read it. If your code unsets the variable later, that block isn't cleared. 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 that are easy to forget: a crash that dumps the environment, and the log it lands in.

ubuntu@secopslog-docker:~ · Docker 29.9.2
$ docker run --name lab-crash -e DB_PASSWORD=lab-fake-password-env alpine:3.24 \ 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=e21984f82baf 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. Now the secret sits on disk in the clear, and grep counts it once. An error tracker that attaches the process environment to every exception report forwards it the same way. Before you change 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.9.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 doesn't 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 and list it on the service. Compose then mounts it read-only under /run/secrets/. Many official images already expect a file through the *_FILE convention. 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.24
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.9.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 26 13:12 db_password.txt
$ docker compose --progress quiet up -d --wait db docker compose --progress quiet up -d report
time="2026-10-26T13:12:41+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-26T13:12:41+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-26T13:12:41+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-26T13:12:46+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-26T13:12:46+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-26T13:12:46+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 couldn't read the file. Its process runs as UID 10001, and the mounted file is owned by the host user that created it. That's the first thing to understand about Compose file secrets outside Swarm. The long-syntax uid, gid and mode keys are Swarm-only, and Compose ignores them with a warning (you can see it 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.9.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 26 07:42 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/00fe4b71ba41764d31d1d5e839ceff70f00a811eb2a9223dc09c5274335503dc/_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 182811: 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. What you bought is "not in the environment", 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, which is a path. The host's view of the postgres process environment holds no password. The entrypoint reads the file and exports the value into its own short-lived init process. Then it 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.9.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-26T13:12:49+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-26T13:12:49+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-26T13:12:49+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 won't 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.9.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-26T13:12:52+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-26T13:12:52+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-26T13:12:52+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-26T13:12:53+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-26T13:12:53+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-26T13:12:53+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-26T13:12:55+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-26T13:12:55+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-26T13:12:55+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 you rotate the file and restart, a client using the file's new contents is rejected. That's because postgres set its password from the file only when it first initialised its data directory. Changing a secret file isn't rotation on its own. Real rotation needs the credential changed at the source (the database, the cloud IAM system), and the consumer has to re-read it. That's why the stronger pattern keeps the source of truth outside the container. A secrets manager such as HashiCorp Vault issues short-lived credentials. 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.9.2
$ docker run -d --name lab-export -v "$PWD/secrets/db_password.txt:/run/secrets/db_password:ro" alpine:3.24 \ 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_
83aecdf4d62c4ee881a17bdc7332b25867feccb0e2ee776bd1997e1126b52943 ["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. You should know it's 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 doesn't, because it never touches the layer:

ubuntu@secopslog-docker:~ · Docker 29.9.2
$ docker run -d --name lab-fetch alpine:3.24 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
fc5c47813fa528ac4771a34cdcc121a0e60b95b694037201375a1530e195056a 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.24 \ 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)"
9099c4a290867ca858e4930f93a0d8683ea2fce1e2fc47d38450ef193aa2c0a8 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. Here docker diff shows only the mountpoint /run/secrets being created (C /run, A /run/secrets), not the token. 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 aren't on one. They're 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 it's 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. Stripping the image does nothing for a password in the container config that docker inspect prints.

ubuntu@secopslog-docker:~/lab/runsecrets · Docker 29.9.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

Try this

On the main lab VM, start a fresh container from alpine:3.24 with a fake value in -e DB_PASSWORD=... and --read-only, plus a non-root --user 10001. Before you read anything back, predict which of the reads from the first section still show the value. Then run the same docker inspect -f '{{json .Config.Env}}' and the host-side /proc/$P/environ read, and compare with the output above. Remove the container with docker rm -f when you're done.

Takeaway

A secret in -e is readable from docker inspect, from host /proc/<pid>/environ and from the JSON log file once a crash prints it, and no in-container hardening changes that. Moving it to a file only helps until something reads it back into the environment: the one-line export brought the value straight back into /proc/<pid>/environ. In reviews, check the entrypoint and the image's own _FILE handling too, since MySQL keeps the password in its environment where postgres unsets it.

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.

Related