Docker compose for local development setup: A practical guide

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 databasesdb-data persists your database between container restarts. Without this, every docker compose down wipes your data.
  • Healthchecks with conditions — the depends_on with condition: service_healthy means 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.