Docker Intermediate #5: Environment Variables and Secrets
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:
- #1 Multi-stage builds and image slimming
- #2 Build cache — layer ordering
- #3 docker compose basics — web + db
- #4 compose deep dive — depends_on, healthcheck, profiles
- #5 Environment variables and secrets ← this post
- #6 Logging and debugging
Where env vars come from #
Env vars reach a Docker container through more paths than you might expect. All in one place:
┌─────────────────────────┐
[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.
POSTGRES_PASSWORD=secret
APP_VERSION=1.0.0
DB_HOST=pgservices:
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:
- That
.envis for compose.yaml interpolation. It isn’t auto-injected into containers. - To make values reach the container, list them under
environment:/env_file:.
Interpolation syntax #
${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 → errorA common production pattern:
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.
services:
web:
environment:
DEBUG: "1"
DB_HOST: pg
DB_PASSWORD: ${POSTGRES_PASSWORD}services:
web:
env_file:
- .env.web
- .env.localDEBUG=1
DB_HOST=pg
LOG_LEVEL=info| environment | env_file | |
|---|---|---|
| Defined where | Inside compose.yaml | Separate file |
| When you have many | Becomes long | Stays clean |
| Interpolation | Yes (${VAR}) | No — literal |
| Precedence | Higher than env_file | Lower 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 anenv_file. It’s a plain dotenv file — every value is read literally. For interpolation, useenvironment:.
Variable precedence in one table #
When the same variable lives in multiple places, the highest-listed source wins.
| Rank | Source |
|---|---|
| 1 (strongest) | docker compose run -e KEY=val (CLI explicit) |
| 2 | compose.yaml environment: |
| 3 | compose.yaml env_file: |
| 4 | The host shell’s env (when compose runs) |
| 5 | .env file (compose.yaml interpolation — not direct injection) |
| 6 | Dockerfile 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.
| Level | Right tool |
|---|---|
| Dev / local | .env file (don’t commit, .gitignore) |
| CI / small ops | CI’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.
.env
.env.*
!.env.example.env.example is the template with key names only — a guide for new clones to fill in.
POSTGRES_PASSWORD=
DJANGO_SECRET_KEY=
SENTRY_DSN=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
#
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.
docker history myapp:1.0
# CREATED BY SIZE
# ENV DB_PASSWORD=secret 0B ← baked foreverNever bake secrets into an image. Anyone who pulls it sees them in plaintext, and pushing it to a registry spreads them wider.
Process tree #
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.
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.txtsuper-secret-passwordsecrets/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
*_FILEenv var convention.
External secrets — Swarm-mode #
secrets:
db_password:
external: trueexternal: 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).
# 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.txtservices:
web:
build:
context: .
secrets:
- pypi_token
secrets:
pypi_token:
file: ./secrets/pypi_token.txtIt’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.
| Environment | Idiomatic tool |
|---|---|
| AWS | Secrets Manager, Parameter Store |
| GCP | Secret Manager |
| Azure | Key Vault |
| Self-hosted | HashiCorp Vault, Bitnami Sealed Secrets |
| K8s | External 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 indocker history. Use build secrets / runtime injection.- Committing
.env— once pushed, history scrubs are painful. Pre-commit gitleaks is the cheapest defense. docker compose logsprinting secrets — apps that dump env vars at startup (some libraries do by default). Mask or disable.- Host-shell vars accidentally landing in containers —
environment: - MY_VAR(no value) pulls from the host environment. Use explicit values unless that’s intentional. - Loose permissions on secret files —
chmod 600thesecrets/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 >.envinterpolation > DockerfileENV. .envis for compose.yaml interpolation — not for direct container injection..gitignore+.env.exampleis the basic setup.- Don’t bake secrets into images — exposed by
docker history. Don’t put them inENV. - Compose
secrets:injects secrets as read-only files, not env vars — pairs well with the*_FILEconvention. - 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).