Docker Compose for Local Development Environments
New engineer. Day one. Enthusiastic. Ready to ship.
Four hours later, they’re still installing PostgreSQL 9.6 because the app doesn’t work on 9.5, configuring Redis, debugging a Node version mismatch, and quietly wondering if they chose the wrong company.
Meanwhile, the senior dev’s machine “just works” because they set it up in 2014 and haven’t thought about it since. Different patch versions. Different env vars. Different /etc/hosts hacks. The classic “works on my machine” trap — except now it’s institutionalized across the team.
Docker Compose fixed this for us. One docker-compose up and everyone runs the same Postgres, same Redis, same service topology. Onboarding dropped from half a day to twenty minutes. “Works on my machine” became “works on everyone’s machine, because it’s the same machine, virtually.”
Here’s how we built local dev environments that actually reproduce production — without reproducing production’s complexity on day one.
What Compose Gives You
Before Compose, local setup was artisanal:
- Install databases manually, hope versions match
- Configure each service in isolation
- Fight port conflicts with whatever else is running
- Document the setup in a wiki that was wrong by Tuesday
Compose replaces that with a YAML file that is the documentation:
- One command starts the full stack
- Every developer gets identical services
- Cleanup is
docker-compose down -v, not “reinstall Postgres” - Production-like topology without production-like AWS bills
The Starter Stack
A web app, a database, a cache. The holy trinity:
# docker-compose.yml
version: '3.8'
services:
web:
build: .
ports:
- "8000:8000"
volumes:
- .:/app
depends_on:
- db
- redis
db:
image: postgres:9.6
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
POSTGRES_PASSWORD: secret
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:3-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:
docker-compose up -d
That’s it. Web on 8000, Postgres on 5432, Redis on 6379. New hire runs two commands: git clone, docker-compose up -d. They’re coding before lunch.
Dev vs. Prod: Same Services, Different Hats
Don’t maintain two completely separate compose files. Use a base file and overrides:
# docker-compose.dev.yml
version: '3.8'
services:
web:
build:
context: .
dockerfile: Dockerfile.dev
volumes:
- .:/app
- /app/node_modules # Prevent overwrite
environment:
- NODE_ENV=development
- DEBUG=true
command: npm run dev # Hot reload
db:
ports:
- "5432:5432" # Expose for local tools
environment:
- POSTGRES_LOG_STATEMENT=all # Debug queries
redis:
ports:
- "6379:6379"
# docker-compose.prod.yml
version: '3.8'
services:
web:
build:
context: .
dockerfile: Dockerfile.prod
environment:
- NODE_ENV=production
restart: always
db:
environment:
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
secrets:
- db_password
restart: always
secrets:
db_password:
file: ./secrets/db_password.txt
# Development
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up
# Production
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up
Dev exposes ports for local DB clients and enables hot reload. Prod uses secrets and restart policies. Same service names, different behavior. The /app/node_modules anonymous volume is critical — without it, your bind mount overwrites node_modules with your host’s (possibly empty, possibly wrong-architecture) directory.
Full-Stack Reality: Laravel + Vue + Workers
Real apps aren’t one container. Here’s a stack that mirrors production:
# docker-compose.yml
version: '3.8'
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
- ./public:/var/www/public
depends_on:
- php
- frontend
php:
build:
context: .
dockerfile: docker/php/Dockerfile
volumes:
- ./:/var/www
- ./docker/php/local.ini:/usr/local/etc/php/conf.d/local.ini
environment:
- DB_HOST=db
- DB_DATABASE=laravel
- DB_USERNAME=laravel
- DB_PASSWORD=secret
- REDIS_HOST=redis
depends_on:
- db
- redis
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.dev
volumes:
- ./frontend:/app
- /app/node_modules
ports:
- "3000:3000"
command: npm run dev
db:
image: postgres:9.6
environment:
POSTGRES_DB: laravel
POSTGRES_USER: laravel
POSTGRES_PASSWORD: secret
volumes:
- postgres_data:/var/lib/postgresql/data
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
redis:
image: redis:3-alpine
volumes:
- redis_data:/data
queue:
build:
context: .
dockerfile: docker/php/Dockerfile
command: php artisan queue:work --tries=3
volumes:
- ./:/var/www
depends_on:
- db
- redis
volumes:
postgres_data:
redis_data:
Including the queue worker locally catches job serialization bugs before they hit staging. “It worked in tinker” is not the same as “it worked when dispatched to a worker.”
Database Seeding Without Manual Steps
Init scripts run on first container start. Migrations and seeds run on demand:
services:
db:
image: postgres:9.6
environment:
POSTGRES_DB: myapp
volumes:
- postgres_data:/var/lib/postgresql/data
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
migrate:
build: .
command: php artisan migrate --force
volumes:
- .:/app
depends_on:
- db
profiles:
- tools # Only run when explicitly requested
seed:
build: .
command: php artisan db:seed
volumes:
- .:/app
depends_on:
- db
- migrate
profiles:
- tools
docker-compose --profile tools up migrate
docker-compose --profile tools up seed
Profiles keep one-off tasks out of the default docker-compose up. You don’t want migrations running every time someone starts their laptop.
Hot Reload: Edit Code, See Changes
Node.js with Nodemon
services:
api:
build:
context: .
dockerfile: Dockerfile.dev
volumes:
- .:/app
- /app/node_modules
command: nodemon --watch /app --exec "node /app/index.js"
environment:
- NODE_ENV=development
Python with Watchdog
services:
api:
build:
context: .
dockerfile: Dockerfile.dev
volumes:
- .:/app
command: watchmedo auto-restart --directory=/app --pattern="*.py" --recursive -- python /app/main.py
PHP: Opcache Timestamps
services:
php:
volumes:
- .:/var/www
environment:
- PHP_OPCACHE_VALIDATE_TIMESTAMPS=1 # Check for changes
Bind mounts sync source instantly. The reload mechanism depends on your runtime. PHP needs opcache configured to check timestamps in dev — otherwise you’re restarting containers to see a semicolon change.
Networking: Services Find Each Other by Name
version: '3.8'
services:
web:
networks:
- frontend
- backend
api:
networks:
- backend
db:
networks:
- backend
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # No external access
# In your application
import os
db_host = os.getenv('DB_HOST', 'db') # 'db' is service name
db_port = 5432
# Docker Compose resolves 'db' to the db service IP
Use service names as hostnames. DB_HOST=db, not DB_HOST=localhost. localhost inside a container is the container itself, not your machine, not the database. This confuses everyone exactly once.
Environment Variables: .env Files Save Marriages
# .env
DB_PASSWORD=secret
REDIS_PASSWORD=redis_secret
APP_ENV=local
services:
web:
env_file:
- .env
environment:
- DB_HOST=db
- DB_PASSWORD=${DB_PASSWORD}
services:
web:
env_file:
- .env.${ENV:-development}
Commit .env.example. Gitignore .env. Every new hire copies one file instead of interrogating Slack for secrets.
Health Checks: Don’t Start Web Before DB Is Ready
depends_on waits for the container to start, not for the service to be ready. Postgres takes a few seconds to accept connections. Your app crashes immediately. You blame Docker. Docker is innocent.
services:
web:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
db:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
redis:
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
services:
web:
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
condition: service_healthy is the difference between “web container restarted 47 times” and “web container started once, successfully.”
Volumes: Persistence vs. Live Editing
Named volumes persist data across docker-compose down. Bind mounts sync source code. Both matter:
services:
web:
volumes:
- ./src:/app/src # Sync source code
- ./config:/app/config
- /app/node_modules # Exclude node_modules
volumes:
postgres_data:
driver: local
redis_data:
driver: local
docker-compose down -v wipes named volumes. Document that prominently before someone loses a week of local test data.
Commands You’ll Actually Use
# Start services
docker-compose up -d
# View logs
docker-compose logs -f web
docker-compose logs -f --tail=100
# Execute commands
docker-compose exec web php artisan migrate
docker-compose exec db psql -U postgres -d myapp
# Scale services
docker-compose up -d --scale worker=5
# Rebuild after Dockerfile changes
docker-compose up -d --build
# Stop and remove volumes
docker-compose down -v
# View service status
docker-compose ps
# Execute one-off commands
docker-compose run --rm web php artisan tinker
docker-compose exec runs in a running container. docker-compose run creates a new one. Use run --rm for one-off tasks that shouldn’t stick around.
Team Workflow: From Clone to Shipping
Onboarding Script
#!/bin/bash
# setup.sh
echo "Setting up development environment..."
# Start services
docker-compose up -d
# Wait for database
echo "Waiting for database..."
sleep 10
# Run migrations
docker-compose exec web php artisan migrate
# Seed database
docker-compose exec web php artisan db:seed
# Install frontend dependencies
docker-compose exec frontend npm install
echo "Setup complete! Visit http://localhost"
The sleep 10 is crude. Health checks are better. We kept the sleep in onboarding scripts because it’s obvious and works; health checks are for the compose file itself.
Daily Rhythm
# Start everything
docker-compose up -d
# Work on code (hot reload enabled)
# Run tests
docker-compose exec web php artisan test
# Check logs
docker-compose logs -f
# Stop everything
docker-compose down
When Things Break (They Will)
# All services
docker-compose logs
# Specific service
docker-compose logs web
# Follow logs
docker-compose logs -f web db
# Shell into container
docker-compose exec web bash
# Run commands
docker-compose exec web php artisan migrate
# Nuclear option
docker-compose down -v
docker-compose down --rmi all
docker-compose up -d --build
The nuclear option fixes 80% of “something weird is happening” reports. It’s slow, but so is debugging phantom state from a container that was built three months ago.
What We Learned
Use .env files and never commit secrets. Split dev and prod with override files, not duplicate stacks. Health checks with service_healthy dependencies prevent startup race conditions. Anonymous volumes for node_modules prevent bind mount disasters. Named volumes for database persistence. Profiles for migrations and seeds.
Keep images reasonably current — that postgres:9.6 made sense in 2016; update deliberately, not never. Document the two commands every new hire needs. Put setup.sh in the repo.
Docker Compose doesn’t replace production orchestration. It replaces the afternoon lost to “can you help me configure Postgres.” That’s worth more than it sounds.
Start with web + db + redis. Add nginx, workers, and frontend when the app needs them. The patterns here scaled from solo side projects to teams of twenty — because the compose file is version-controlled truth, not tribal knowledge in someone’s .bashrc.
Docker Compose best practices from October 2016, using Compose file format 3.8. Compose V2 (docker compose without hyphen) is now standard; concepts and override patterns are unchanged.