Running a database, a cache, and your app together locally usually means either installing each one natively (version conflicts across projects, guaranteed) or hand-writing a handful of docker run commands you forget the flags to every time. Compose fixes both.
A typical setup
# docker-compose.yml
services:
app:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://postgres:postgres@db:5432/myapp
depends_on:
- db
- redis
volumes:
- .:/app
- /app/node_modules
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: myapp
ports:
- "5432:5432"
volumes:
- db-data:/var/lib/postgresql/data
redis:
image: redis:7
ports:
- "6379:6379"
volumes:
db-data:docker compose up # start everything
docker compose up -d # same, but detached (background)
docker compose down # stop and remove containers
docker compose logs -f app # follow logs for one serviceOne command brings up the app, a Postgres database, and Redis, all networked together automatically — services can reach each other by name (db, redis) without manually configuring a Docker network.
The volumes trick that avoids a common node_modules bug
volumes:
- .:/app # mount the whole project directory
- /app/node_modules # but NOT node_modules — use the container's ownWithout the second line, mounting your entire project directory into the container also overwrites the container's node_modules with whatever's on your host machine — which can be the wrong platform's compiled binaries (e.g., host is macOS, container is Linux) and cause cryptic native-module errors. This pattern keeps the code live-synced for hot reload while letting the container manage its own dependencies.
Environment-specific overrides
# docker-compose.override.yml (automatically merged with docker-compose.yml)
services:
app:
command: npm run dev
environment:
NODE_ENV: developmentCompose automatically merges docker-compose.override.yml on top of the base file if it exists — a clean way to keep local-only tweaks (like running a dev server with hot reload) separate from a base config that might also be used in CI.
Running one-off commands inside a service
docker compose exec app npm run migrate
docker compose exec db psql -U postgres -d myappexec runs a command inside an already-running container — the way you'd run a database migration, open a database shell, or debug something without stopping the stack.
Waiting for dependencies to actually be ready
depends_on controls startup order, not readiness — Postgres's container can report "started" before it's actually accepting connections, which can cause the app to fail on its first connection attempt. For anything beyond local convenience, add a healthcheck:
services:
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 2s
timeout: 5s
retries: 5
app:
depends_on:
db:
condition: service_healthyThis makes app actually wait until Postgres is verified ready to accept connections, not just "the container process started."
Why this beats installing everything natively
Every teammate runs the exact same Postgres version, the exact same Redis version, with zero risk of "works on my machine" caused by a locally installed version drifting from what production actually runs. Onboarding a new developer becomes git clone + docker compose up, instead of a setup document that's perpetually out of date.