Docker Environment Variables — .env Files, Secrets, and Config Management
You hard-coded the database password in the Dockerfile. It worked in development. Then someone pushed the image to a public registry. Now your database password is on the internet forever, baked into an immutable image layer that docker history will happily reveal to anyone. Environment variables done right prevent this entire class of mistakes.
ENV in Dockerfile vs Runtime -e Flag
There are two fundamentally different ways to set environment variables, and confusing them causes real problems.
Build-time (ENV in Dockerfile): Baked into the image. Every container from this image inherits these values. Visible in docker inspect and docker history.
FROM node:20-alpine
WORKDIR /app
# These are defaults — baked into the image
ENV NODE_ENV=production
ENV PORT=3000
COPY . .
RUN npm ci --only=production
CMD ["node", "server.js"]
Runtime (-e flag or environment in Compose): Set when the container starts. Override anything set in the Dockerfile. Not stored in the image.
# Override ENV values at runtime
docker run -d \
-e NODE_ENV=staging \
-e PORT=8080 \
-e DATABASE_URL=postgresql://db:5432/myapp \
myapp:latest
| Method | When Set | Stored In Image | Visible In | Use For |
|---|---|---|---|---|
ENV in Dockerfile | Build time | Yes | docker history, docker inspect | Defaults (PORT, NODE_ENV) |
-e flag | Run time | No | docker inspect only | Configuration, credentials |
--env-file | Run time | No | docker inspect only | Multiple variables |
The rule is simple: use ENV for non-sensitive defaults. Use runtime variables for everything else.
.env Files with docker run
When you have more than three or four variables, passing them individually with -e gets unwieldy. Use an env file.
# app.env
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://postgres:secret@db:5432/myapp
REDIS_URL=redis://redis:6379
LOG_LEVEL=info
JWT_SECRET=my-jwt-secret-key
docker run -d --env-file app.env myapp:latest
# Verify the variables are set
docker exec myapp env | sort
# DATABASE_URL=postgresql://postgres:secret@db:5432/myapp
# LOG_LEVEL=info
# NODE_ENV=production
# PORT=3000
# ...
Important: .env files should never be committed to version control. Add them to .gitignore and .dockerignore.
# .gitignore
.env
*.env
!.env.example
Keep a .env.example file with placeholder values so developers know what variables are needed:
# .env.example (committed to git)
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://postgres:password@localhost:5432/myapp
REDIS_URL=redis://localhost:6379
LOG_LEVEL=debug
JWT_SECRET=change-me-in-production
Docker Compose Environment Variables
Compose offers several ways to inject environment variables, and they have a specific precedence order.
Inline environment
# docker-compose.yml
services:
api:
image: myapp:latest
environment:
- NODE_ENV=production
- PORT=3000
- DATABASE_URL=postgresql://db:5432/myapp
env_file directive
services:
api:
image: myapp:latest
env_file:
- ./common.env
- ./api.env
environment:
- NODE_ENV=production # This overrides anything in the env files
Variable substitution
Compose can reference variables from the shell or from a .env file in the project root.
# docker-compose.yml
services:
api:
image: myapp:${APP_VERSION:-latest}
environment:
- DATABASE_URL=${DATABASE_URL}
- PORT=${API_PORT:-3000}
# .env (in the same directory as docker-compose.yml)
APP_VERSION=v2.1.0
DATABASE_URL=postgresql://db:5432/myapp
API_PORT=8080
# Verify what Compose will use
docker compose config
Precedence Order
When the same variable is set in multiple places, Compose uses this precedence (highest wins):
- Shell environment variables
environmentdirective in compose fileenv_filedirective in compose fileENVin Dockerfile
# Shell env overrides everything
export DATABASE_URL=postgresql://override:5432/myapp
docker compose up -d
# Container gets the shell value, not the compose file value
environment vs env_file
| Feature | environment | env_file |
|---|---|---|
| Defined in | docker-compose.yml | External .env file |
| Variable substitution | Yes | No |
| Visible in compose file | Yes | No (just filename) |
| Supports comments | No | Yes (# comments) |
| Best for | Small number of vars, per-service overrides | Large number of vars, shared config |
ARG: Build-Time Variables
ARG sets variables available only during the build process. They do not exist in the running container.
FROM node:20-alpine
# ARG is available during build only
ARG NPM_TOKEN
ARG BUILD_VERSION=unknown
WORKDIR /app
# Use ARG during build
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc && \
npm ci && \
rm .npmrc
# Convert ARG to ENV if needed at runtime
ENV APP_VERSION=${BUILD_VERSION}
COPY . .
CMD ["node", "server.js"]
docker build \
--build-arg NPM_TOKEN=abc123 \
--build-arg BUILD_VERSION=v2.1.0 \
-t myapp:v2.1.0 .
| Feature | ARG | ENV |
|---|---|---|
| Available during build | Yes | Yes |
| Available at runtime | No | Yes |
| Visible in docker history | Yes (caution!) | Yes |
| Overridable at build | --build-arg | No |
| Overridable at run | N/A | -e flag |
| Default value | ARG NAME=default | ENV NAME=default |
| Scope | From declaration to end of stage | Entire image |
Critical warning: ARG values appear in docker history. Never use ARG for secrets — use BuildKit secret mounts instead.
# Anyone can see ARG values
docker history myapp:latest
# ... --build-arg NPM_TOKEN=abc123 ...
# BuildKit secrets are NOT visible in history
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
Secrets in Docker Swarm
Docker Swarm has first-class secrets management. Secrets are encrypted at rest, transmitted over TLS, and mounted as in-memory files — never as environment variables.
# Create secrets
echo "supersecret" | docker secret create db_password -
echo "jwt-key-here" | docker secret create jwt_secret -
# List secrets
docker secret ls
# Use in a service
docker service create --name api \
--secret db_password \
--secret jwt_secret \
myapp:latest
# Application reads secret from file
import os
def get_secret(name):
"""Read a Docker secret from /run/secrets/"""
secret_path = f"/run/secrets/{name}"
try:
with open(secret_path, 'r') as f:
return f.read().strip()
except FileNotFoundError:
# Fall back to environment variable for local development
return os.environ.get(name.upper())
db_password = get_secret("db_password")
Docker Configs
Docker configs are like secrets but for non-sensitive configuration files — nginx configs, application config files, etc. They are not encrypted but offer the same deployment convenience.
# Create a config
docker config create nginx_conf ./nginx.conf
# Use in a service
docker service create --name web \
--config source=nginx_conf,target=/etc/nginx/nginx.conf \
nginx:alpine
12-Factor App Configuration
The 12-factor app methodology states that configuration should be stored in the environment, not in code. Docker makes this natural.
# docker-compose.yml — 12-factor style
services:
api:
build: .
environment:
# Factor III: Store config in the environment
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- PORT=3000
- LOG_LEVEL=${LOG_LEVEL:-info}
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:3000}
env_file:
- .env.${DEPLOY_ENV:-development}
Multi-Environment Setup
Use separate env files for each environment and select at deploy time.
# .env.development
NODE_ENV=development
DATABASE_URL=postgresql://postgres:devpass@localhost:5432/myapp_dev
LOG_LEVEL=debug
CORS_ORIGIN=http://localhost:3000
# .env.staging
NODE_ENV=staging
DATABASE_URL=postgresql://postgres:stagingpass@staging-db:5432/myapp_staging
LOG_LEVEL=info
CORS_ORIGIN=https://staging.myapp.com
# .env.production
NODE_ENV=production
DATABASE_URL=postgresql://postgres:prodpass@prod-db:5432/myapp_prod
LOG_LEVEL=warn
CORS_ORIGIN=https://myapp.com
# docker-compose.yml
services:
api:
build: .
env_file:
- .env.${DEPLOY_ENV:-development}
environment:
- NODE_ENV=${DEPLOY_ENV:-development}
# Deploy to staging
DEPLOY_ENV=staging docker compose up -d
# Deploy to production
DEPLOY_ENV=production docker compose up -d
Never Bake Secrets Into Images
This point deserves its own section because it is the most common and most dangerous mistake.
# NEVER DO THIS — secret is in the image forever
FROM node:20-alpine
COPY .env /app/.env
ENV DATABASE_PASSWORD=mysecret
# Anyone with the image can extract secrets
docker history myapp:latest --no-trunc
# Step 3: ENV DATABASE_PASSWORD=mysecret
docker run --rm myapp:latest cat /app/.env
# DATABASE_PASSWORD=mysecret
Even if you delete the file in a later layer, it still exists in the previous layer. Image layers are immutable and additive. The only safe approaches are:
- Pass at runtime with
-eor--env-file - Use Docker secrets (Swarm) or secret management tools
- Use BuildKit secret mounts for build-time secrets
Wrapping Up
Environment variables are the bridge between your immutable Docker image and the specific environment it runs in. Use ENV for non-sensitive defaults, runtime -e flags for configuration, --env-file for convenience, ARG for build-time parameters, and Docker secrets for credentials. Never commit .env files to git, never bake secrets into images, and always provide a .env.example so your team knows what configuration is needed.
In the next post, we will cover Docker in CI/CD — building, testing, and pushing images automatically with GitHub Actions, GitLab CI, and Jenkins, including layer caching strategies and multi-platform builds.
