Section 3 · Local Environments with Docker Compose
Env Files, Overrides and Watch Mode
20 min3 min readFill in the blanks
Lesson at a glance
Move notes-api’s configuration into env files and Compose secrets, split dev-only settings into an override file and profiles, and get a fast edit loop with docker compose watch.
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
The notes-api Compose file now works, and it has three problems teams hit in the second week. The database password sits in plain text in a committed file. Dev-only settings like debug ports are mixed in with what CI needs. And every code change means docker compose up --build and a thirty-second wait. This lesson fixes all three.
Two kinds of environment
Compose has two separate mechanisms that both involve “env”, and mixing them up causes most configuration confusion.
Interpolation fills in ${VAR} placeholders in the Compose file itself. Values come from your shell, then from a .env file in the project directory:
POSTGRES_PASSWORD=change-me-locally
API_PORT=8080services:
api:
ports:
- "${API_PORT:-8080}:3000"
db:
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}${API_PORT:-8080} falls back to 8080 if unset. ${POSTGRES_PASSWORD:?…} makes the value required: if it’s missing, Compose stops with your message instead of starting Postgres with an empty password.
Container environment is what the process inside sees. It comes from environment: and env_file: on the service:
api:
env_file: .env.api
environment:
DATABASE_URL: postgres://notes:${POSTGRES_PASSWORD}@db:5432/notesThe project .env is not passed into containers by itself. If a value should reach the app, reference it under environment: or list the file under env_file:. When the same variable comes from both, environment: wins.
For interpolation, a variable set in your shell beats the same one in .env, so API_PORT=9090 docker compose up -d is a quick one-off override without editing any file. To use a different file entirely, pass --env-file .env.staging. Keep the number of these files small; three slightly different env files is how teams end up debugging configuration instead of code.
Commit a .env.example with dummy values and add .env to .gitignore. New teammates copy the example and fill it in.
Compose secrets
Environment variables are visible in docker inspect and to every process in the container. For passwords, Compose can mount a file instead:
services:
db:
image: postgres:17
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txtThe secret appears inside the container at /run/secrets/db_password. The Postgres image reads POSTGRES_PASSWORD_FILE, and many official images support the same _FILE convention. For your own app, read the file at startup. Kubernetes Secrets mounted as files work the same way, so the habit transfers.
Base file plus override
Compose automatically merges compose.override.yaml on top of compose.yaml if it exists. Use that split on purpose:
compose.yaml: what every environment needs, CI included.compose.override.yaml: developer conveniences, like published database ports, debug flags and watch rules.
CI can then run with the base file only: docker compose -f compose.yaml up -d --wait. When you pass -f, the override isn’t loaded automatically.
Profiles cover optional services. A pgAdmin container that only some people want gets profiles: [tools], and starts only with docker compose --profile tools up.
Watch mode: a fast edit loop
Rebuilding the image for every edit is slow. Bind-mounting the whole project is fragile: your host’s node_modules (built for macOS) shadows the container’s (built for Linux), and file access across the Docker Desktop VM is slow. Compose watch solves both by syncing only the files you choose into the running container.
services:
api:
build:
context: .
target: build
command: ["npm", "run", "dev"]
develop:
watch:
- action: sync
path: ./src
target: /app/src
- action: rebuild
path: package.json
- action: rebuild
path: package-lock.jsonFor development, the override builds the build stage (which has dev dependencies) and runs npm run dev, which in notes-api is tsx watch src/server.ts, a TypeScript runner that restarts on changes. Then:
docker compose up --watchEdit src/routes.ts and the file is copied into /app/src within a second; tsx restarts the server. Change package-lock.json and Compose rebuilds the image and replaces the container, because new dependencies need a real install. A third action, sync+restart, syncs files and restarts the container, which suits config files the app reads only at startup. docker compose watch does the same as up --watch with quieter output.
Section 3 is complete: a reproducible local stack with persistent data, ordered startup, safe configuration and a fast edit loop. Next you’ll take the same image to Kubernetes, starting with how Kubernetes thinks.
Key takeaways
- The project
.envfile feeds${VAR}interpolation incompose.yaml;env_file:andenvironment:set variables inside containers. They are different mechanisms. ${VAR:?message}makes Compose fail fast with a clear message when a required value is missing.docker compose configprints the fully merged and interpolated file, which is the quickest way to debug configuration.compose.override.yamlis merged automatically, so keep shared settings incompose.yamland dev-only ones in the override.develop.watchwithsynccopies edited files into the running container, andrebuildrebuilds the image when dependencies change.