Using .env Files

A .env file is a simple text file that stores environment variable names and values. In Docker, it is useful because the same image can run with different settings in development, staging, and production without rebuilding the image.

The important detail is that Docker has two related but different uses for .env files: passing variables into a container, and substituting variables into a Compose file. Confusing those two is the source of many broken deployments.

Overview: How .env Files Work

An environment variable is part of a process environment. When Docker starts a container, the daemon creates the container from read-only image layers, adds a thin writable container layer, prepares namespaces and networking, then starts the configured process with a final set of environment variables. Those variables come from image defaults set with Dockerfile ENV, values passed on the command line with -e, values read from --env-file, and, in Compose, values defined by environment or env_file.

A .env file used with docker run --env-file is direct container configuration. Docker reads each name and value and injects them into the container process. The file is not copied into the image unless your Dockerfile explicitly copies it. This is different from ENV in a Dockerfile, which is stored in image metadata and becomes a default for every container created from that image.

Docker Compose adds another layer. By default, docker compose looks for a file named .env in the project directory and uses it for variable substitution in compose.yml. For example, ${WEB_PORT} in the Compose file can be replaced by a value from .env. That automatic Compose .env file does not automatically put every variable into the container. To pass variables into the container, use the service’s environment or env_file keys.

This separation is useful. Build images once, then supply configuration at runtime. An image named config-demo-api:1.0 can run locally with a development database URL and in production with a production database URL. The image layers stay the same; only the container’s process environment changes.

Do not treat .env as a complete secret-management system. A local .env is often appropriate for development, but production secrets belong in your orchestrator, Docker secrets, cloud secret manager, or mounted secret files. Also add local .env files to .gitignore and .dockerignore so they are not committed or sent in the build context.

Syntax

# app.env
APP_ENV=development
PORT=8080
LOG_LEVEL=debug
API_KEY=<YOUR_API_KEY>
Form Meaning
NAME=value Defines one variable. The name should normally use letters, numbers, and underscores, and should not start with a number.
# comment Comment line. Use comments for non-secret notes about expected values.
EMPTY= Defines the variable with an empty string value.
docker run --env-file app.env IMAGE Reads variables from app.env and passes them to the container.
docker run -e NAME=value IMAGE Sets or overrides one variable for that container.
docker compose --env-file file.env up Uses a specific file for Compose variable interpolation.
env_file: Compose service key that passes variables from a file into that service’s containers.

Keep .env syntax plain. Do not write shell commands, export, or spaces around the equals sign. If a value contains spaces, test the exact behavior with your Docker or Compose version before depending on it; simple values are the most portable. For examples in this lesson, obvious placeholders such as <YOUR_API_KEY> are used instead of real secrets.

Examples

Example 1: Pass an env file to docker run

Create an env file for runtime settings:

APP_ENV=development
PORT=8080
LOG_LEVEL=debug

Run a small container that prints the values:

docker run --rm --env-file app.env alpine:3.20 sh -c 'echo APP_ENV=$APP_ENV; echo PORT=$PORT; echo LOG_LEVEL=$LOG_LEVEL'

Output:

APP_ENV=development
PORT=8080
LOG_LEVEL=debug

Docker reads app.env on the host and adds those names to the process environment inside the container. The file itself is not mounted into the container and is not baked into the alpine:3.20 image. The --rm flag removes the container after the process exits; the image remains available locally.

Example 2: Override one value at runtime

docker run --rm --env-file app.env -e LOG_LEVEL=info alpine:3.20 sh -c 'echo APP_ENV=$APP_ENV; echo PORT=$PORT; echo LOG_LEVEL=$LOG_LEVEL'

Output:

APP_ENV=development
PORT=8080
LOG_LEVEL=info

The command line override wins for LOG_LEVEL. This is handy when most settings come from a file but one value changes for a single test run. The exact precedence you should remember is practical: Dockerfile ENV gives image defaults, --env-file supplies container values, and -e can override a specific value for that run.

Example 3: Use Compose .env for interpolation and env_file for the container

A Compose project might have this .env file:

WEB_PORT=8080
APP_ENV=development
LOG_LEVEL=debug

The Compose file can use WEB_PORT to choose the host port and env_file to pass variables into the container:

services:
  web:
    image: nginx:1.27-alpine
    ports:
      - "${WEB_PORT}:80"
    env_file:
      - .env
    environment:
      LOG_LEVEL: info

Start it with modern Compose:

docker compose up -d

Output:

[+] Running 2/2
 Network env-demo_default  Created
 Container env-demo-web-1  Started

