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.

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.

Key APIs in Problem 1: baking .env into the image The main identifiers used in Problem 1: baking .env into the image DockerfileCOPY
The APIs and identifiers this section uses.

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.

Key APIs in Problem 2: expecting python-dotenv to run in production The main identifiers used in Problem 2: expecting python-dotenv to run in production load_dotenvapp.py
The APIs and identifiers this section uses.

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.

Key APIs in Secure implementation The main identifiers used in Secure implementation env_fileos.environ.envPathBaseSettingsSettingsConfigDict
The APIs and identifiers this section uses.

Gotchas & version-specific behaviour

  • Compose env_file does not expand shell variables inside the file — values are literal.
  • Values set with environment: in Compose override those from env_file, matching the precedence order.
  • docker run --env-file .env behaves like Compose’s env_file; -e KEY=value takes precedence over it.
  • override=False matters here: if a stray .env exists 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 can docker inspect.
Gotchas & version-specific behaviour Key points from Gotchas & version-specific behaviour Compose env_file does notexp…values are literal.Values set with environmentin Compose override those fromenv_file, matching t…docker run --env-file .envbe…override=False matters hereif a stray .env exists in the imagebuild context, …For real secrets, preferDock…
The essentials of Gotchas & version-specific behaviour.

Production parity checklist

  • Gitignore .env and ship a .env.example with dummy values.
  • Never COPY .env in the Dockerfile; scan image layers for secrets in CI.
  • Use env_file (dev) and a secret manager (prod) so the same code reads os.environ in both.
  • Load .env with override=False so injected values always win.
  • Validate every injected variable through a BaseSettings model at startup.
Production parity checklist Key points from Production parity checklist Gitignore .env and ship a.en…Never COPY .env in theDocker…Use env_file (dev) and asecr…Load .env withoverride=False…Validate every injectedvaria…
Every item to tick off for Production parity checklist.

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.