Two kinds of variables that get mixed up
| Mechanism | Used for |
|---|---|
A .env file next to compose.yaml (loaded automatically) or --env-file |
Substituting variables in the Compose file, for example image: node-app:${TAG} |
The environment: key |
Variables inside the container, like docker run -e |
The env_file: key |
Loading many variables from a file into the container |
services:
api:
image: registry.example.com/node-app:${TAG}
environment:
DB_HOST: db
DEBUG: # empty: take the value from the shell running compose
env_file: [api.env]
Variables in .env do not reach the container by themselves; declare them with environment or env_file. Switch environments with another file: docker compose --env-file .env.dev up -d. --env-file, -f and -p are global options and go before the subcommand (up, logs…).
The default network
flowchart LR
subgraph NET["network node-app_default"]
A["api"] -- "db:5432" --> D["db"]
end
Compose creates the network <project>_default; every service joins it and reaches the others by service name (DNS). The project name defaults to the directory name (node-app); change it with -p myapp or COMPOSE_PROJECT_NAME, and the network becomes myapp_default.
The classic trap: the app in the api container connects to localhost:5432. localhost is the api container itself, so use db:5432. links is a legacy feature and is not needed for communication.
| Command | Meaning |
|---|---|
docker compose --env-file .env.dev up -d |
Use another variables file |
docker compose -p myapp up -d |
Set the project name |
docker compose run --rm -e DEBUG=1 api node console.js |
One-off container with its own variable |
Substitution tricks
| Syntax | Meaning |
|---|---|
${TAG:-latest} |
Use latest when TAG is unset or empty |
${TAG-latest} |
Use latest only when TAG is unset |
${DB_PASSWORD:?set DB_PASSWORD} |
Stop with an error message when missing |
$$ |
A literal dollar sign passed to the container |
Variables come from the shell first, then from .env. docker compose config prints the final file with every variable resolved, which is the quickest way to check what Compose will really run.
Splitting networks
services:
proxy:
image: nginx:1.27
ports: ["80:80"]
networks: [frontend]
api:
build: .
networks: [frontend, backend]
db:
image: postgres:16
networks: [backend]
networks:
frontend:
backend:
internal: true
Here the proxy can reach api but not db, and db has no route to the internet. ports: publishes to the host, while expose: only documents ports for other services on the same network.
Secrets in Compose
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]
secrets:
db_password:
file: ./secrets/db_password.txt
The value is mounted as a file instead of appearing in docker inspect output. Many official images accept *_FILE variables for this reason. Keep the secrets folder out of Git.
Overrides and profiles
compose.override.yaml is merged automatically on top of compose.yaml, a common place for development-only settings such as bind mounts and debug ports. Profiles keep optional services out of the default start:
adminer:
image: adminer
profiles: [tools]
docker compose --profile tools up -d starts them only when you ask.