Environment Variables and Secrets in Docker Compose
TL;DR — Use
.envfor compose-time substitution,env_file:to inject runtime env into containers,secrets:top-level for credentials mounted as files. Never commit.env. Never useenvironment:for real secrets — they show indocker inspect.
After profiles, the other axis of compose configuration: env vars and secrets. Three mechanisms; each has a specific purpose. Mixing them up is how secrets end up in commit history.
.env at the project root
The .env file next to your compose file does compose-time substitution — variable interpolation in the compose file itself:
# .env
POSTGRES_VERSION=14
APP_PORT=8080
# docker-compose.yml
services:
postgres:
image: postgres:${POSTGRES_VERSION}-alpine
api:
ports: ["${APP_PORT}:8080"]
docker compose up reads .env, substitutes into the YAML before parsing. Values not present in .env come from your shell environment.
Use .env for:
- Image tags / versions
- Port mappings that differ per developer
- Project name overrides
Don’t put secrets here unless you’re committed to gitignoring it AND using only safe values.
env_file: runtime env injection
A different mechanism. env_file: injects variables into the container at runtime:
services:
api:
env_file:
- .env.local
# OR explicit list
environment:
DB_URL: postgres://app:app@postgres/app
LOG_LEVEL: debug
Variables in env_file are passed to the container process. The application reads them via process.env, os.Getenv, etc.
.env.local is what most apps use:
# .env.local — committed example, real values per developer
DATABASE_URL=postgres://app:app@postgres:5432/app
REDIS_URL=redis://redis:6379
LOG_LEVEL=debug
JWT_SECRET=local-dev-only-not-real
STRIPE_SECRET=sk_test_xxx
Add to .gitignore:
.env.local
.env.*.local
Commit .env.local.example with placeholders so new developers know what to fill in.
The difference between .env and env_file
Easy to confuse. Both look like env files. Different uses:
| Aspect | .env (root) |
env_file: |
|---|---|---|
| When read | Compose parse time | Container start |
| Purpose | Substitute into YAML | Inject into container |
| Default location | ./.env |
Path you specify |
| Use for | Versions, ports | DB URLs, API keys |
Both can have secrets. Both should be gitignored. Different lifecycles.
environment: vs env_file:
Inline env in compose:
services:
api:
environment:
LOG_LEVEL: debug
JWT_SECRET: hardcoded-bad-idea
These work. They also show up in docker inspect. Anyone with read access to your Docker socket can read them.
For non-secrets (LOG_LEVEL, FEATURE_FLAG_X), inline is fine.
For secrets (API keys, DB passwords), prefer env_file: from a gitignored file, OR the secrets: mechanism below.
secrets: top-level (the proper way)
Compose v3.1+ supports the secrets mechanism:
services:
api:
secrets:
- source: stripe_secret
target: STRIPE_SECRET
- source: db_password
target: /run/secrets/db_password
secrets:
stripe_secret:
file: ./secrets/stripe.txt
db_password:
external: true
name: my_stack_db_password
The secret is mounted into the container as a file at /run/secrets/<name>. The application reads the file. This avoids:
- Secrets in env vars (visible in inspect)
- Secrets in image layers
- Secrets in process env (visible in /proc)
For most local dev, the overhead is more than it’s worth — just use .env.local. For production-adjacent stacks (staging, demo envs), secrets-as-files is correct.
Variable defaults
In compose YAML you can default values:
services:
api:
image: my-api:${TAG:-latest} # default to "latest"
environment:
LOG_LEVEL: ${LOG_LEVEL:-info} # default to "info"
${VAR:-default} uses default if VAR is unset or empty. ${VAR-default} uses default only if unset (empty is allowed).
Useful for compose files that should work with minimal .env.
Required variables
services:
api:
image: my-api:${TAG:?TAG must be set}
${VAR:?error} — error if VAR is unset or empty. Compose refuses to start. Good for variables that genuinely need a value.
Multi-env: dev / staging / prod
Don’t run production from compose. For local + CI + staging, three patterns:
Pattern A — env_file per env:
services:
api:
env_file: .env.${APP_ENV:-local}
Bring up with APP_ENV=staging docker compose up. Reads .env.staging.
Pattern B — override files:
docker-compose.yml
docker-compose.staging.yml
docker-compose.ci.yml
docker compose -f docker-compose.yml -f docker-compose.staging.yml up
Pattern C — separate compose files entirely.
compose/dev/docker-compose.yml
compose/staging/docker-compose.yml
Pick one; document it.
What I do
For my stacks:
.envat root — image versions, project name (committed).env.localper service — DB URLs, dev secrets (gitignored).env.local.example— committed template with placeholderssecrets/directory for staging/demo envs (gitignored)
For production, all the above is irrelevant. Production reads from a secrets manager (Vault, AWS Secrets Manager, GCP Secret Manager) via runtime injection at the orchestrator layer (Kubernetes, ECS).
Common Pitfalls
Committing .env. Most-common leak. .gitignore it from day one.
Hardcoded secrets in environment:. Visible in docker inspect. Treat as leaked.
Different secret values in code review. “Update your local .env” PRs are a smell. Use external secret management even for staging.
COMPOSE_PROJECT_NAME collision. Two projects with same name share networks/volumes. Use explicit name: in compose file.
Forgetting ${VAR:-default} syntax. ${VAR-default} (no colon) doesn’t substitute empty strings. Subtle bug.
Secrets in compose history. docker compose config prints fully-substituted compose YAML. Run in CI; output goes to logs. Audit.
Wrapping Up
Three mechanisms: .env for compose-time, env_file: for runtime container env, secrets: for file-mounted credentials. Each has a place. Monday: healthchecks and depends_on conditions — the boot-order story.