Docker: Environment Variables and Secrets
How to feed configuration into Docker containers without baking it into the image — ENV vs ARG, --env-file, Compose env, BuildKit secret mounts, and Docker Swarm secrets, with safe defaults.
What you'll learn
- ✓The difference between ENV and ARG in a Dockerfile
- ✓How to pass environment variables at run time with --env-file and Compose
- ✓BuildKit secret mounts for credentials needed only at build time
- ✓A short tour of Docker Swarm secrets for production
- ✓What you should never bake into an image and how to keep it out
Prerequisites
- •You have written a Dockerfile and built an image — see Dockerfile Basics
- •You have used docker compose at least once — see Docker Compose
Application configuration belongs outside the image. The same image should be able to run in development, staging, and production with different database URLs, log levels, and API keys. The mechanism Docker gives you for this is environment variables, plus a small set of dedicated tools for the values that must stay secret.
ENV vs ARG
A Dockerfile has two ways to declare a variable, and they answer different questions.
# ARG — build-time only
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
# ENV — present at build time AND inside the running container
ENV NODE_ENV=production
ARG is a build-time variable. It is visible to the Dockerfile during docker build and disappears at the end of the build. You can override it with --build-arg:
docker build --build-arg NODE_VERSION=22 -t my-app .
ENV is set in the image and present inside every container started from it. Application code can read it with process.env.NODE_ENV, os.environ['NODE_ENV'], and so on.
A practical rule: use ARG for things that affect how the image is built (versions, feature flags for RUN steps). Use ENV for things the application reads at runtime.
A worked Dockerfile
# Build-time only
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
# Defaults the container ships with — override at run time
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["node", "server.js"]
NODE_ENV and PORT get sensible defaults baked into the image. Anyone running it can override either without rebuilding.
Passing variables at run time
There are three main ways to inject env vars when you start a container.
-e and —env
docker run -e DATABASE_URL=postgres://... -e LOG_LEVEL=debug my-app
Quick for one-off testing. Tedious past three variables, and credentials end up in your shell history.
—env-file
Put your variables in a file:
# .env.dev
DATABASE_URL=postgres://user:pass@db:5432/app
LOG_LEVEL=debug
FEATURE_FLAG_BETA=true
Then:
docker run --env-file .env.dev my-app
The format is one KEY=value per line, no quoting, no shell expansion. This is the most common way to run a container with realistic config.
Always add .env* to .gitignore. A leaked .env is one of the most common security incidents in the wild.
environment in Compose
In compose.yaml, the environment: key sets variables on a service:
services:
api:
image: my-app
environment:
DATABASE_URL: postgres://app:secret@db:5432/app
LOG_LEVEL: info
You can also point Compose at a file:
services:
api:
image: my-app
env_file:
- .env.shared
- .env.local
Later files override earlier ones, so .env.local (developer-specific) wins.
Compose interpolation
Compose itself reads a .env file in the same directory as compose.yaml and uses it to interpolate ${VAR} placeholders in the YAML:
services:
api:
image: my-app:${TAG:-latest}
environment:
DATABASE_URL: ${DATABASE_URL}
Note the subtle distinction. There are two different “.env” mechanisms:
env_file:loads variables into the container.- The top-level
.envfile loads variables into Compose itself for interpolation.
They are independent. Both are useful.
Try it yourself. Write a tiny Node script that prints process.env.MESSAGE. Build it into an image. Run it with docker run -e MESSAGE=hello and confirm the output. Then create a .env.test file with MESSAGE=fromfile and run with --env-file .env.test. Then add the image to a compose.yaml and set MESSAGE via environment:. Confirm all three paths work.
What NOT to bake into images
A Docker image is a static, distributable artifact. Anyone who can pull it can read every layer. Therefore:
- Never
COPY .envinto an image. That file goes onto every developer’s machine and possibly your registry. Use runtime injection instead. - Never
RUN echo "API_KEY=..." > /etc/secrets. It is permanently in a layer; deleting it in a later layer does not erase it. - Never
ARG SECRETfollowed by using it in aRUN. TheARGvalue is recorded in the image history (docker history) in plain text. - Never check secrets into Git even briefly. Treat any leaked secret as compromised and rotate it.
A practical mental model: anything that should be different in dev vs prod should arrive at run time. Anything sensitive should arrive at run time through a secret mechanism, not as an environment variable on the image itself.
BuildKit secret mounts
Some builds genuinely need a secret — fetching a private npm package, downloading from a private S3 bucket, signing a binary. BuildKit (Docker’s modern builder, enabled by default) provides a clean way to do this without leaking the secret into the image.
In your Dockerfile:
# syntax=docker/dockerfile:1.7
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]
The --mount=type=secret line mounts a file only during that RUN step. The file does not appear in any layer. Build it like this:
DOCKER_BUILDKIT=1 docker build \
--secret id=npmrc,src=$HOME/.npmrc \
-t my-app .
docker history for the resulting image will show the RUN step but not the secret content. This is the right way to handle build-time credentials.
Docker Swarm secrets (briefly)
If you run a small fleet with Docker Swarm, it has a first-class secret store:
echo "supersecret" | docker secret create db_password -
Reference it in compose.yaml for a Swarm stack:
services:
api:
image: my-app
secrets:
- db_password
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
secrets:
db_password:
external: true
The secret is mounted as a file at /run/secrets/db_password inside the container. Your app reads the file, not an environment variable. Kubernetes Secrets follow exactly the same pattern.
For local development with plain docker compose up, Swarm secrets work in a limited form (file-based), but you will more commonly stick with env_file.
The _FILE convention
Many official images (Postgres, MySQL, Redis) accept VAR_FILE alongside VAR:
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
The image reads the secret out of the file at startup. This avoids putting the secret in the container’s environment — where any other process inside the container, plus docker inspect, could read it.
Build this pattern into your own images. A few lines at the top of your entrypoint:
#!/bin/sh
if [ -n "$DB_PASSWORD_FILE" ] && [ -z "$DB_PASSWORD" ]; then
export DB_PASSWORD="$(cat "$DB_PASSWORD_FILE")"
fi
exec "$@"
Now your image works with both DB_PASSWORD (dev) and DB_PASSWORD_FILE (prod, via mounted secret).
Try it yourself. Take a small image you have built. Add the _FILE shim above as entrypoint.sh, set it as the ENTRYPOINT, and verify that both -e DB_PASSWORD=plain and a mounted DB_PASSWORD_FILE produce the same behaviour inside the app.
Inspecting and debugging
A handful of commands you will reach for constantly.
# See an image's env defaults (and its full history)
docker inspect my-app --format '{{json .Config.Env}}'
# See what is actually set inside a running container
docker exec my-container env | sort
# Confirm a Compose file resolves to what you expect
docker compose config
docker compose config is especially useful — it prints the fully-resolved YAML with interpolation applied, so you can see exactly which value is reaching each service.
A safe defaults checklist
A short list to keep open while writing Dockerfiles and Compose files:
.env,.env.*, andsecrets/are in.gitignore. Always. Day one.ARGis for build-time-only values;ENVis for runtime. Never the other way around.- Secrets at build time go through
--mount=type=secret, notARG. - Production secrets go through a real secret store (Swarm secrets, Kubernetes Secrets, AWS Secrets Manager, HashiCorp Vault) — not
--env-file. - Images carry safe defaults. No real credentials. The image should be runnable by a stranger.
- Rotate any secret that ever lands in Git or chat. Speed matters; assume the worst.
Following this list prevents the long tail of “we leaked a key in 2023” stories that haunt every team.
Recap
You now know:
ARGis build-time only;ENVpersists into the running container- Pass runtime config with
-e,--env-file, or Compose’senvironment:/env_file:keys - Compose has two different
.envmechanisms — one for the container, one for YAML interpolation - Never bake secrets into image layers,
ARG, or committed files - BuildKit
--mount=type=secrethandles build-time credentials safely - Swarm/Kubernetes secrets plus the
_FILEconvention are the production patterns
Next steps
With configuration handled, the last piece for collaborative work is keeping a clean Git history — which is where rebase shines if you know its rules.
→ Next: Git Rebase Explained: When to Rebase and When to Merge
Questions or feedback? Email codeloomdevv@gmail.com.