Skip to main content
RunBook Academy

Docker & ContainersXIV Β· SecretsWhy not env vars

Why environment variables are not secrets

Foundation⏱ ~20 min

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

Not yet marked complete on this device.

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.

Read-only / Safeinspect
CONTAINER=api
docker inspect "$CONTAINER" --format '{{range .Config.Env}}{{println .}}{{end}}'
Read-only / Safeinspect output
$ 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=REDACTED

Illustrative 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.

Read-only / Safeproc environ
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.

Read-only / Safecore dump exposure
# 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”.

Read-only / Safeimage-level exposure
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 pathEnvironment variableFile at /run/secrets/...
docker inspectExposedNot present
/proc/$PID/environExposedNot present
Inherited by childrenAlwaysOnly if you pass the path
Crash dump / core fileExposedExposed once read into memory
Baked into the imagePossible via ENVNot possible
Readable by docker execYes, triviallyOnly if file mode allows
Removable after useNoYes β€” the app can close and forget
Rotatable without recreating the containerNoYes β€” 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.

Read-only / Safesecret audit
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
Read-only / Safeaudit output
$ ./audit-container-env.sh
api
DATABASE_PASSWORD
STRIPE_API_KEY
worker
REDIS_PASSWORD

Illustrative 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

  1. Q1. Which command reads a container secret from the host WITHOUT using the Docker CLI or API at all?

  2. Q2. Which leak makes an unhandled exception ship your credentials to a third party?

  3. Q3. Which leak paths does moving from an environment variable to a file at /run/secrets close? Select all that apply.

  4. Q4. Calling unsetenv() at startup reliably removes the secret from /proc/$PID/environ.

  5. 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.