Docker Intermediate #5: Environment Variables and Secrets

8 min read

We’ve been casually feeding env vars and secret values via -e / environment:. This post pulls that thread out on its own — how to inject them, and how to keep them from leaking.

This post in the Docker Intermediate series:

Where env vars come from #

Env vars reach a Docker container through more paths than you might expect. All in one place:

Sources of env vars
                          ┌─────────────────────────┐
[Dockerfile]              │                         │
  ENV KEY=value ────────▶ │                         │
                          │                         │
[docker run]              │     process inside the  │
  -e KEY=val      ──────▶ │     container           │
  --env-file file ──────▶ │     KEY env var         │
                          │                         │
[compose.yaml]            │                         │
  environment:    ──────▶ │                         │
  env_file:       ──────▶ │                         │
                          │                         │
[host shell]              │                         │
  $KEY (interp)   ──────▶ │                         │
                          └─────────────────────────┘

When the same variable is defined in several places, the later one wins. The order is environment: > env_file: > Dockerfile ENV.

Keep that in mind and 90% of “the env var didn’t apply” is solved.

.env file — the most common entry point #

If a .env file sits next to your compose file, Compose reads it automatically and uses it for ${VAR} interpolation inside compose.yaml.

.env
POSTGRES_PASSWORD=secret
APP_VERSION=1.0.0
DB_HOST=pg
compose.yaml
services:
  pg:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
  web:
    image: myapp:${APP_VERSION}
    environment:
      DB_HOST: ${DB_HOST}
      DB_PASSWORD: ${POSTGRES_PASSWORD}

Two facts to pin down:

  1. That .env is for compose.yaml interpolation. It isn’t auto-injected into containers.
  2. To make values reach the container, list them under environment: / env_file:.

Interpolation syntax #

Interpolation forms
${VAR}                   # plain reference; missing → empty
${VAR:-default}          # if VAR is unset or empty → default
${VAR-default}           # only if VAR is undefined → default (empty stays empty)
${VAR:?error message}    # if VAR is unset or empty → error
${VAR?error message}     # if VAR is undefined → error

A common production pattern:

Avoid silent misconfig
services:
  pg:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}

If .env is missing, compose up errors out immediately — no silent boot with an empty password.

environment: vs. env_file: #

Two ways to inject vars into a service.

environment — inline
services:
  web:
    environment:
      DEBUG: "1"
      DB_HOST: pg
      DB_PASSWORD: ${POSTGRES_PASSWORD}
env_file — from a file
services:
  web:
    env_file:
      - .env.web
      - .env.local
.env.web
DEBUG=1
DB_HOST=pg
LOG_LEVEL=info
environmentenv_file
Defined whereInside compose.yamlSeparate file
When you have manyBecomes longStays clean
InterpolationYes (${VAR})No — literal
PrecedenceHigher than env_fileLower than environment

If you list multiple env_files, later ones override earlier ones, and environment: overrides them all. Keep that order in mind.

Common confusion: KEY=${OTHER_KEY} interpolation doesn’t work inside an env_file. It’s a plain dotenv file — every value is read literally. For interpolation, use environment:.

Variable precedence in one table #

When the same variable lives in multiple places, the highest-listed source wins.

RankSource
1 (strongest)docker compose run -e KEY=val (CLI explicit)
2compose.yaml environment:
3compose.yaml env_file:
4The host shell’s env (when compose runs)
5.env file (compose.yaml interpolation — not direct injection)
6Dockerfile ENV

docker compose config shows the merged result and resolves confusion fast.

Secret values via env vars — how far does it scale? #

DB passwords, API keys, JWT secrets — passing them via env vars is common, but the security level dictates a different tool.

LevelRight tool
Dev / local.env file (don’t commit, .gitignore)
CI / small opsCI’s secret store, compose environment:
Production (moderate)compose secrets: or K8s Secret
Production (high)AWS Secrets Manager, Vault, GCP Secret Manager — external managers

This post focuses on the first two; the rest gets a paragraph at the end.

Securing .env.gitignore is the first line #

Most secret leaks come from simple oversights.

.gitignore — must-have lines
.env
.env.*
!.env.example

.env.example is the template with key names only — a guide for new clones to fill in.

.env.example (commit OK)
POSTGRES_PASSWORD=
DJANGO_SECRET_KEY=
SENTRY_DSN=
.env (do not commit)
POSTGRES_PASSWORD=actual-secret
DJANGO_SECRET_KEY=actual-key
SENTRY_DSN=https://...@sentry.io/...

Once the convention is in place, you avoid sharing secrets out-of-band, and onboarding debugging gets faster: “is everything aside from the env vars set up correctly?”.

One more — pre-commit to prevent leaks #

