In this blog post Mastering Docker environment variables with Docker Compose we will walk through how to use environment variables confidently across Docker and Docker Compose, and how to avoid the common gotchas.
Environment variables are a simple, time-tested way to keep configuration outside your code. With containers, they become even more useful: the same image can run in multiple environments by swapping values at runtime. In this guide, we’ll start with the big picture and then show practical steps you can copy into your projects.
High-level overview
Docker images are built once, then run many times. Build-time settings shape how an image is produced. Runtime settings configure how a container behaves when it starts. Environment variables bridge both worlds:
- At build time,
ARG
values let you parameterise the build without baking sensitive data into the image. - At runtime,
ENV
and Compose’senvironment
entries set application config inside the container. - Compose adds a
.env
file and variable substitution to keep your YAML tidy and DRY across machines.
The result: one codebase and one set of Dockerfiles can support dev, test, and production with minimal drift.
The technology behind it
Dockerfile: ARG vs ENV
ARG
exists only duringdocker build
. It’s not present at runtime unless you copy it into anENV
or your app.ENV
persists in the built image and becomes part of every container spawned from it.
Use ARG
for build toggles (e.g., base image tag, package registry), and ENV
for runtime defaults (e.g., NODE_ENV=production
).
Docker Compose: .env, environment, env_file
.env
in the Compose project directory provides values for variable substitution inside the YAML (e.g.,${IMAGE_TAG}
). These are parsed at compose-file load time.environment
sets variables inside the running container.env_file
loads key-value pairs and injects them into the container environment (similar to--env-file
withdocker run
).
Key point: variables in .env
do not automatically become container environment variables unless you reference them in environment
or include them via env_file
.
Quick start in three files
Here’s a minimal but realistic setup. It shows build-time args, runtime env, and Compose substitution.
1) .env
APP_NAME=payments-api
APP_PORT=8080
IMAGE_TAG=1.4.2
DB_HOST=db
DB_USER=app
# Do not commit real secrets to .env
DB_PASSWORD=change_me
DEBUG=false
2) Dockerfile
# syntax=docker/dockerfile:1
ARG BASE_IMAGE=node:20-alpine
FROM ${BASE_IMAGE} as runtime
# Build args are available only during build
ARG APP_NAME
ARG BUILD_DATE
# Runtime defaults (can be overridden at container start)
ENV NODE_ENV=production \
APP_NAME=${APP_NAME:-app}
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
# Example of using ARG during build steps
RUN echo "Building ${APP_NAME:-app} on ${BUILD_DATE:-unknown}" && \
npm run build
CMD ["node", "dist/server.js"]
3) docker-compose.yml
services:
api:
build:
context: .
dockerfile: Dockerfile
args:
APP_NAME: ${APP_NAME}
BASE_IMAGE: node:20-alpine
BUILD_DATE: ${BUILD_DATE:-now}
image: cloudproinc/${APP_NAME}:${IMAGE_TAG}
ports:
- "${APP_PORT}:8080"
environment:
NODE_ENV: production
DB_HOST: ${DB_HOST}
DB_USER: ${DB_USER}
# Illustrative only; prefer secrets for real passwords
DB_PASSWORD: ${DB_PASSWORD}
DEBUG: ${DEBUG:-false}
# Alternatively, inject many variables at once
# env_file:
# - ./.env
db:
image: postgres:16
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${APP_NAME}
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Run it:
docker compose up --build -d
How Compose variable substitution works
Compose supports shell-style interpolation inside the YAML. Common patterns:
${VAR}
– use value from.env
or your shell’s environment.${VAR:-default}
– usedefault
ifVAR
is unset or empty.${VAR?error}
– fail with a message ifVAR
is missing.
services:
api:
image: cloudproinc/${APP_NAME}:${IMAGE_TAG:-latest}
environment:
LOG_LEVEL: ${LOG_LEVEL:-info}
REQUIRED_TOKEN: ${REQUIRED_TOKEN?Set REQUIRED_TOKEN in your environment}
Remember: these substitutions happen when Compose parses the YAML, not at container runtime. If a value looks wrong, render the final config to inspect it:
docker compose config
Precedence and scope
Where does a container’s final environment variable value come from? Highest to lowest precedence:
- Explicit values passed at runtime via
docker run -e
or--env-file
(when not using Compose). - Compose
environment
entries. - Compose
env_file
entries. ENV
defaults in the Dockerfile image.
Separately, for Compose file interpolation, values come from (in order):
- Environment of the Compose process (your shell).
- The
.env
file located in the Compose project directory. - Defaults like
${VAR:-...}
inside the YAML.
These two ladders are often confused. Interpolation fills the YAML. Runtime precedence decides what’s inside the container.
Using docker run directly
If you’re not using Compose, you can pass vars inline or via files:
# Inline values
docker run -e APP_PORT=8080 -e DEBUG=false my-image
# From a file (KEY=VALUE per line)
docker run --env-file .env my-image
Check what the container sees:
docker run --rm -e FOO=bar alpine:3 env | sort
Handling secrets safely
Environment variables are convenient but not ideal for secrets. They may appear in docker inspect
, crash logs, or process listings inside the container. Prefer:
- Docker secrets (Swarm) or Compose secrets with files mounted at runtime.
- External secret managers (cloud KMS, Vault) with SDKs or sidecars.
Example using file-based secret with Compose:
services:
api:
image: cloudproinc/${APP_NAME}:${IMAGE_TAG}
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt
Your app reads the file at /run/secrets/db_password
instead of a plain env var.
Debugging and verification
- Render the final Compose config:
docker compose config
- Inspect a container:
docker inspect <container>
showsConfig.Env
- Print inside the container:
docker exec <container> env | sort
- Recreate with new values:
docker compose up -d --build --force-recreate
Production tips
- Keep a minimal, documented
.env.example
checked into source control; never commit real secrets. - Separate concerns: build once with
ARG
, configure many times with runtime env. - Pin base images and package versions via
ARG
for reproducibility. - Use Compose profiles or separate override files to switch stacks or turn features on/off.
- Centralise environment management in your CI/CD and pass values to Compose at deploy time.
Putting it all together
Environment variables give you a clean separation between code and configuration. Docker provides the mechanics (ARG
, ENV
), while Compose adds ergonomics (.env
, interpolation, env_file
). With a small amount of structure—defaults in the Dockerfile, environment blocks in Compose, and secrets out of env—you get portable builds, predictable runtime behavior, and a safer path to production.
Start with the three-file quick start above, verify with docker compose config
, and evolve from there. You’ll quickly find that managing configuration this way scales smoothly from a developer laptop to your production cluster.
Discover more from CPI Consulting
Subscribe to get the latest posts sent to your email.