Loading .env Files in Docker and Compose
A .env file is a local-development convenience, but the moment a Python service is containerized the question changes: does the file travel into the image, get injected as environment variables, or stay on the host? Getting this wrong either leaks secrets into image layers or leaves the container with no configuration at all. This page shows the correct runtime-injection pattern, and how it relates to the .env file management rules and the precedence order.
The confusion has a specific root: Compose uses a file named .env for two entirely unrelated jobs. One is interpolating ${VAR} placeholders inside compose.yaml itself, on the host, before any container exists. The other is env_file:, which injects keys into a container’s process environment at run time. They read the same filename by default and do completely different things, which is why “my variable isn’t reaching the container” and “my compose file has an empty image tag” are both common and both blamed on the same file.
Problem 1: baking .env into the image
Copying the file into the image is the most common and most dangerous mistake.
# Dockerfile — ANTI-PATTERN
COPY .env /app/.env # secrets are now in an image layer, forever
Anyone who can pull the image can run docker history and extract the layer. The secret is also immutable — rotating it means rebuilding and redeploying the image. Worse, deleting the file in a later layer does not remove it: layers are additive, so a RUN rm /app/.env leaves the original content in the layer beneath and merely hides it from the final filesystem. The only way to remove a baked secret is to rebuild without it, and by then the pushed image is already a distribution channel for the credential.
The usual cause is not an explicit COPY .env but a broad COPY . . with no .dockerignore entry. Git ignoring the file does nothing here — .gitignore and the Docker build context are unrelated mechanisms, and the build context includes every untracked file in the directory. Adding .env to .dockerignore is the fix, and it belongs in the same commit as the .gitignore entry, since the two protect against different accidents and teams routinely add only the first.
Problem 2: expecting python-dotenv to run in production
The mirror-image mistake is assuming load_dotenv() does the work everywhere.
# app.py — ANTI-PATTERN in a container
from dotenv import load_dotenv
load_dotenv() # there is no .env in the container; this loads nothing
In a container the platform injects real environment variables; there is no file to load. Relying on load_dotenv() silently gives you an unconfigured process. The call returns False and carries on, so nothing in the logs marks the moment configuration failed to arrive — the failure shows up later as a connection to a default host, or an exception about a None value several layers into the request path.
The resolution is the same one that applies outside containers: the loader is best-effort and the validation is mandatory. Let load_dotenv find nothing, and let a settings model with required fields decide whether the process is configured. Then the container behaves correctly whether the variables came from env_file, from -e flags, from a Kubernetes secret, or from nowhere at all — in the last case by refusing to start.
Secure implementation
Inject at runtime with Compose’s env_file, and read os.environ (or a settings model). Keep .env on the host and gitignored.
# config.py — reads injected env vars; .env is a local-only fallback
from pathlib import Path
from dotenv import load_dotenv
from pydantic_settings import BaseSettings, SettingsConfigDict
# In local dev only: load .env WITHOUT overriding real env vars.
load_dotenv(Path(__file__).parent / ".env", override=False)
class Settings(BaseSettings):
model_config = SettingsConfigDict(extra="forbid")
database_url: str
redis_url: str = "redis://localhost:6379/0"
settings = Settings() # validated from injected env vars in the container
# compose.yaml — inject the host .env as container environment variables
services:
app:
build: .
env_file:
- .env # read on the host, injected as env vars; not copied in
The container process reads validated environment variables; the .env file never enters the image. Note what the Compose fragment does not do: there is no volumes: entry mounting the file, and no build argument carrying a value. The host reads the file, Compose turns each line into an environment variable on the container process, and the container’s filesystem is untouched — which is why the same image runs unchanged in an environment where the values come from a secret manager instead.
Being explicit about the path in env_file: is worth doing even when it matches the default. Writing - ./.env states that this file is the run-time source, distinct from the interpolation file Compose picks up implicitly, and it survives someone later adding - ./.env.local without wondering which one was already in play.
Where the value actually wins
Compose has its own precedence between the ways a variable can reach a container, and it matches the general precedence order: the more specific and more deliberate source wins. From highest to lowest, a value set by docker compose run -e beats one in the service’s environment: block, which beats one from env_file:, which beats an ENV instruction baked into the image.
That ordering makes the ENV instruction a reasonable place for a genuinely constant, non-secret default — a PYTHONUNBUFFERED=1, say — because anything the deploy actually cares about will override it. It also explains a frequent surprise: adding a key to environment: for a quick test and forgetting to remove it produces a value that silently outranks the whole .env file, and the file looks broken. Compose will tell you the truth if you ask: docker compose config renders the fully resolved configuration, including interpolated values and merged overrides, which is the fastest way to see what a service will actually receive before starting anything.
The container’s override=False loader sits below all of these, since by the time Python starts, everything above has already populated os.environ. That is the property that makes a stray .env inside an image harmless rather than dangerous — it can only ever fill keys that nothing above it in the order already supplied, which in a real deployment is usually none of them.
When env vars are not private enough
env_file is the right mechanism for local development and for any value you would not mind a colleague seeing. Production credentials often fail that test, and the reason is that environment variables in a container are not confidential in the way people assume. They appear in docker inspect output, in the orchestrator’s API for anyone who can read pod specs, in /proc/<pid>/environ for processes in the same container, and in any crash reporter that attaches environment context.
The alternative that keeps the same application code is a mounted secret file plus a path variable. The platform writes the secret to a tmpfs mount, injects only the path, and the settings model reads it:
# config.py — read the value from a mounted file, keep the path in the env
from pathlib import Path
from pydantic import SecretStr, field_validator
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_password: SecretStr # supplied directly, or via *_FILE
@field_validator("database_password", mode="before")
@classmethod
def read_from_file(cls, v):
# convention: DATABASE_PASSWORD_FILE=/run/secrets/db_password
import os
path = os.environ.get("DATABASE_PASSWORD_FILE")
return Path(path).read_text().strip() if path and not v else v
# compose.yaml — Docker secrets are mounted at /run/secrets, not injected
services:
app:
build: .
environment:
DATABASE_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt # swap for an external secret in production
The _FILE suffix convention is worth adopting because it is widely understood — the official Postgres, MySQL, and Redis images all support it — and because it degrades gracefully. A deployment that has a real secret manager mounts a file and sets the path; a local run sets the variable directly and the validator leaves it alone. Both paths produce the same typed field, so nothing downstream knows or cares which was used.
pydantic-settings also has this built in through its file-secrets source, which reads a directory of files named after fields. Either approach is fine; what matters is that the secret arrives as file content rather than as an environment variable, so it never appears in an inspect dump or a process listing.
Gotchas & version-specific behaviour
- Compose
env_filedoes not expand shell variables inside the file — values are literal. - Values set with
environment:in Compose override those fromenv_file, matching the precedence order. docker run --env-file .envbehaves like Compose’senv_file;-e KEY=valuetakes precedence over it.override=Falsematters here: if a stray.envexists in the image build context, it must never win over the injected variable.- For real secrets, prefer Docker/Swarm secrets or a manager over
env_file; env vars are visible to anyone who candocker inspect. - Build arguments are not a place for secrets —
ARGvalues are recorded in image metadata and readable withdocker history. - Quoting differs from a shell:
KEY="value"in anenv_filekeeps the quotes in some Compose versions, so prefer unquoted values unless you have tested otherwise.
The docker inspect point is the one that decides how far this pattern takes you. Environment variables are readable by anyone with access to the daemon or the orchestrator’s API, and they appear in process listings and crash dumps. That is acceptable for a development database password and not acceptable for a production signing key. When you cross that line, the injection mechanism changes — a mounted secret file or a runtime fetch from a manager — but the application code does not, because the settings model still reads a value and validates it.
Production parity checklist
- Gitignore
.envand ship a.env.examplewith dummy values. - Never
COPY .envin the Dockerfile; add it to.dockerignoreand scan image layers for secrets in CI. - Use
env_file(dev) and a secret manager (prod) so the same code readsos.environin both. - Load
.envwithoverride=Falseso injected values always win. - Validate every injected variable through a
BaseSettingsmodel at startup. - Promote the same image digest between environments; if staging and production build separately, you are not shipping what you tested.
A five-minute audit covers most of this. Run docker history --no-trunc on your latest image and look for a COPY step whose context could have included the file; check that .dockerignore exists and lists .env alongside .git and __pycache__; and run the container with no env_file at all to confirm it exits with a clear message rather than starting in a half-configured state. The third check is the one that proves the validation is real rather than aspirational.
Frequently asked questions
Does Docker Compose’s env_file put values in the container environment or in a file?
env_file reads the listed file on the host and injects each key as a real environment variable into the container process. The file itself is not copied into the container, so your Python code reads os.environ, not the file.
Should I COPY a .env file into my Docker image?
No. Anything COPYed into an image is readable in the image layers forever, including by anyone who pulls the image. Inject values at runtime with Compose env_file, --env-file, or a secret manager instead.
Why does my python-dotenv call do nothing inside the container?
If the platform already injected the variables, there is no .env file in the container to load, and load_dotenv finds nothing. That is correct — read os.environ, and treat .env as a local-dev-only fallback with override=False.
Key takeaways
Inject configuration into containers at runtime and read it through a validated settings model; never bake a .env into an image. The .env file is a local-dev fallback loaded with override=False, nothing more. Two rules cover almost every mistake in this area: nothing that varies between deploys belongs in a layer, and nothing about the application’s code path may depend on which mechanism supplied the value. Follow both and the image becomes genuinely portable — the same digest running in development, staging, and production, distinguished only by what the platform injects at the moment the container starts, and by nothing that was decided at build time.