Tools like gitleaks or detect-secrets as a pre-commit hook block commits that contain secrets before they ever leave the machine. Once a secret is committed, scrubbing it from git history is painful (git filter-branch or BFG Repo-Cleaner). Prevention is by far the cheapest defense.

Where env vars leak from #

Putting a secret in an env var doesn’t automatically secure it. In Docker, it can leak from any of these places:

docker inspect #

All env vars on a container
docker inspect myapp-web-1 --format '{{json .Config.Env}}'
# ["PATH=...", "DB_PASSWORD=secret", ...]

Anyone who can reach the Docker daemon can read them. Host security is the defense here.

docker history #

If a secret is baked into the image at build time, docker history exposes it.

Build-time leak
docker history myapp:1.0
# CREATED BY                         SIZE
# ENV DB_PASSWORD=secret              0B    ← baked forever

Never bake secrets into an image. Anyone who pulls it sees them in plaintext, and pushing it to a registry spreads them wider.

Process tree #

Container env vars
docker exec myapp-web-1 env | grep PASSWORD
docker exec myapp-web-1 cat /proc/1/environ | tr '\0' '\n'

Anyone with shell access into the container sees every env var. Controlling shell access into containers is one defensive layer.

Compose secrets: — one tier up #

A safer way to inject secrets. secrets: mounts secret values as files inside the container — not as env vars.

compose.yaml — secrets
services:
  pg:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
      POSTGRES_DB: app
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt
./secrets/db_password.txt
super-secret-password
.gitignore
secrets/

Why this is better:

  • The secret never appears as an env var — invisible to inspect.
  • It mounts as a read-only file at /run/secrets/db_password.
  • Official images like Postgres already support the *_FILE env var convention.

External secrets — Swarm-mode #

external secret (Swarm)
secrets:
  db_password:
    external: true

external: true means Compose doesn’t create the secret but references one that already exists. It really shines in Swarm mode; for plain compose up, the file form is the day-to-day option.

Build-time secrets — --mount=type=secret #

The BuildKit secret was introduced in #2. For secrets needed only during build (e.g., a private package registry token).

Dockerfile — build secret
# syntax=docker/dockerfile:1.7
FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=secret,id=pypi_token \
    pip install --extra-index-url \
      https://__token__:$(cat /run/secrets/pypi_token)@pypi.example.com/simple \
      -r requirements.txt
compose.yaml — build secret
services:
  web:
    build:
      context: .
      secrets:
        - pypi_token

secrets:
  pypi_token:
    file: ./secrets/pypi_token.txt

It’s readable as /run/secrets/pypi_token only during the build, and doesn’t end up in any layer of the image. Invisible to docker history. The standard for things like private indexes / GitHub PATs / internal mirror authentication.

External secret managers — one paragraph #

At larger scale, holding secrets in files or env vars becomes a burden. In the cloud you move to external managers.

EnvironmentIdiomatic tool
AWSSecrets Manager, Parameter Store
GCPSecret Manager
AzureKey Vault
Self-hostedHashiCorp Vault, Bitnami Sealed Secrets
K8sExternal Secrets Operator + the above

The flow: containers fetch secrets from the manager at startup and inject them into themselves as env vars / files. No secrets in the image, no secrets in container definitions — just careful permissions on the manager. The leak surface shrinks dramatically.

You don’t usually need this for plain Docker / Compose, but it’s a natural step once you operate on ECS / EKS / GKE.

Common mistakes #

  • ENV API_KEY=... baked into the image — exposed forever in docker history. Use build secrets / runtime injection.
  • Committing .env — once pushed, history scrubs are painful. Pre-commit gitleaks is the cheapest defense.
  • docker compose logs printing secrets — apps that dump env vars at startup (some libraries do by default). Mask or disable.
  • Host-shell vars accidentally landing in containersenvironment: - MY_VAR (no value) pulls from the host environment. Use explicit values unless that’s intentional.
  • Loose permissions on secret fileschmod 600 the secrets/ directory.

Wrap-up #

The picture from this post:

  • Env vars in a container come from six sources. Order: compose CLI > environment: > env_file: > host shell > .env interpolation > Dockerfile ENV.
  • .env is for compose.yaml interpolation — not for direct container injection. .gitignore + .env.example is the basic setup.
  • Don’t bake secrets into images — exposed by docker history. Don’t put them in ENV.
  • Compose secrets: injects secrets as read-only files, not env vars — pairs well with the *_FILE convention.
  • For build-time secrets, BuildKit --mount=type=secret — leaves no trace in any layer.
  • At scale, move to external secret managers (AWS Secrets Manager, Vault, etc.).

In the next post (#6 Logging and debugging), we wrap up the Intermediate series. Viewing logs from many containers in one place, switching log drivers, common docker compose logs options, and in-container debugging tools (exec, inspect, stats, dive).

X