background-shape
Environment Variables and Secrets in Docker Compose
July 8, 2022 · 4 min read · by Muhammad Amal programming

TL;DR — Use .env for compose-time substitution, env_file: to inject runtime env into containers, secrets: top-level for credentials mounted as files. Never commit .env. Never use environment: for real secrets — they show in docker 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:

  • .env at root — image versions, project name (committed)
  • .env.local per service — DB URLs, dev secrets (gitignored)
  • .env.local.example — committed template with placeholders
  • secrets/ 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.