Environment, .env, and Interpolation
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.
KEY=value mechanisms, two destinations.env filecompose.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:env_file:environment: — variables inside the container — but sourced from a file of KEY=value lines, handy when a service needs many.# .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: 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.
- Putting values in
.envand assuming the app sees them —.envonly resolves${VAR}in the compose file; unless a service'senvironment:references the var, nothing reaches the container. - Committing
.envwith the database password andSECRET_KEYin it — it is the default secret-leak path, since.envlives in the project dir and is easy togit addby accident; add it to.gitignorefrom the first commit. - Running
docker compose configin 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.
- 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
.envto.gitignoreand commit a.env.examplewith blank or dummy values, so the required variables are documented without the secret values. - Treat
.envsecrets as the throwaway Layer-A approach only, and move database passwords andSECRET_KEYto Docker/Compose secrets delivered as files in production (Chapter 10). - Know the precedence — shell over
.envfor interpolation, explicitenvironment:overenv_file:for container env — so the value that wins is the one you intended.
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;.envfeeds${VAR}interpolation in the compose file- They are two interchangeable names for the same thing — both inject variables into every container
.envloads at container start whileenvironment:reloads live as the file changes.envencrypts its values at rest whileenvironment: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 —
.envonly resolves${VAR}in the file; a service must reference it underenvironment:to reach the container - Yes — every single entry in the
.envfile 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
.envvalue 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 configprints it in plaintext; file-based secrets (Chapter 10) fix it - It is read-only so the app can never rotate it; mounting the
.envfile 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_PASSresolves the underlying issue
You got correct