Docker Compose for local development: productive setup
Docker Compose for Local Development: A Productive Setup
Local development environments have always been a pain point for software teams. The classic "works on my machine" problem persists because each developer's laptop becomes a unique snowflake of installed dependencies, environment variables, and running services. Docker Compose solves this by defining your entire application stack in declarative configuration files, ensuring every team member works with identical infrastructure.
This guide walks through building a productive, real-world Docker Compose setup for local development. You will learn patterns that scale from simple web applications to complex microservices architectures.
Why Docker Compose Over Plain Docker
While docker run commands work for single containers, they become unmanageable quickly. A typical web application needs a database, cache, message queue, and perhaps multiple backend services. Docker Compose lets you define these relationships in a docker-compose.yml file and control them with simple commands.
The key advantages for local development include:
- Declarative configuration: Your entire stack lives in version-controlled YAML files
- Service discovery: Containers communicate by service name without manual network configuration
- Volume persistence: Code changes reflect immediately without rebuilding images
- Environment parity: The same Compose file (with minor modifications) can bootstrap CI pipelines
- Team onboarding: New developers run
docker compose upand have a working environment in minutes
Project Structure for Maintainability
Before diving into configuration, establish a consistent project layout. A well-organized structure separates concerns and makes your setup navigable:
myproject/
├── docker-compose.yml # Main orchestration file
├── docker-compose.override.yml # Local overrides (gitignored in some patterns)
├── docker/
│ ├── php/
│ │ ├── Dockerfile
│ │ └── php.ini
│ ├── nginx/
│ │ ├── Dockerfile
│ │ └── default.conf
│ └── mysql/
│ └── my.cnf
├── .env # Environment variables (gitignored)
├── src/ # Application source code
└── Makefile # Common command shortcuts
This separation allows different environments to extend the base configuration. Your production deployment might use docker-compose.prod.yml while developers work with the default file plus optional overrides.
The Core Compose File: A Practical Example
Let us build a complete setup for a modern web application: a Node.js API with PostgreSQL, Redis for caching, and Nginx as a reverse proxy. This pattern transfers to Python, PHP, Go, or any stack.
version: "3.9"
services:
api:
build:
context: .
dockerfile: docker/node/Dockerfile
target: development
container_name: myproject-api
restart: unless-stopped
volumes:
- ./src:/app:cached
- /app/node_modules
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- DATABASE_URL=postgres://postgres:postgres@db:5432/myapp
- REDIS_URL=redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
networks:
- backend
command: npm run dev
db:
image: postgres:15-alpine
container_name: myproject-db
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql/data
- ./docker/postgres/init:/docker-entrypoint-initdb.d
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: myapp
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
networks:
- backend
cache:
image: redis:7-alpine
container_name: myproject-cache
restart: unless-stopped
volumes:
- redis_data:/data
ports:
- "6379:6379"
networks:
- backend
nginx:
image: nginx:alpine
container_name: myproject-nginx
restart: unless-stopped
volumes:
- ./docker/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
ports:
- "80:80"
depends_on:
- api
networks:
- backend
volumes:
postgres_data:
redis_data:
networks:
backend:
driver: bridge
Several critical patterns appear in this configuration that deserve explanation.
Optimized Dockerfile for Development
The Node.js service uses a multi-stage Dockerfile with a development target. Building separate stages for development and production prevents bloated production images while keeping local debugging tools available:
# docker/node/Dockerfile
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM base AS development
RUN npm install -g nodemon
COPY . .
EXPOSE 3000
FROM base AS production
COPY . .
RUN npm run build
RUN npm prune --production
EXPOSE 3000
CMD ["node", "dist/server.js"]
The development stage includes nodemon for automatic restarts, while the production stage removes devDependencies and compiles assets. Reference the correct target in your Compose file to avoid shipping debugging tools.
Volume Mounting Strategies
The volumes configuration in the API service demonstrates two essential techniques:
- Bind mount with caching:
./src:/app:cachedsynchronizes local code changes into the container. Thecachedflag (Docker Desktop) improves performance on macOS by deferring consistency guarantees. - Anonymous volume:
/app/node_modulesprevents the host'snode_modulesfrom overwriting the container's installed dependencies. This avoids architecture-specific package issues when your host runs ARM64 but containers use AMD64.
For interpreted languages, bind mounts provide instant code reloading. Compiled languages might instead mount only configuration files and use volume triggers or rebuild workflows.
Health Checks and Startup Order
Docker Compose does not guarantee services start in a usable state, only that containers launch. The PostgreSQL service includes a healthcheck that pg_isready executes. The API service then declares depends_on with condition: service_healthy—ensuring it waits for actual database readiness, not just container startup.
Without this pattern, you encounter race conditions where applications crash trying to connect to databases still initializing. Implement health checks for every service your application depends upon.
Environment Management with Dotenv Files
Hardcoding credentials in Compose files creates security risks and reduces flexibility. Docker Compose automatically loads variables from a .env file in the same directory:
# .env
COMPOSE_PROJECT_NAME=myproject
POSTGRES_USER=devuser
POSTGRES_PASSWORD=devpassword
POSTGRES_DB=myapp_dev
API_PORT=3000
NGINX_PORT=8080
Reference these variables in your Compose file with substitution syntax:
services:
db:
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "${API_PORT}:3000"
Never commit .env files containing secrets. Provide a .env.example template instead, and document required variables in your README.
Performance Optimization for Mac and Windows
Docker Desktop on macOS and Windows runs containers inside a Linux VM, introducing filesystem performance penalties. Several optimizations mitigate this:
Use VirtioFS on macOS: Docker Desktop 4.6+ includes VirtioFS, dramatically improving bind mount performance. Enable it in Settings > General.
Optimize volume consistency: The delegated and cached flags trade consistency for speed. Use cached when the container rarely writes back to the mounted directory.
Consider docker-sync or mutagen: For large codebases, dedicated synchronization tools outperform native Docker volumes. Mutagen integrates natively with recent Docker Desktop versions.
Store heavy I/O in named volumes: Database files, build caches, and vendor directories belong in named volumes rather than bind mounts:
volumes:
- vendor_data:/app/vendor
volumes:
vendor_data:
Debugging and Development Workflows
Productive development requires debugging access. Configure services to expose debugging ports and use Docker Compose exec for interactive sessions:
# Attach to running container with shell
docker compose exec api sh
# Run one-off commands
docker compose exec db psql -U postgres -d myapp
# View logs with follow
docker compose logs -f api
# Restart single service after config changes
docker compose up -d --no-deps --build api
For Node.js debugging, expose the V8 inspector port:
services:
api:
ports:
- "3000:3000"
- "9229:9229" # Node inspector
command: npm run debug # uses --inspect=0.0.0.0:9229
Attach your IDE's debugger to localhost:9229 using the "Attach to Remote" configuration.
Database Migrations and Seed Data
Automate database preparation so new developers have populated data immediately. The PostgreSQL image executes scripts in /docker-entrypoint-initdb.d on first startup:
-- docker/postgres/init/01-schema.sql
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
-- docker/postgres/init/02-seed.sql
INSERT INTO users (email) VALUES ('dev@example.com');
For migration tools like Flyway or Prisma, add an initialization service that runs migrations before application startup:
services:
migrate:
build:
context: .
target: development
command: npx prisma migrate dev
depends_on:
db:
condition: service_healthy
volumes:
- ./src:/app:cached
networks:
- backend
Testing Integration
Run your test suite in containers matching your development environment. Create a separate Compose override for test execution:
# docker-compose.test.yml
services:
api:
environment:
- NODE_ENV=test
- DATABASE_URL=postgres://postgres:postgres@test-db:5432/myapp_test
depends_on:
- test-db
test-db:
image: postgres:15-alpine
environment:
POSTGRES_DB: myapp_test
tmpfs:
- /var/lib/postgresql/data
The tmpfs mount creates an in-memory database, speeding up test execution and ensuring isolation. Execute with: docker compose -f docker-compose.yml -f docker-compose.test.yml run --rm api npm test
Team Onboarding and Documentation
Compress your entire setup into discoverable commands. A Makefile provides self-documenting shortcuts:
# Makefile
.PHONY: up down build logs shell test
up:
docker compose up -d
down:
docker compose down -v
build:
docker compose build --no-cache
logs:
docker compose logs -f
shell:
docker compose exec api sh
test:
docker compose -f docker-compose.yml -f docker-compose.test.yml run --rm api npm test
migrate:
docker compose exec api npx prisma migrate dev
New team members need only clone, copy .env.example to .env, and run make up.
Common Pitfalls to Avoid
- Building production images in development: Separate concerns with multi-stage builds and distinct Compose files.
- Ignoring .dockerignore: Large build contexts slow operations. Exclude
node_modules,.git, and build artifacts. - Running as root in containers: Match container user IDs to your host user to avoid permission issues with bind mounts.
- Forgetting resource limits: Unrestricted containers consume excessive memory. Set limits in Compose for local realism.
- Over-engineering early: Start simple, add complexity only when patterns prove necessary.
Conclusion
A well-crafted Docker Compose setup eliminates environment inconsistencies and accelerates team productivity. The configurations presented here provide a foundation applicable across technology stacks. Focus on fast feedback loops through volume optimization, reliable startup sequencing with health checks, and discoverable workflows through Makefiles or similar tooling.
Invest time in your local development infrastructure—it pays continuous dividends as your team grows and your application evolves.