Docker Compose for Local Development: A Practical Guide for Developers
On this page
You're juggling PostgreSQL, Redis, a Node.js API, and a React frontend on your machine. One service breaks, you spend 20 minutes debugging environment variables. Another dev clones your repo and can't get it running without three Slack messages. Docker Compose solves this—it's the difference between "works on my machine" and "works everywhere."
TL;DR: Master Docker Compose for local development. Learn to orchestrate multi-container applications with practical examples, best practices, and real-world workflows.
If you've been manually spinning up containers or worse, installing everything locally, you're burning time you don't have. This guide shows you exactly how to set up Docker Compose for real-world development workflows, complete with the patterns I use across projects.
Why Docker Compose Matters for Local Development
Docker itself lets you containerize applications. Docker Compose orchestrates multiple containers as a single system. For developers, this means:
- Reproducible environments: Every team member runs identical services
- No pollution: Your machine stays clean—no globally installed databases
- Service networking: Containers talk to each other seamlessly
- Volume mounting: Live code changes without rebuilding
- One-command startup:
docker compose upand everything runs
The alternative is shell scripts, makefiles, or worse—memory. Compose is declarative, version-controlled, and self-documenting.
Your First Docker Compose Setup
Let's build a realistic stack: a Node.js API, PostgreSQL database, and Redis cache.
Create a docker-compose.yml file at your project root:
version: '3.9'
services:
api:
build:
context: .
dockerfile: Dockerfile
container_name: my-api
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- DATABASE_URL=postgresql://postgres:password@db:5432/myapp
- REDIS_URL=redis://cache:6379
volumes:
- .:/app
- /app/node_modules
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
networks:
- app-network
db:
image: postgres:16-alpine
container_name: my-postgres
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
- POSTGRES_DB=myapp
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
networks:
- app-network
cache:
image: redis:7-alpine
container_name: my-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- app-network
volumes:
postgres_data:
redis_data:
networks:
app-network:
driver: bridge
This is a production-grade template. Let me break down the critical parts.
Understanding the Core Concepts
Service Definition
Each service under services: is a container. The api service uses a local Dockerfile, while db and cache use pre-built images from Docker Hub.
# Dockerfile for the API
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["node", "src/index.js"]
Keep your Dockerfile clean and lightweight. Alpine images reduce size; npm ci ensures reproducible builds.
Volumes: The Game Changer
volumes:
- .:/app # Bind current directory to /app
- /app/node_modules # Keep node_modules in container
The first volume syncs your code into the container. Changes locally reflect instantly—no rebuild needed. The second prevents local node_modules from overwriting container dependencies.
For databases, named volumes persist data:
volumes:
- postgres_data:/var/lib/postgresql/data
This survives docker compose down. Data lives even when containers stop.
Environment Variables
Pass config through environment:
environment:
- DATABASE_URL=postgresql://postgres:password@db:5432/myapp
- REDIS_URL=redis://cache:6379
Inside containers, services reference each other by name (db, cache). Docker's embedded DNS resolves these to container IPs automatically.
Never hardcode secrets in docker-compose.yml. Use .env files instead:
# .env
POSTGRES_PASSWORD=supersecret
API_TOKEN=dev-token-12345
Reference them in your compose file:
environment:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- API_TOKEN=${API_TOKEN}
Health Checks and Dependencies
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
Wait for the database to actually be ready (not just running) before starting the API. This prevents connection errors on startup.
The healthcheck on PostgreSQL ensures it's truly accepting connections:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
Real-World Workflows
Starting Your Dev Environment
docker compose up
This builds and starts all services. The -d flag runs in background:
docker compose up -d
View logs from all services:
docker compose logs -f
Or logs from a specific service:
docker compose logs -f api
Making Database Changes
Migrations live in your repo. On startup, run them automatically:
api:
command: sh -c "npm run migrate && node src/index.js"
Or for manual runs:
docker compose exec api npm run migrate
Debugging Inside Containers
docker compose exec api bash
This drops you into a shell inside the container. Useful for checking environment variables, inspecting files, or running commands.
docker compose exec api npm test
Run tests without leaving your terminal.
Resetting Everything
docker compose down
Stops and removes containers, but preserves named volumes.
docker compose down -v
Includes volumes—everything gone, fresh start. Useful when your database schema is corrupted during development.
Multi-Environment Compose Files
For staging or production differences, use overrides:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up
The prod file overrides base settings. Example:
# docker-compose.prod.yml
services:
api:
build:
context: .
cache_from:
- my-registry/api:latest
restart: always
environment:
- NODE_ENV=production
Common Pitfalls and Solutions
Ports Already in Use
Error response from daemon: driver failed programming external connectivity on endpoint
Your machine already has something on port 3000. Change it:
ports:
- "3001:3000" # Access API at localhost:3001
Containers Can't Reach Each Other
Always define a network explicitly:
networks:
app-network:
driver: bridge
Then add all services to it. This ensures service names resolve correctly.
Permission Denied on Volumes
Linux containers run as root by default. Mounted volumes might have permission issues. Set the user explicitly:
api:
user: "1000:1000" # Your UID:GID
volumes:
- .:/app
Find your UID with id.
Database Won't Start
docker compose logs db
Usually it's a volume permission issue or missing init script. Verify the path exists:
ls -la ./init.sql
Disk Space Blows Up
Images and layers accumulate. Clean up:
docker system prune -a
Remove unused images and containers. Add --volumes to clear volumes too (careful—deletes data).
Performance Optimization
Reduce Build Time
Use .dockerignore to exclude unnecessary files:
node_modules
.git
.env
dist
build
Cache layers efficiently in your Dockerfile:
# Bad: reinstalls on every code change
COPY . .
RUN npm install
# Good: only reinstalls if dependencies change
COPY package*.json ./
RUN npm install
COPY . .
Optimize Volume Performance
On macOS and Windows, bind mounts are slow. Use named volumes for non-source code:
volumes:
- .:/app # Source code
- /app/node_modules # Keep in container
Use Alpine Images
image: postgres:16-alpine
image: redis:7-alpine
image: node:20-alpine
Alpine is 5-10x smaller than standard images. Pulls faster, starts faster.
Testing and CI Integration
Run tests locally:
docker compose run --rm api npm test
The --rm flag removes the container after completion.
In GitHub Actions:
services:
api:
image: node:20-alpine
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: password
Compose isn't needed in CI—use service containers instead. But your local setup should mirror it.
Monitoring and Troubleshooting
Check Container Status
docker compose ps
Shows all containers, their status, and ports.
Inspect Configuration
docker compose config
Prints the resolved compose file after environment variable substitution. Useful when debugging .env issues.
View Resource Usage
docker stats
Real-time CPU and memory usage. Identifies resource hogs.
Production Considerations
Docker Compose isn't for production. Use Kubernetes, ECS, or Cloud Run instead. But your local setup should be production-adjacent:
- Match image versions exactly (
postgres:16-alpine, notpostgres:latest) - Use
restart: alwaysin production, never in development - Secrets come from secure vaults, not
.envfiles - Health checks are production-critical, worth setting up locally too
Final Checklist
Before shipping your project:
- All team members run
docker compose upand everything works - Services are named and networked explicitly
- Data persists in named volumes, not
bindmounts - Health checks verify service readiness
-
.env.exampledocuments all required variables -
.gitignoreexcludes.envanddocker-compose.override.yml - README includes setup instructions
- Database init scripts are version-controlled
Docker Compose is a force multiplier. It removes entire categories of "works on my machine" bugs, accelerates onboarding, and makes your development experience consistent. Invest 30 minutes now to set it up properly, and you'll save hours across your team.
Sources
Looking for more? Check out Adaptels.
Related Articles
How to Debug Node.js Memory Leaks (Step-by-Step Guide)
Learn how to detect, diagnose, and fix Node.js memory leaks using heap snapshots, Chrome DevTools, and clinic.js — with real code examples.
How to Set Up GitHub Actions for CI/CD (Beginner-Friendly Guide)
Learn how to set up GitHub Actions for CI/CD pipelines — from your first workflow file to automated deployments with real YAML examples.
Running Local LLMs With Ollama: Developer Setup Guide
Set up Ollama to run local LLMs on your machine. Covers installation, model selection, API usage, and integrating local models into your dev workflow.