The core idea
Two different things in Compose both get called "env", and mixing them up causes most of the confusion:
- Variables in the compose file, like
${POSTGRES_PASSWORD}. Compose fills these in from your shell or a.envfile before it does anything else. - Environment variables in a container, set with
environment:orenv_file:. These are what the app sees.
Variables in the file
services:
proxy:
ports:
- "${PROXY_PORT:-8080}:80"
api:
environment:
DATABASE_URL: postgres://${POSTGRES_USER:-notes}:${POSTGRES_PASSWORD:-notes}@db:5432/${POSTGRES_DB:-notes}
MAX_NOTE_LENGTH: ${MAX_NOTE_LENGTH:-280}${VAR:-default} uses default when VAR is unset or empty. Others you'll meet: ${VAR} (empty if unset) and
${VAR:?message} (refuse to run if unset), handy for things that must never default, like production passwords.
Values come from, highest priority first:
- your shell:
MAX_NOTE_LENGTH=99 docker compose up - a file named
.envnext tocompose.yaml, read automatically - the default in the file
docker compose config prints the file with everything filled in, so you can check before you run:
$ docker compose config | grep MAX_NOTE_LENGTHMAX_NOTE_LENGTH: "280"$ echo "MAX_NOTE_LENGTH=50" > .env$ docker compose config | grep MAX_NOTE_LENGTHMAX_NOTE_LENGTH: "50"$ MAX_NOTE_LENGTH=99 docker compose config | grep MAX_NOTE_LENGTHMAX_NOTE_LENGTH: "99"
The project commits a .env.example with safe defaults and ignores .env, so everyone copies the example and
changes their own copy:
# Copy to .env and change as needed. Compose reads .env from this folder automatically (see 06.04).
POSTGRES_USER=notes
POSTGRES_PASSWORD=notes
POSTGRES_DB=notes
MAX_NOTE_LENGTH=280
PROXY_PORT=8080Variables in the container
environment: sets variables directly. env_file: loads them from a file into that service's container:
services:
api:
env_file: ./api/.env.local # every KEY=value in it becomes a container variable
environment:
LOG_LEVEL: debug # environment: wins over env_file for the same keyThe difference in one line: .env configures Compose; env_file: configures a container. A variable in .env
only reaches the app if the compose file passes it on, like MAX_NOTE_LENGTH above.
Profiles: optional services
Some services are only sometimes useful: a database UI, a load generator, a debugging toolbox. Give them a profile and they're skipped unless you ask for it:
adminer:
image: adminer
profiles: [tools]
ports:
- "127.0.0.1:8081:8080"
environment:
ADMINER_DEFAULT_SERVER: db
networks: [backend]$ docker compose up -d # proxy, api, db, cache: no adminer$ docker compose --profile tools up -d # the same, plus adminer$ curl -s -o /dev/null -w "%{http_code}\n" localhost:8081200
Adminer is a web UI for Postgres at localhost:8081, published on 127.0.0.1 only so it isn't reachable from your
network. You can also set COMPOSE_PROFILES=tools in .env to turn it on by default for yourself.
Your .env contains DEBUG=true. compose.yaml never mentions DEBUG. Does the API container see DEBUG?
Copy .env.example to .env, set PROXY_PORT=9090 and MAX_NOTE_LENGTH=50, and confirm both with docker compose config. Run up -d, reach the API on 9090, and check that a 60-character note is rejected. Then start Adminer with the tools profile and log in with the values from your .env.
STUCK? ▸
If docker compose config still shows the default, check that .env is in the same folder as compose.yaml and that there are no spaces around =.
Recap
Interview questions
What's the difference between the .env file and env_file in Docker Compose?