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.
Scaling a service locally
docker compose up --scale runs multiple instances of a single service, useful for testing behavior under multiple concurrent instances (load balancing, distributed locking, race conditions) without deploying to a real cluster:
docker compose up --scale app=3This only works cleanly for services that don't publish a fixed host port directly (three instances can't all bind host port 3000) — either omit the ports mapping for the scaled service and access it through another service in the same network, or put a lightweight reverse proxy (like nginx) in front of it within the Compose network to load-balance across the scaled instances.
Named networks for multi-project isolation
By default, Compose creates a network scoped to the project (the directory name, or COMPOSE_PROJECT_NAME), so two unrelated projects each running docker compose up don't interfere with each other even if they both define a service called db. For genuinely shared infrastructure across multiple Compose projects (a shared local Postgres instance multiple app repos connect to), an explicit external network is the right pattern instead of relying on each project's isolated default network:
networks:
shared-db:
external: truedocker network create shared-dbLayering multiple Compose files with -f
Beyond the automatic docker-compose.override.yml merge, explicit -f flags let you compose several files deliberately — a common pattern for a base file plus environment-specific additions (local, CI, staging-like):
docker compose -f docker-compose.yml -f docker-compose.ci.yml upLater files override or extend earlier ones field by field, not wholesale — a docker-compose.ci.yml might only override environment variables for one service while everything else still comes from the base file, letting CI-specific tweaks stay minimal and explicit rather than duplicating the entire stack definition.
Once a service defined in Compose is ready to actually ship, the same image benefits from multi-stage Docker builds to keep it lean, and the infrastructure it deploys onto is the kind of thing worth managing with Infrastructure as Code rather than clicking through a console by hand.
Common mistakes
- Mounting the entire project directory without excluding
node_modules(or the language equivalent), then debugging confusing native-module errors that are actually a host/container platform mismatch, not a real bug. - Relying on
depends_onalone for startup ordering and assuming it means "ready," not just "container process started" — leads to intermittent first-connection failures that look flaky but are actually a real, fixable race condition. - Committing real secrets (database passwords, API keys) directly into
docker-compose.ymlinstead of an untracked.envfile — fine for genuinely disposable local-only credentials, a real risk if that same file is ever reused as a template for a shared or production config. - Never pruning unused volumes and images. Local Docker installs accumulate stopped containers, dangling images, and orphaned volumes over time —
docker system prune(ordocker volume prunespecifically) periodically prevents this from quietly consuming significant disk space.
Check your understanding
Docker Basics Quiz
Which command builds a Docker image from a Dockerfile in the current directory?
True or false: containers created from the same image share the exact same writable filesystem layer.
Fill in the missing flag: this command should map the host's port 8080 to the container's port 80.
docker run ___ 8080:80 nginx
Your container exits immediately after `docker run` with no error output. What's the most likely first thing to check?
Related reading
- Docker Multi-Stage Builds: A Step-by-Step Tutorial — shares tags: docker, devops (same category).
- CI/CD Pipelines Explained: From Commit to Production — shares tags: devops.
- Understanding Cloud Cost Optimization Basics — shares tags: devops.
- Git Hooks Explained: Automate Your Workflow — shares tags: devops.
- Infrastructure as Code: Why Terraform Won — shares tags: devops.