Compose Environment Variables and .env
Compose environment variables let one compose.yml file adapt to different machines without hard-coding every port, username, image tag, or development setting. The confusing part is that Compose has two related but separate jobs: substituting variables into the Compose file before containers are created, and passing environment variables into the containers themselves. The local .env file is mainly for Compose interpolation, while environment and env_file control what a service process sees at runtime.
Overview: How it works
Docker Compose is a client-side tool that reads a Compose file, resolves variables, and then asks the Docker Engine to create ordinary Docker objects: images, containers, networks, and volumes. A container is still a normal container with a thin writable layer, mounts, network attachments, and one main process. Environment variables are part of the container configuration that Docker stores when the container is created. Changing an environment value in Compose usually means the service container must be recreated so the new process starts with the new environment.
There are three places to keep straight. First, the shell environment is whatever variables already exist in the terminal where you run docker compose. Second, a project-level .env file in the Compose project directory supplies values Compose can use while parsing the YAML. Third, service-level environment and env_file entries pass values into a specific container.
Interpolation happens before Docker creates anything. In a line such as - "${APP_PORT:-8080}:80", Compose replaces ${APP_PORT:-8080} with a value from the shell or .env, using 8080 if no value is set. The container does not automatically receive APP_PORT just because the Compose file used it for interpolation. If the application process needs APP_PORT, put it under that service’s environment or load it with env_file.
The project .env file is not a secure secret store. It is convenient for local development and for values like host ports, database names, and image tags. Real production secrets should come from your deployment platform, Docker secrets, mounted secret files, or another secret manager. Putting a password in a committed .env file leaks it to anyone with repository access.
Syntax
A practical Compose file uses interpolation and runtime environment together:
services:
web:
image: nginx:${NGINX_TAG:-1.27-alpine}
ports:
- "${WEB_PORT:-8080}:80"
environment:
APP_MODE: "${APP_MODE:-development}"
env_file:
- ./web.env
A matching project .env file might look like this:
NGINX_TAG=1.27-alpine
WEB_PORT=8080
APP_MODE=development
| Form | Meaning |
|---|---|
${VAR} |
Substitute VAR. If it is missing, Compose warns and uses an empty string. |
${VAR:-default} |
Use default when VAR is unset or empty. |
${VAR-default} |
Use default only when VAR is unset. |
${VAR:?message} |
Fail with message when VAR is unset or empty. |
environment |
Sets variables in one service container. Values can be literal or interpolated. |
env_file |
Loads container environment variables from one or more files for that service. |
--env-file FILE |
Tells Compose to use a specific file for interpolation instead of the default project .env. |
Useful commands for inspecting the result are:
docker compose config
docker compose --env-file .env.dev config
docker compose up -d
docker compose exec web printenv APP_MODE
docker compose config prints the fully resolved configuration after interpolation. It is the safest way to check which values Compose will send to Docker before you create or recreate containers.
Examples
Example 1: Use .env to choose the host port
This project-level .env file sets the host port for a local Nginx service:
WEB_PORT=8088
NGINX_TAG=1.27-alpine
services:
web:
image: nginx:${NGINX_TAG:-1.27-alpine}
ports:
- "${WEB_PORT:-8080}:80"
docker compose config
Output:
services:
web:
image: nginx:1.27-alpine
ports:
- mode: ingress
target: 80
published: "8088"
protocol: tcp
Compose reads .env, substitutes the values into the YAML, and produces a resolved config. The web container does not receive WEB_PORT as an environment variable; Compose used it only to decide the host-side port mapping. The image tag is pinned to 1.27-alpine, which is reproducible in a way that latest is not.
Example 2: Pass application settings into a container
Now the application process needs runtime settings. Use environment when the values are few and belong beside the service definition:
APP_MODE=development
REDIS_URL=redis://redis:6379/0
services:
api:
image: node:20-alpine
working_dir: /app
command: ["node", "server.js"]
environment:
NODE_ENV: "${APP_MODE:-development}"
REDIS_URL: "${REDIS_URL:-redis://redis:6379/0}"
volumes:
- ./app:/app
redis:
image: redis:7-alpine
docker compose up -d
docker compose exec api printenv NODE_ENV
docker compose exec api printenv REDIS_URL
Output:
development
redis://redis:6379/0
Here APP_MODE and REDIS_URL are read during interpolation, then the resolved values become container environment variables named NODE_ENV and REDIS_URL. The Redis hostname works because services on the same Compose network can resolve each other by service name.
Example 3: Use env_file for a service-specific file
Use env_file when a service has many runtime variables. This file is read and injected into the container environment for the worker service:
QUEUE_NAME=emails
LOG_LEVEL=info
API_TOKEN=changeme
services:
worker:
image: alpine:3.20
command: ["sh", "-c", "printenv QUEUE_NAME LOG_LEVEL API_TOKEN"]
env_file:
- ./worker.env
environment:
LOG_LEVEL: "debug"
docker compose up
Output:
worker-1 | emails
worker-1 | debug
worker-1 | changeme
worker-1 exited with code 0
The worker.env file supplies QUEUE_NAME, LOG_LEVEL, and API_TOKEN. The explicit environment entry overrides LOG_LEVEL for that service, so the container prints debug. changeme is an obvious placeholder; do not commit real tokens.
How it works step by step
- You run
docker composefrom a project directory, optionally passing--env-fileor-f. - Compose loads interpolation variables from the shell environment and the selected env file. Shell values take precedence over values in the default
.env. - Compose parses the YAML and replaces expressions such as
${WEB_PORT:-8080}. docker compose configcan print this resolved model before containers are created.- When you run
up, Compose asks Docker to pull or reuse images, create networks and volumes, and create containers. - Runtime environment from
environmentandenv_fileis stored in the container configuration. - The container’s main process starts with that environment. Like any Linux or Windows process, it usually reads environment variables only at startup.
- If a variable changes later, Compose must recreate the container for the process to see the new value.
Common Mistakes
Assuming .env automatically enters the container
services:
api:
image: node:20-alpine
command: ["node", "server.js"]
If .env contains NODE_ENV=development, this service still does not receive NODE_ENV. The fix is to pass it explicitly:
services:
api:
image: node:20-alpine
command: ["node", "server.js"]
environment:
NODE_ENV: "${NODE_ENV:-development}"
Leaving required variables silent
services:
api:
image: mycompany/api:1.4.2
environment:
DATABASE_URL: "${DATABASE_URL}"
If DATABASE_URL is missing, Compose may substitute an empty string and the app fails later. Prefer a required expression for values the service cannot run without:
services:
api:
image: mycompany/api:1.4.2
environment:
DATABASE_URL: "${DATABASE_URL:?Set DATABASE_URL before starting Compose}"
Committing real secrets
DATABASE_PASSWORD=changeme
API_TOKEN=changeme
Even placeholder-looking files should be treated carefully. Commit an example file such as .env.example, ignore real local env files, and use Docker secrets or your orchestrator’s secret store for production. Secrets baked into images or committed to Git are hard to remove from history.
Best Practices
- Use project
.envfor Compose interpolation values such as image tags, host ports, and local defaults. - Use
environmentorenv_filefor variables the container process must actually read. - Run
docker compose configbefore debugging a stack; it shows the resolved configuration Compose will apply. - Prefer
${VAR:-default}for optional local defaults and${VAR:?message}for required values. - Keep real secrets out of committed Compose files and env files.
- Use clear placeholder values such as
changemeor quoted<YOUR_API_KEY>in examples and templates. - Pin image tags, including tags supplied through variables, instead of relying on
latest. - Remember that changing environment variables requires recreating the service container.
- Keep service-specific runtime files named clearly, such as
api.envorworker.env, instead of using one large unclear file.
Practice Exercises
- Create a Compose file for
nginx:1.27-alpinewhereWEB_PORTfrom.envcontrols the host port. Expected end state:docker compose configshows the resolved published port. - Add a Redis-backed API service with
REDIS_URLpassed throughenvironment. Hint: the URL should use the Compose service nameredis, not a container IP address. - Create
worker.envwith three variables and load it withenv_file. Override one variable inenvironmentand confirm the override withdocker compose execor container output.
Summary
- Compose interpolation and container runtime environment are related but separate concepts.
- The project
.envfile supplies values while Compose parses the YAML. environmentandenv_filepass variables into a service container.docker compose configis the best first debugging command for variable issues.- Shell variables can override values from the default
.envfile. - Use defaults for convenience, required expressions for critical settings, and a real secret store for production secrets.
