What is it? #
Containers are configured with environment variables. The same image runs in development, staging and production with different values.
That is the core discipline: configuration comes from outside the image, so one build is promoted through environments rather than rebuilt per environment.
Secrets need more care. Build arguments are visible in image history, environment variables are visible to anyone who can inspect the container, and both are easy to commit by accident.
For anything genuinely sensitive, file-based secrets are better than environment variables, because they are not exposed in process inspection or crash dumps.
Think of it like this #
A machine with a settings dial rather than a fixed factory configuration.
The same machine works in every workshop. Where it gets awkward is the key to the safe: writing it on the dial where everyone can read it defeats the purpose.
Simple example #
An application image built once, run locally with development values, in CI with test values and in production with real credentials — no rebuild between them.
Code #
# Passing variables at run time
docker run -e LOG_LEVEL=debug -e PAGE_SIZE=20 myapp
docker run --env-file .env.production myapp # a file of KEY=value lines
# In Compose
services:
app:
image: myapp:1.4.2
env_file:
- .env # not committed
environment:
LOG_LEVEL: info # non-sensitive, fine inline
DATABASE_URL: postgresql://appuser:${DB_PASSWORD}@db:5432/shop
# ENV in the Dockerfile: defaults and non-secret settings ONLY
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
LOG_LEVEL=info
# NEVER do this — it is baked into the image and visible in history
# ENV API_KEY=sk_live_abc123
# Build-time secrets, done correctly
# BAD: ARG values remain visible in the image history
# ARG NPM_TOKEN
# RUN npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN
# GOOD: a build secret, mounted only for that instruction
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
# docker build --secret id=npm_token,src=./npm_token.txt -t myapp .
# File-based secrets in Compose — safer than environment variables
services:
app:
image: myapp:1.4.2
secrets:
- db_password
environment:
DB_PASSWORD_FILE: /run/secrets/db_password # the app reads the file
secrets:
db_password:
file: ./secrets/db_password.txt # 600, not committed
Where environment variables are visible
docker inspect <container> shows every variable
docker exec <container> env shows them
/proc/<pid>/environ on the host readable by root
crash dumps and error reports often include the environment
child processes inherit them
File-based secrets avoid all of these: the value is read from a file
with restricted permissions and never enters the process environment.
How it works #
Environment variables set at run time override those in the image, which is what allows one image to behave differently per environment.
ENV in a Dockerfile is baked into the image. It is appropriate for defaults and runtime behaviour flags, and never for credentials — anyone who can pull the image can read them.
Build arguments are worse than they look. ARG values appear in docker history, so a token passed that way is recoverable from the image even though it is not in the final filesystem.
The --mount=type=secret form makes the value available only during that instruction and never writes it into a layer. It is the correct mechanism for private registry tokens during a build.
File-based secrets mount the value as a file inside the container. The application reads the file, so the secret never appears in docker inspect or in the process environment — which matters because crash reporters frequently capture the environment.
The convention of DB_PASSWORD_FILE pointing at a path, rather than DB_PASSWORD holding the value, is common in official images and is worth supporting in your own.
Variable substitution in Compose, ${DB_PASSWORD}, reads from the shell or a .env file at the project root. That file must be git-ignored.
Real-world use #
The build-once, configure-per-environment discipline is what makes promotion pipelines work: the exact artefact tested in staging is the one deployed to production.
Committed secrets remain among the most common real incidents. Scanners monitor public repositories constantly and credentials are abused within minutes.
Orchestrators provide proper secret management — mounted as files, encrypted at rest, with access control and rotation. Compose secrets are a simpler version of the same idea.
Cloud secret managers go further with automatic rotation and audit logging, injecting values at container start.
Whichever mechanism you use, the rules are constant: never in the image, never in git, and rotate when exposed.
Common mistakes #
- Putting secrets in ENV or ARG, where they persist in image history.
- Committing a .env file with real credentials.
- Assuming environment variables are private — they are visible to inspection.
- Rebuilding the image per environment instead of configuring at run time.
- Logging the full environment during startup or in error reports.
Practice #
Build an image with sensible ENV defaults and no secrets. Run it with an env file for configuration, then convert the most sensitive value to a file-based secret and confirm it no longer appears in the container environment.