We earn commissions when you shop through the links below.
If you’ve ever heard “works on my machine” one too many times, a Docker compose for local development setup is the fix you need. It gives every developer on your team an identical environment — same database version, same cache layer, same environment variables — spun up with a single command. No more “just install Postgres 15.2 and Redis and make sure your PHP version is right.” Just docker compose up and you’re running.
In this guide I’ll walk through how I structure Docker Compose for real projects, the pitfalls to avoid, and the patterns that actually hold up when your stack gets more complex.
Why Docker Compose beats manual local setup
Before Docker Compose became the standard, local dev setup meant a README with fifteen steps, half of which were out of date. Someone always had the wrong Node version, or their MySQL was 8.0 instead of 8.4, and debugging those differences burned hours every sprint.
Docker Compose solves this by defining your entire stack as code. The docker-compose.yml file lives in your repo, gets versioned, gets reviewed. When you add Redis to the stack, you add it once and everyone gets it on their next git pull.
It’s also a great stepping stone toward production container setups. If you’re planning to deploy to DigitalOcean App Platform or Kubernetes, your Compose file gives you a solid mental model of service dependencies before you get into orchestration complexity.
The anatomy of a docker-compose.yml
Here’s a realistic setup for a Laravel app with MySQL, Redis, and a queue worker. This is the kind of file I use day-to-day:
version: '3.9'
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: myapp
restart: unless-stopped
working_dir: /var/www
volumes:
- .:/var/www
environment:
- APP_ENV=local
- DB_HOST=db
- DB_PORT=3306
- DB_DATABASE=myapp
- DB_USERNAME=myapp
- DB_PASSWORD=secret
- REDIS_HOST=redis
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- myapp-network
webserver:
image: nginx:alpine
container_name: myapp-nginx
restart: unless-stopped
ports:
- "8080:80"
volumes:
- .:/var/www
- ./docker/nginx/conf.d:/etc/nginx/conf.d
depends_on:
- app
networks:
- myapp-network
db:
image: mysql:8.4
container_name: myapp-db
restart: unless-stopped
environment:
MYSQL_DATABASE: myapp
MYSQL_USER: myapp
MYSQL_PASSWORD: secret
MYSQL_ROOT_PASSWORD: rootsecret
volumes:
- db-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
networks:
- myapp-network
redis:
image: redis:7-alpine
container_name: myapp-redis
restart: unless-stopped
networks:
- myapp-network
queue:
build:
context: .
dockerfile: Dockerfile
container_name: myapp-queue
restart: unless-stopped
working_dir: /var/www
command: php artisan queue:work --sleep=3 --tries=3
volumes:
- .:/var/www
depends_on:
- db
- redis
networks:
- myapp-network
networks:
myapp-network:
driver: bridge
volumes:
db-data:
driver: local
A few things worth calling out in this file:
- Named volumes for databases —
db-datapersists your database between container restarts. Without this, everydocker compose downwipes your data. - Healthchecks with conditions — the
depends_onwithcondition: service_healthymeans your app won’t start until MySQL is actually ready to accept connections, not just started. This matters more than most tutorials acknowledge. - Separate worker service — reusing the same image for your queue worker keeps things DRY. One Dockerfile, two purposes.
- Named networks — services communicate by container name (
db,redis) within the network. Clean and predictable.
Using override files for team flexibility
One pattern I’ve found invaluable is splitting config with docker-compose.override.yml. Docker Compose automatically merges this file with your base file, so you can put developer-specific settings there without polluting the shared config.
# docker-compose.override.yml (gitignored)
services:
app:
environment:
- XDEBUG_MODE=debug
- XDEBUG_CLIENT_HOST=host.docker.internal
db:
ports:
- "3306:3306" # expose DB port locally for GUI tools
redis:
ports:
- "6379:6379" # expose Redis for local inspection
Add docker-compose.override.yml to your .gitignore. Each developer can expose ports, enable Xdebug, or mount additional volumes without creating noisy commits. Your docker-compose.yml stays clean and CI-friendly.
Environment variables the right way
Don’t hardcode secrets in your Compose file. Use a .env file at the project root — Docker Compose reads it automatically:
# .env (gitignored)
DB_PASSWORD=secret
MYSQL_ROOT_PASSWORD=rootsecret
APP_KEY=base64:your-key-here
# docker-compose.yml referencing .env variables
services:
db:
environment:
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
Commit a .env.example with placeholder values so new team members know what to fill in. This is a small discipline that pays off enormously as projects grow.
Common pitfalls and how to avoid them
Volume mount performance on macOS
If you’re on macOS and your bind-mounted app feels slow, you’re not imagining it. The default volume mount mode is slow. For most projects I use the cached option or switch to Docker’s VirtioFS backend (set in Docker Desktop settings). For serious performance, consider using a dev container with the source living inside the VM rather than bind-mounted from the host.
Not pinning image versions
Using image: mysql:latest is a trap. A teammate updates, pulls the latest image, and suddenly you’re debugging a MySQL version mismatch. Always pin: mysql:8.4, redis:7-alpine, node:22-alpine. Boring, but it saves you.
Forgetting to rebuild after Dockerfile changes
If you change your Dockerfile, docker compose up won’t automatically rebuild. Run docker compose up --build or docker compose build explicitly. I’ve seen this waste a lot of debugging time.
Useful commands you’ll use daily
# Start everything (detached)
docker compose up -d
# Start and rebuild images
docker compose up -d --build
# Stop containers (keep volumes)
docker compose stop
# Stop and remove containers (keep volumes)
docker compose down
# Stop and remove containers AND volumes (wipe DB)
docker compose down -v
# Run a one-off command in a service
docker compose exec app php artisan migrate
docker compose exec app bash
# View logs
docker compose logs -f app
docker compose logs -f db queue
# Check service status
docker compose ps
I keep these in a Makefile with short aliases like make up, make down, make shell. It reduces cognitive overhead when you’re switching between multiple projects.
Extending your Docker compose for local development setup
Once you have a solid Docker compose for local development setup, you can layer in extra services as your project grows: Mailpit for email testing, MinIO for S3-compatible object storage, Elasticsearch, or a separate testing database that gets wiped between test runs. Each addition is a new service block — no new installation steps for the team.
mailpit:
image: axllent/mailpit:latest
container_name: myapp-mailpit
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
networks:
- myapp-network
Point your app’s SMTP config at mailpit:1025 and you’ve got a local email inbox at localhost:8025. Zero external dependencies.
Learning more
If you want to go deeper into Docker and container-based workflows — including production patterns, multi-stage builds, and CI integration — there are some excellent Docker courses on Udemy that cover the full picture from basics to deployment. Worth it if your team is onboarding to containers for the first time.
Final thoughts
A well-crafted Docker compose for local development setup is one of the highest-leverage things you can do for a development team. It eliminates onboarding friction, kills environment-related bugs before they happen, and gives you a documented, executable description of your stack. The initial investment — an afternoon to get it right — pays back in hours saved every single month.
Start with the basics: app, database, cache. Get that working and committed. Then add services incrementally as you need them. Keep it boring and keep it pinned, and it’ll serve you well for years.