The development compose file
# docker-compose.yml
services:
api:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
environment:
- DATABASE_URL=postgresql://dev:dev@postgres:5432/myapp
- REDIS_URL=redis://redis:6379
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: dev
POSTGRES_PASSWORD: dev
POSTGRES_DB: myapp
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dev"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
mailpit:
image: axllent/mailpit
ports:
- "1025:1025"
- "8025:8025"
volumes:
postgres_data:
The dev Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install
COPY . .
CMD ["pnpm", "dev"]
Notice the volume mount: .:/app syncs your local code into the container, but /app/node_modules is excluded so the container uses its own installed dependencies. This avoids platform mismatches (macOS node_modules inside a Linux container).
Health checks prevent race conditions
Without the healthcheck on PostgreSQL, your API container starts before the database is ready to accept connections. The depends_on with condition: service_healthy waits for PostgreSQL to actually respond to queries, not just start the process.
Seeding data
Mount an init script to populate the database on first start:
-- scripts/init.sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
INSERT INTO users (id, email, name, role) VALUES
(uuid_generate_v4(), 'admin@test.com', 'Admin User', 'admin'),
(uuid_generate_v4(), 'user@test.com', 'Test User', 'user');
This only runs when the volume is empty (first docker compose up). To re-seed, delete the volume: docker compose down -v.
Mailpit for email testing
Mailpit captures all outgoing email on port 1025 and shows it in a web UI on port 8025. Point your app's SMTP config at mailpit:1025 and every email your app sends is visible at http://localhost:8025. No more sending test emails to real addresses.
Matching production versions
This is the whole point. If production runs PostgreSQL 16, your compose file should too. Not 15, not "latest." Pin the exact major version:
postgres:
image: postgres:16-alpine # Match production
I have seen bugs caused by PostgreSQL version differences — a query that uses a feature added in 15 will fail silently or behave differently in 14.
Useful commands
docker compose up -d # Start everything in background
docker compose logs -f api # Follow API logs
docker compose exec postgres psql -U dev myapp # Database shell
docker compose down # Stop everything
docker compose down -v # Stop and delete volumes (fresh start)
The one rule
If a new developer cannot run docker compose up and have a working environment within 5 minutes, your compose file is incomplete.
