Chapter 8: Docker Compose
Topic 49

Environment, .env, and Interpolation

Config.env

There are two different "environments" in Compose, and conflating them is the usual source of confusion. One is the environment inside the container — what your app reads via os.environ — set with environment: or env_file:. The other is variable interpolation in the compose file itself${TAG} or ${POSTGRES_PASSWORD} substituted as Compose parses the YAML, fed automatically from a .env file in the project directory.

They look similar — both are KEY=value — and they live in completely different worlds. One reaches a running container; the other only shapes the file Compose reads. Keeping the two destinations straight is most of what this topic is about.

environment: — Inline Container Env

A list or map under a service's environment: key sets environment variables in that container. Driftwood's web gets DATABASE_URL and SECRET_KEY this way, and db gets POSTGRES_PASSWORD. The values are baked into the parsed configuration and end up in the container's process environment, where the application reads them at runtime — and they show up in docker compose config, which prints the fully resolved file.

env_file: — Container Env From a File

env_file: points a service at a file of KEY=value lines that Compose loads into that container's environment. It is the same destination as environment: — the container — but sourced from a file, which is handy when a service needs many variables. The crucial thing is the name: this is distinct from the .env interpolation file, despite the near-identical look, because env_file: feeds a container and .env feeds the parser.

The .env File and Interpolation

A .env file sitting next to compose.yaml is auto-loaded by Compose to resolve ${VAR} references in the compose file — image tags, ports, paths. This substitution happens at parse time, before any container exists, and it does not by itself put anything inside a container. If .env contains TAG=1.4.0 and the file says image: driftwood/web:${TAG}, Compose reads the file as driftwood/web:1.4.0. The container never sees TAG unless an environment: entry also references it.

Three KEY=value mechanisms, two destinations
.env file
Auto-loaded next to compose.yaml to interpolate ${VAR} in the compose file at parse time — image tags, ports, paths. Reaches no container unless a var is also referenced under environment:.
environment:
Sets variables inside the container, listed inline under a service. The application reads them at runtime via its process environment.
env_file:
Same destination as environment: — variables inside the container — but sourced from a file of KEY=value lines, handy when a service needs many.
.env (next to compose.yaml) and the file that interpolates it
# .env — auto-loaded for ${VAR} interpolation
TAG=1.4.0
POSTGRES_PASSWORD=secret

# compose.yaml — ${...} resolved at parse time
services:
  web:
    image: driftwood/web:${TAG}
    environment:
      DATABASE_URL: postgres://driftwood:${POSTGRES_PASSWORD}@db:5432/driftwood
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

${TAG} only shapes which image tag web runs — it never reaches a container. ${POSTGRES_PASSWORD} reaches the containers only because each service's environment: explicitly references it; the .env entry alone would not have. That double step — interpolate into the file, then the file's environment: carries it into the container — is the bridge between the two worlds.

Precedence and Resolution Order

When the same name appears in more than one place, a fixed precedence decides which value wins, and getting it wrong means the value you set silently does not take effect. For interpolation, a value already exported in the shell environment beats the .env file — so TAG=2.0.0 docker compose up overrides .env's TAG. For container environment, an explicit environment: entry beats anything in env_file:. Knowing the order matters most when a stale shell export quietly overrides the .env you just edited.

The Secret-in-.env Footgun

Driftwood's naive Layer-A approach puts POSTGRES_PASSWORD and SECRET_KEY in .env. It is convenient, and that convenience is exactly why secrets leak. .env sits in the project directory, so it is one stray git add . away from the repository — and docker compose config prints every resolved value in plaintext, so a routine debugging command in a shared terminal or CI log dumps the password.

Add .env to .gitignore from the first commit and treat the secret-in-.env pattern as the throwaway Layer-A shortcut it is. The real fix — database passwords and SECRET_KEY delivered as files through Docker and Compose secrets — is Layer B's job in Chapter 10, and it removes the plaintext from both the repo and the config output.

environment: / env_file: vs the .env interpolation file

environment: and env_file: — set variables inside a container for the application to read at runtime. environment: lists them inline; env_file: sources them from a file. Both destinations are the same: the running container's process environment.

The .env file — feeds ${VAR} interpolation in the compose file itself at parse time. It shapes the config — which tag, which port — not the container's environment, unless you also reference those vars under environment:. Same KEY=value syntax, two unrelated destinations: container runtime versus file parsing.

Common Mistakes
  • Putting values in .env and assuming the app sees them — .env only resolves ${VAR} in the compose file; unless a service's environment: references the var, nothing reaches the container.
  • Committing .env with the database password and SECRET_KEY in it — it is the default secret-leak path, since .env lives in the project dir and is easy to git add by accident; add it to .gitignore from the first commit.
  • Running docker compose config in a shared terminal or CI log and exposing resolved secret values — interpolation prints the substituted plaintext, so a debugging command becomes a leak.
  • Expecting environment: to override a value the shell already exports for interpolation — for ${VAR} the shell wins over .env, and getting the precedence backwards means the value you set never takes effect.
Best Practices
  • Keep environment:/env_file: (container runtime env) mentally separate from .env (compose-file interpolation), and reach for whichever matches the destination you actually mean.
  • Add .env to .gitignore and commit a .env.example with blank or dummy values, so the required variables are documented without the secret values.
  • Treat .env secrets as the throwaway Layer-A approach only, and move database passwords and SECRET_KEY to Docker/Compose secrets delivered as files in production (Chapter 10).
  • Know the precedence — shell over .env for interpolation, explicit environment: over env_file: for container env — so the value that wins is the one you intended.
Comparable tools docker run --env-file / -e handles only the container-runtime half, no interpolation Podman podman-compose reads the same environment/env_file/.env model Kubernetes splits this into ConfigMaps (non-secret env) and Secrets (sensitive env)

Knowledge Check

What is the difference between environment: and the project's .env file?

  • environment: sets variables inside the container; .env feeds ${VAR} interpolation in the compose file
  • They are two interchangeable names for the same thing — both inject variables into every container
  • .env loads at container start while environment: reloads live as the file changes
  • .env encrypts its values at rest while environment: stores them in the clear as plaintext

A .env file sets API_KEY=abc but no service references it. Does the app see API_KEY?

  • No — .env only resolves ${VAR} in the file; a service must reference it under environment: to reach the container
  • Yes — every single entry in the .env file is automatically injected into all of the containers
  • Yes, but only the edge service (proxy) receives it automatically, since it faces the host
  • No, and on top of that Compose errors out at parse time because the variable is left unused

For ${VAR} interpolation, what wins when the shell exports TAG and .env also sets it?

  • The shell's exported value wins and overrides .env
  • The .env value wins out because it sits closer to the compose file
  • Compose concatenates both values together into one combined string
  • Compose errors out because the same variable is defined twice

Why is putting POSTGRES_PASSWORD in .env a leak risk, and what fixes it?

  • It can be committed by accident and compose config prints it in plaintext; file-based secrets (Chapter 10) fix it
  • It is read-only so the app can never rotate it; mounting the .env file writable is what fixes the leak
  • It is baked into a read-only image layer at build time, where it persists forever; switching to a multi-stage build strips it out cleanly
  • Postgres rejects that exact variable name, so renaming it to DB_PASS resolves the underlying issue

You got correct