Before Compose 514 parses compose.yaml, it interpolates every ${...} from the shell environment and from a file named .env in the project directory. The shell wins over .env, and .env wins over defaults written in the file:
| Syntax | Result |
|---|---|
| ${VAR} or $VAR | The value, or an empty string with a warning |
| ${VAR:-default} | default if VAR is unset or empty |
| ${VAR-default} | default only if VAR is unset |
| ${VAR:?message} | Stops with message if VAR is unset or empty |
| ${VAR:+other} | other if VAR is set and not empty |
| $$ | A literal $ |
BookNest's defaults make docker compose up work on a fresh clone. On this shared machine a .env file moves the ports and renames the project with COMPOSE_PROJECT_NAME, which prefixes every container, network, volume and image, so this chapter's stack runs as l3-booknest beside others. .gitignore and .dockerignore already exclude .env, so it never reaches Git 1,932 or an image. Delete the legacy file, then write .env:
git rm -q docker-compose.yml
printf '%s\n' COMPOSE_PROJECT_NAME=l3-booknest API_PORT=33000 DB_PORT=33432 \
POSTGRES_PASSWORD=bn-dev-2026 > .env
docker compose config | grep -E '^name|PASSWORD|published'name: l3-booknest
PGPASSWORD: bn-dev-2026
published: "33000"
POSTGRES_PASSWORD: bn-dev-2026
published: "33432"docker compose config prints the file exactly as Compose will use it, which makes it the first debugging step for any interpolation surprise. Do not confuse .env with the env_file: attribute of a service: .env feeds interpolation of the YAML, while env_file: [api.env] loads variables into that container's environment. --env-file prod.env swaps the interpolation file, and ${POSTGRES_PASSWORD:?} would make a missing production password fail loudly.