Here ${WEB_PORT} is replaced before the service is created, so port 8080 on the host maps to port 80 in the container. The env_file key passes APP_ENV, WEB_PORT, and LOG_LEVEL into the service container, but the explicit environment entry changes LOG_LEVEL to info. Remember that ports publishes a port; an environment variable named PORT only configures an application if the application reads it.

Example 4: Select a different Compose env file

docker compose --env-file staging.env config

Output:

name: env-demo
services:
  web:
    environment:
      LOG_LEVEL: info
    image: nginx:1.27-alpine
    ports:
      - mode: ingress
        target: 80
        published: "8081"
        protocol: tcp

docker compose config renders the final Compose model after interpolation. This is a safe way to check whether ${WEB_PORT} became the value you expected before you start or replace containers. In this example, staging.env contains WEB_PORT=8081.

How It Works Step By Step

  1. You write a plain env file such as app.env with one NAME=value entry per line.
  2. With docker run --env-file app.env, the Docker client sends the requested container configuration to the daemon. The daemon creates the container from the image and stores the final environment in the container configuration.
  3. When the process starts, Docker passes that environment to the process, just like any operating system process environment. The application must read those variables itself.
  4. With Compose, the CLI first loads variables for interpolation. It uses shell variables and a project .env file to replace expressions such as ${WEB_PORT} in compose.yml.
  5. Compose then creates service containers. Only variables listed in environment or files listed under env_file become container environment variables.
  6. If you change an env file, existing containers do not magically change. Recreate the container with docker run again, or run docker compose up -d so Compose can update affected services.

Under the hood, changing a runtime env file does not create a new image layer. That is the main advantage over Dockerfile ENV for deployment-specific values. The image is still a read-only stack of layers; the container’s configuration changes around it.

Common Mistakes

Expecting Compose .env to automatically enter the container

services:
  web:
    image: nginx:1.27-alpine
    ports:
      - "${WEB_PORT}:80"

If .env contains WEB_PORT=8080, this publishes the host port, but it does not guarantee a WEB_PORT variable inside the container. Add environment or env_file when the application needs the value:

services:
  web:
    image: nginx:1.27-alpine
    ports:
      - "${WEB_PORT}:80"
    environment:
      WEB_PORT: "${WEB_PORT}"

Copying .env into an image

FROM alpine:3.20
WORKDIR /app
COPY .env .env
CMD ["sh", "-c", "cat .env"]

This is wrong for sensitive configuration. Image layers are reusable artifacts, and deleting a file in a later layer does not erase it from an earlier layer. Keep local env files out of the build context with .dockerignore:

.env
*.env
secrets/

Using production secrets in a local .env file

DATABASE_URL=postgres://admin:changeme@db:5432/app
API_KEY=<YOUR_API_KEY>

This is acceptable only as a placeholder or disposable local configuration. Real production values should come from a secret store or orchestrator and should be rotated if they were ever committed, copied into an image, or shared in logs.

Best Practices

  • Use env files for runtime configuration that changes between environments, not for values that should be compiled into the image.
  • Name files clearly, such as app.env, dev.env, or staging.env, when more than one environment exists.
  • Add local env files to .gitignore and .dockerignore.
  • Use obvious placeholders in examples and templates, such as <YOUR_API_KEY>, never real-looking secrets.
  • Use docker compose config to inspect interpolated Compose output before starting important services.
  • Keep Dockerfile ENV values harmless, such as NODE_ENV=production or PORT=8080.
  • Prefer environment for a few explicit Compose variables and env_file when a service has many settings.
  • Recreate containers after changing env files; running processes do not reload their environment automatically.
  • Use pinned image tags such as alpine:3.20 and nginx:1.27-alpine so configuration changes are not mixed with unexpected image changes.

Practice Exercises

  1. Create app.env with APP_ENV=local, PORT=5000, and LOG_LEVEL=debug. Run an alpine:3.20 container that prints all three values. Hint: use --env-file and sh -c.
  2. Write a Compose file for nginx:1.27-alpine where ${WEB_PORT} controls the published host port. Then run docker compose config to confirm the rendered value before starting the service.
  3. Audit a project directory that contains .env, .env.example, and compose.yml. Decide which files should be committed, which should be ignored by Git, and which should be excluded from the Docker build context.

Summary

  • docker run --env-file passes variables from a file into one container.
  • Compose automatically reads a project .env for interpolation, but that alone does not pass every value into containers.
  • Use Compose environment or env_file for service container variables.
  • Runtime env files change container configuration, not image layers.
  • Do not copy sensitive .env files into images or commit them to source control.