Where a container’s environment variables end up

Start a container with -e DB_PASSWORD=hunter2 and run docker inspect on it:

1
2
docker run -d --name api -e DB_PASSWORD=hunter2 nginx:alpine
docker inspect --format '{{json .Config.Env}}' api
1
["DB_PASSWORD=hunter2","PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]

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:

MechanismSet whereLives in the image?Visible after docker inspect?
ARG in the DockerfileBuild time onlyNo (unless copied into ENV)No, but recorded in build history
ENV in the DockerfileBuild timeYes, baked into every layer after itYes
-e / --env on docker runContainer startNoYes
--env-file on docker runContainer startNoYes

At run time you pass them one flag at a time, or from a file:

1
2
3
4
5
# One at a time, repeatable
docker run -e NODE_ENV=production -e PORT=3000 my-api

# From a file, one KEY=VALUE per line, no quotes
docker run --env-file .env.production my-api

.env.production looks like this:

1
2
3
NODE_ENV=production
PORT=3000
LOG_LEVEL=info

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

  • .env in the project root is read by Compose itself, to substitute ${VARIABLE} placeholders inside compose.yaml. It never reaches the container unless you also reference it under environment:.
  • env_file: lists files whose contents are injected into the container’s environment, the same as --env-file on docker run.
  • environment: sets variables directly in the Compose file, inline.
1
2
3
4
5
6
7
services:
  api:
    image: my-api
    environment:
      - NODE_ENV=production
    env_file:
      - .env.api

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 inspect prints every runtime variable, as shown above.
  • docker exec <container> env prints 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 ENV are permanent: docker history --no-trunc my-api shows 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt

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:

1
2
echo "supersecret" | docker secret create db_password -
docker service create --name db --secret db_password postgres:16

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.

1
2
3
FROM node:20-slim
ARG NPM_TOKEN
RUN npm config set //registry.npmjs.org/:_authToken=${NPM_TOKEN} && npm install
1
2
docker build --build-arg NPM_TOKEN=npm_abc123 -t my-api .
docker history --no-trunc my-api | grep npm_abc123

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:

1
2
3
4
# syntax=docker/dockerfile:1
FROM node:20-slim
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) npm config set //registry.npmjs.org/:_authToken=${NPM_TOKEN} && npm install
1
docker buildx build --secret id=npm_token,src="$HOME/.npm_token" -t my-api .

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

SituationUse
Non-sensitive config (port, log level, feature flag)-e, --env-file, or Compose environment:/env_file:
Database password, API key, TLS key at runtimeDocker secret, mounted as a file
Auth token needed only during docker buildBuildKit --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.