Where a container’s environment variables end up
Start a container with -e DB_PASSWORD=hunter2 and run docker inspect on it:
| |
| |
The password sits there in plain text, readable by anyone with access to the Docker socket, by docker exec api env, and by any process running inside the container. That is not a bug — it is what environment variables are for. The problem is using them for values that should never be that visible.
Where to set a Docker environment variable: build time vs run time
Four mechanisms, three lifetimes. Where you set a variable decides how long it lives and who can read it back:
| Mechanism | Set where | Lives in the image? | Visible after docker inspect? |
|---|---|---|---|
ARG in the Dockerfile | Build time only | No (unless copied into ENV) | No, but recorded in build history |
ENV in the Dockerfile | Build time | Yes, baked into every layer after it | Yes |
-e / --env on docker run | Container start | No | Yes |
--env-file on docker run | Container start | No | Yes |
At run time you pass them one flag at a time, or from a file:
| |
.env.production looks like this:
| |
--env-file is the better choice once you have more than two or three variables — it keeps the run command readable and the values in one place you can diff.
Environment variables in Docker Compose: environment, env_file, and .env
Compose has three places to put variables, and two of them are files with almost the same name. .env and env_file: do completely different jobs.
.envin the project root is read by Compose itself, to substitute${VARIABLE}placeholders insidecompose.yaml. It never reaches the container unless you also reference it underenvironment:.env_file:lists files whose contents are injected into the container’s environment, the same as--env-fileondocker run.environment:sets variables directly in the Compose file, inline.
| |
When a variable is defined in more than one place, Compose resolves it in this order, highest priority first: a -e passed to docker compose run, then environment:, then env_file:, then whatever ENV the image already has baked in. If NODE_ENV shows up in both environment: and .env.api, the value in environment: wins.
Why environment variables are the wrong place for secrets
None of the mechanisms above hide a value from anything with access to the container or the host:
docker inspectprints every runtime variable, as shown above.docker exec <container> envprints them from inside.- A child process inherits the full environment, including a debugger, a crash reporter, or a dependency you didn’t audit.
- Anything that dumps its environment on startup puts the value into your log aggregator, and a surprising number of frameworks do exactly that when debug logging is on.
- Environment variables set at build time with
ENVare permanent:docker history --no-trunc my-apishows the exact value, and it stays in the image on every registry you push it to.
None of this requires an attacker to compromise the container. It needs only the access a lot of people already have: the Docker socket, the CI logs, the image registry.
How Docker secrets work: the value is mounted as a file
Stop handing credentials to the container as environment variables and mount them as files instead. Compose does this on its own, no Swarm required:
| |
Compose mounts db_password.txt at /run/secrets/db_password inside the container, read-only, and nowhere else — it never appears in docker inspect, in docker exec ... env, or in the image. The _FILE suffix is a convention the official Postgres, MySQL, and MongoDB images already support: on startup, the entrypoint script reads the file instead of expecting the value directly.
If your application doesn’t support that convention, read the file yourself at startup — one line in most languages, for example open('/run/secrets/db_password').read().strip() in Python.
On a Swarm cluster, the equivalent secret is created and distributed by the orchestrator instead of a local file:
| |
Swarm stores the secret encrypted at rest and in transit, and mounts it into a memory-backed filesystem in each replica — it is never written to the container’s writable layer.
Keeping secrets out of the image at build time
ARG and ENV in a Dockerfile have the same problem one step earlier: a value passed as a build argument is recorded in the image’s build history even if you never turn it into an ENV.
| |
| |
That docker history command finds it. The token is gone from the final filesystem if you don’t COPY the config file forward, but it is permanently readable in the image metadata, which ships with the image to every registry.
BuildKit’s --secret flag avoids this by mounting the value into a single RUN step as an in-memory file that never becomes a layer:
| |
| |
npm_token exists only for the duration of that RUN instruction and is not recorded anywhere docker history can see.
When to use an environment variable and when to use a secret
| Situation | Use |
|---|---|
| Non-sensitive config (port, log level, feature flag) | -e, --env-file, or Compose environment:/env_file: |
| Database password, API key, TLS key at runtime | Docker secret, mounted as a file |
Auth token needed only during docker build | BuildKit --secret with RUN --mount=type=secret |
| Value baked into the image on purpose (app version, build commit) | ARG copied into ENV — fine, it isn’t a secret |
One question decides it: would you mind if this value leaked? If yes, it does not go through -e, --env-file, environment:, or a Dockerfile ARG/ENV. It goes through a secret mount. The settings your app is happy to print in its own logs stay plain environment variables, which are easier to override per environment anyway.
So go look. Run docker inspect --format '{{json .Config.Env}}' against the containers you have running right now, and read the output as if it were a pull request diff. Anything in there you wouldn’t want reviewed in public belongs in a secrets: block in your Compose file, with a _FILE variable where the password used to be. If the file has to survive the container rather than come from the build context, put it on a Docker volume.