Why Compose instead of five terminal tabs
Onboarding used to be install Postgres 16, Redis 7, create a role, remember the port conflicts with the other project. New hires lost a day. Compose gives us reproducible ports, versions, and seed scripts. I still run the Node process on the host with hot reload when I iterate fast, and only containerize Node when we need parity with production images.
Two modes: full stack in Compose for demos and CI, and docker compose up db redis while npm run dev hits localhost mapped ports. Both share the same compose.yaml.
Before Compose, we had a wiki page with 14 steps and three different Homebrew formulae depending on macOS version. After Compose, first successful up median time for new hires went from roughly a day to under 30 minutes, including cloning and copying .env.example.
A Compose file that survives Monday mornings
Pin image tags. postgres:latest bit us when a major jumped overnight. Named volumes keep data across restarts. Healthchecks stop the API from racing migrations before Postgres accepts connections — that race caused flaky CI for weeks.
I also set restart unless-stopped on db and redis for long-lived laptops, and I keep resource limits light so a runaway migration cannot freeze Docker Desktop. Memory 512M for Postgres is enough for local datasets under a few GB.
services:
db:
image: postgres:16.3-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app_dev
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./docker/db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app_dev"]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:7.2-alpine
ports:
- "6379:6379"
command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
api:
build:
context: .
dockerfile: Dockerfile.dev
env_file: .env.docker
environment:
DATABASE_URL: postgres://app:app@db:5432/app_dev
REDIS_URL: redis://redis:6379
ports:
- "4000:4000"
volumes:
- .:/app
- /app/node_modules
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
volumes:
pgdata:
Networking and .env gotchas
Inside the Compose network, hostname is the service name db or redis, not localhost. I have pasted localhost into a containerized API more times than I will admit — connection refused, 30 minutes of blame on Postgres.
Host-run Node must use localhost:5432. Container-run Node must use db:5432. We keep two env files: .env for host and .env.docker for Compose, or we template DATABASE_URL in scripts. Document that in README in bold; it is the number one Slack question.
Port collisions are the other weekly pain. If another project already bound 5432, Compose fails in a cryptic way. I map host 5433 to container 5432 for secondary projects and put the host port in .env so people do not hardcode.
Anonymous volumes for node_modules
The trick of bind-mounting . to /app plus an anonymous volume at /app/node_modules prevents the host from overwriting Linux node_modules with macOS binaries or an empty folder. Without the anonymous volume, native modules like bcrypt crash with invalid ELF headers.
When dependencies change, I run docker compose run --rm api npm ci or rebuild. Cold npm ci in the image took about 90s on our M2; mounting source keeps edit-reload under a second with nodemon.
# Dockerfile.dev
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
CMD ["npm", "run", "dev"]
Migrations that wait for healthy Postgres
Even with depends_on healthchecks, I wrap migration commands in a tiny retry script for CI. Postgres can accept connections and still reject the first migration if the role setup from init.sql is mid-flight on a slow disk.
We run migrations as a one-shot Compose service in CI, not as a side effect of API boot, so failed migrations fail the job clearly instead of leaving a half-booted API.
# scripts/wait-and-migrate.sh
set -euo pipefail
for i in $(seq 1 30); do
if pg_isready -h "$PGHOST" -U "$PGUSER" -d "$PGDATABASE"; then
npx prisma migrate deploy
exit 0
fi
sleep 2
done
echo "Postgres never became ready" >&2
exit 1
Seeding data without fighting init.sql
init.sql runs only on first volume create. For day-to-day seed data I use a Make target that runs a Node script against localhost after Compose is healthy. That script is idempotent with upsert by natural key so re-running it does not duplicate rows.
When I need a clean slate I explicitly run docker compose down -v, bring db and redis back, then migrate and seed. Teaching that reset stopped people from editing init.sql and wondering why nothing changed. Large dumps belong outside git on object storage.
Resetting state without drama
docker compose down keeps volumes. docker compose down -v wipes Postgres data — useful for migration experiments, catastrophic if you thought it was a soft stop. I alias dc-reset only after confirming the project name.
For shared laptops, set COMPOSE_PROJECT_NAME so volume names do not collide with another repo also calling its service db.
- Pin major.minor image tags
- Healthcheck before depends_on
- Use service DNS names inside the network
- Anonymous volume for node_modules on bind mounts
- Know the difference between down and down -v
- Remap host ports when 5432 or 6379 are already taken
CI uses the same Compose file
In GitHub Actions we run docker compose up -d db redis, wait for health, then run tests on the runner with host networking to published ports. Sharing the Compose file means local and CI Postgres majors cannot drift.
We cache the Postgres image layers and avoid building the API image in unit-test jobs. Integration jobs that need the API container build with BuildKit cache mounts so npm ci is not a cold 90s every time. A fixed sleep was sometimes too short on busy runners; healthchecks fixed that class of flake permanently.
What I do not put in local Compose
I avoid shipping production secrets in compose. Local passwords are app/app and never reused. I do not emulate the full Kubernetes mesh locally unless we are debugging mesh-specific bugs — Compose is for data stores and optional API parity.
Redis persistence is optional locally; I enable light RDB saves so cache warmups survive a reboot during a long feature branch. Postgres init scripts run only on empty volumes — after the first boot, change schema with migrations, not by editing init.sql and expecting it to re-run.
In GitHub Actions we run the same Compose file for db and redis, wait for health, then run tests on published ports. Flakes dropped once healthchecks gated the test start instead of a fixed sleep 10. Publish a one-line happy path in the README: compose up db redis, copy env, npm i, npm run dev.
Key takeaways
- Compose the databases; optionally run Node on the host for faster reload.
- Pin image versions and wait on healthchecks before starting the API.
- localhost versus service name depends on whether the process is in a container.
- Bind-mount source with an anonymous node_modules volume to avoid native binary clashes.
- Treat down -v as destructive; use a unique COMPOSE_PROJECT_NAME per repo.
- Run migrations as an explicit step with retries, not as a silent boot side effect.
About the author
Ram — Founder & Editor, BudhiWorks. I build and ship production web apps — Node, React/Next.js, Postgres, and the boring infrastructure that keeps them online. BudhiWorks is where I publish the guides I wish I had when something broke at 2 a.m.