Environment variables are convenient for configuration, but they are a poor home for passwords, API tokens and private keys. This lesson explains where secrets leak in a typical Docker workflow, how to keep them out of images with BuildKit secret mounts, how to deliver them to running containers as files with Compose and Swarm secrets, and how to structure applications so both approaches work.
Every one of these puts a secret somewhere it will be found later:
| Approach | Why it leaks |
|----------|--------------|
| COPY .env . or COPY .npmrc . | The file is in a layer forever, even if a later RUN rm deletes it |
| ARG API_TOKEN | Recorded in the image metadata; docker history shows it |
| ENV DB_PASSWORD=... | Visible in docker inspect and to every process in the container |
| -e DB_PASSWORD=... on the command line | Stored in shell history and visible in docker inspect |
| Committing compose.yaml with inline values | Ends up in Git history |
docker history --no-trunc my-app | grep -i token # reveals ARG and ENV values
docker inspect --format '{{.Config.Env}}' api # reveals run-time -e valuesEnvironment variables are acceptable for non-sensitive settings and, with care, for secrets injected at run time by a platform. They are never acceptable inside an image.
When a build needs a private registry token, an SSH key or a .npmrc, mount it for a single RUN step. The file exists only during that command and is not stored in any layer or in the cache:
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
RUN --mount=type=secret,id=pip,target=/etc/pip.conf pip install -r requirements.txt
RUN --mount=type=ssh git clone git@github.com:org/private-lib.gitdocker build --secret id=npmrc,src=$HOME/.npmrc -t my-app .
docker build --secret id=pip,env=PIP_CONF -t my-app . # take the value from an env var
docker build --ssh default -t my-app . # forward the local SSH agentThe id links the Dockerfile mount to the --secret flag; target sets the path inside the build. In Compose, the same is expressed under build.secrets.
Compose can mount a secret into a container at /run/secrets/<name>. The source can be a local file or an environment variable on the machine running Compose:
services:
db:
image: postgres:17
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
api:
build: .
secrets:
- db_password
- source: stripe_key
target: /run/secrets/stripe
mode: 0400
secrets:
db_password:
file: ./secrets/db_password.txt
stripe_key:
environment: STRIPE_KEYThe secrets/ directory belongs in .gitignore and .dockerignore. In Docker Swarm the same secrets: key refers to secrets stored encrypted in the cluster (docker secret create db_password -), and Kubernetes has an equivalent Secret object, so an application that reads secrets from files ports cleanly between all three.
Many official images accept a variable suffixed with _FILE that points to a file containing the value: POSTGRES_PASSWORD_FILE, MYSQL_ROOT_PASSWORD_FILE, MONGO_INITDB_ROOT_PASSWORD_FILE. Adopt the same pattern in your own code so it works with both plain variables in development and mounted files in production:
import { readFileSync } from "node:fs";
function secret(name) {
const file = process.env[`${name}_FILE`];
if (file) return readFileSync(file, "utf8").trim();
return process.env[name];
}
const dbPassword = secret("DB_PASSWORD");.env.example with placeholder values in Git, and the real .env out of it; Compose reads .env automatically for variable interpolation.--secret id=...,env=VAR, never by writing them into the Dockerfile.Why does `COPY .env .` followed by `RUN rm .env` still leak the file?
COPY, ARG and ENV all leave secrets recoverable from the image; environment variables at run time are visible in docker inspect.RUN --mount=type=secret with docker build --secret for build-time credentials and --ssh for private Git.secrets: mounts files under /run/secrets/; the same key works with Swarm's encrypted store._FILE convention in your application so it reads secrets from files or variables..env out of Git and out of the build context, and rotate anything that was ever committed.Next lesson: Docker Compose for Multi-Container Apps — define and run an entire application stack with one file.