Docker & ContainersXIV Β· SecretsWhy not env vars
Why environment variables are not secrets
What you'll learn
- Demonstrate each path by which an environment variable leaks
- Distinguish the leaks a file-based secret fixes from the ones it does not
- Audit a running host for secrets already exposed this way
Prerequisites
None β start here.
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-12
Environment variables are the canonical Docker way to inject configuration. They are also the worst place for secrets.
βEnv vars leakβ is repeated constantly and demonstrated almost never, which is why it does not change behaviour. Below is each path, with the command that walks it. Run them against something you own; they are all read-only.
Leak 1: docker inspect
The containerβs environment is part of its configuration, stored by the daemon and returned by the API to anyone who can call it.
CONTAINER=api
docker inspect "$CONTAINER" --format '{{range .Config.Env}}{{println .}}{{end}}'$ docker inspect api --format '{{range .Config.Env}}{{println .}}{{end}}'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
LANG=C.UTF-8
DATABASE_URL=postgres://api:REPLACE_ME@db.example.com:5432/api
STRIPE_API_KEY=REDACTEDIllustrative output
Membership of the docker group is enough. So is any process that
can reach /var/run/docker.sock, which includes every monitoring
agent, log shipper and CI runner people mount the socket into. This
is the leak with the widest audience and it needs no exploit at all.
Note that this survives the container: docker inspect works on a
stopped container, so the credential is still readable weeks after
the workload ended, right up until somebody runs docker rm.
Leak 2: /proc/$PID/environ on the host
Container processes are host processes. They appear in the hostβs
/proc under their host PID, and /proc/$PID/environ holds the
environment the process was execβd with.
CONTAINER=api
PID=$(docker inspect --format '{{.State.Pid}}' "$CONTAINER")
tr '\0' '\n' < "/proc/$PID/environ"This one matters because it bypasses Docker entirely. A forensic
tool, a process-monitoring agent, an admin with sudo but no
docker group membership β all of them can read it, and none of
them appear in any Docker audit trail.
/proc/$PID/environ is mode 0400 owned by the process owner, so
it is readable by root and by the same UID. Inside the container,
any process running as the same user can read PID 1βs environment
even if it was started without those variables.
Leak 3: every child process inherits it
Leak 4: crash dumps and core files
A core dump is a copy of the processβs memory, and the environment strings live on the initial stack, which is inside it. So does the buffer holding the secret after the application read it.
# Where does the kernel send cores on this host?
cat /proc/sys/kernel/core_pattern
# And is the container allowed to produce one?
CONTAINER=api
docker inspect "$CONTAINER" --format '{{json .HostConfig.Ulimits}}'If core_pattern pipes to systemd-coredump, cores land in
/var/lib/systemd/coredump on the host, are readable by root, and
are collected by whatever ships that directory off the box. Set
--ulimit core=0 on containers that hold credentials.
Leak 5: the image itself
ENV in a Dockerfile writes the value into the image configuration.
It is not scoped to a build step; it is a permanent property of the
image, present in every container anyone ever runs from it, and
readable by anyone who can pull it.
ARG is not stored as a runtime variable, but the Docker
documentation is explicit that build arguments βare visible in the
docker history command and in max mode provenance attestationsβ.
IMAGE=myorg/api:1.4.0
# Baked ENV values
docker image inspect "$IMAGE" --format '{{range .Config.Env}}{{println .}}{{end}}'
# Build arguments and the commands that used them
docker image history --no-trunc "$IMAGE" | grep -iE 'arg|token|key|secret'Neither needs a running container. Push that image to a registry and the credential goes with it, to every mirror, cache and vulnerability scanner that pulls it. Deleting the tag does not help: the layers and config remain addressable by digest.
Leak 6: configuration files and their backups
# WRONG
services:
api:
environment:
DATABASE_PASSWORD: "REPLACE_ME"
API_KEY: "REDACTED"
The Compose file is in git, so the secret is in git β in history, in every clone, in every fork, and in the CI cache. Rotating it later does not remove it from the history.
# ALSO WRONG, just less obvious
services:
api:
env_file: .env.production
The .env.production file is not in git, which feels like a fix. It
is on the host, so it is in the host backup; it is copied to new
hosts by the provisioning that stands them up; and it is world-
readable more often than not because somebody had a permissions
problem once.
docker compose config renders the whole thing, interpolations
included, which is a useful audit command and an equally useful
exfiltration one.
What files actually buy you
File-based secrets are not magic, and it is worth being exact about which leaks they close.
| Leak path | Environment variable | File at /run/secrets/... |
|---|---|---|
docker inspect | Exposed | Not present |
/proc/$PID/environ | Exposed | Not present |
| Inherited by children | Always | Only if you pass the path |
| Crash dump / core file | Exposed | Exposed once read into memory |
| Baked into the image | Possible via ENV | Not possible |
Readable by docker exec | Yes, trivially | Only if file mode allows |
| Removable after use | No | Yes β the app can close and forget |
| Rotatable without recreating the container | No | Yes β rewrite the file |
The last two rows are the ones that get overlooked and they are why this matters beyond a checkbox. An environment variable is fixed for the life of the container: rotating it means recreating the container, which means a restart, which means rotation becomes a deployment. A file can be replaced underneath a running process.
Auditing a host you inherited
This is the command to run before you argue about policy. It tells you what is already exposed.
for c in $(docker ps --format '{{.Names}}'); do
hits=$(docker inspect "$c" --format '{{range .Config.Env}}{{println .}}{{end}}' | cut -d= -f1 | grep -iE 'passwd|password|secret|token|api[_-]?key|private[_-]?key|credential')
if [ -n "$hits" ]; then
printf '%s\n' "$c"
printf ' %s\n' $hits
fi
done$ ./audit-container-env.shapi
DATABASE_PASSWORD
STRIPE_API_KEY
worker
REDIS_PASSWORDIllustrative output
This is verification that can fail, and it belongs in CI as a gate on the Compose file rather than as a quarterly discovery. An empty result is a passing build.
Knowledge check
Knowledge check Β· 5 questions
Q1. Which command reads a container secret from the host WITHOUT using the Docker CLI or API at all?
Q2. Which leak makes an unhandled exception ship your credentials to a third party?
Q3. Which leak paths does moving from an environment variable to a file at /run/secrets close? Select all that apply.
Q4. Calling unsetenv() at startup reliably removes the secret from /proc/$PID/environ.
Q5. You find a password in a running container environment. What is the correct first action?
Passing score: 75%. Answers are checked in this browser.