Developer Tools

Docker Compose for Local Development: The Multi-Service Setup That Finally Stuck

A real docker-compose.yml running app, database, cache, and mail-catcher together, plus the two mistakes — a mounted node_modules and a missing healthcheck — that made the first two attempts painful enough to abandon.

By Aissam Ait Ahmed Developer Tools 0 comments

I'd tried Docker Compose for local development twice before and abandoned it both times — not because the idea was wrong, but because both earlier setups were slow and fragile enough that dropping back to a bare-metal install felt like relief rather than a step backward. The third attempt is the one that actually stuck, and it stuck specifically because of two fixes for mistakes the first two setups both made without me noticing why they were the problem.

The working docker-compose.yml

services:
  app:
    build:
      context: .
      dockerfile: docker/php.Dockerfile
    volumes:
      - .:/var/www/html
      - /var/www/html/node_modules   # see "mistake one" below
    ports:
      - "8000:8000"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    environment:
      - DB_HOST=db
      - REDIS_HOST=redis

  db:
    image: mysql:8.0
    environment:
      - MYSQL_DATABASE=app
      - MYSQL_ROOT_PASSWORD=${DB_PASSWORD}
    volumes:
      - db-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      timeout: 3s
      retries: 10

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

  mailhog:
    image: mailhog/mailhog
    ports:
      - "8025:8025"

volumes:
  db-data:

Four services — the app itself, MySQL, Redis, and Mailhog for catching outgoing mail locally instead of accidentally emailing real addresses during testing — wired together with depends_on conditions and healthchecks. Nothing exotic. The two things that made this version actually usable day to day are both small, and both were missing from my first two attempts.

Mistake one: bind-mounting the whole project, node_modules included

The first attempt mounted the entire project directory into the container with a single volume line and nothing else — which sounds like the obviously correct thing to do, since you want your local edits to show up inside the container immediately. What that setup didn't account for: node_modules, mounted from the host, meant every npm install and every file-watcher process was reading and writing through the host's filesystem bind-mount layer, which on macOS specifically has real, noticeable I/O overhead compared to a native filesystem. A dev server restart that took two seconds on bare metal took twelve to fifteen seconds through the mount, and every npm install felt like it had aged several years.

# The line that fixed it — an anonymous volume specifically for
# node_modules, which keeps it inside the container's own
# filesystem instead of round-tripping through the host mount
volumes:
  - .:/var/www/html
  - /var/www/html/node_modules

That second volume line creates a separate, container-native volume specifically at the node_modules path, which shadows whatever's there from the host bind-mount and keeps dependency files entirely inside the container's own fast filesystem. The project's actual source files still sync instantly through the main bind mount; only the huge, rarely-hand-edited node_modules directory gets excluded from the slow path. This one line was the single biggest speed difference between the abandoned first attempt and this one.

Mistake two: no healthchecks, just a hopeful depends_on

The second attempt had a plain depends_on: [db, redis] with no healthcheck at all, which only guarantees the containers start in the right order — not that MySQL is actually ready to accept connections by the time the app container tries to connect to it. On a fast machine this usually worked by luck. On a slower machine, or the first cold start after pruning Docker's cache, the app container would boot, immediately try to run migrations, and fail with a connection-refused error because MySQL was still initializing.

# Without a healthcheck: depends_on only orders startup, not readiness
depends_on:
  - db
  - redis

# With a healthcheck: depends_on actually waits for readiness
depends_on:
  db:
    condition: service_healthy
  redis:
    condition: service_healthy

The healthcheck blocks aren't decoration — condition: service_healthy is what actually makes depends_on wait for the database to report itself ready, not just started. Adding these two blocks turned "sometimes fails on a cold start, works fine if you just retry" into "reliably works every time," which sounds like a small distinction until you're the one explaining to a new team member why their very first docker compose up failed for no obvious reason.

A real problem this setup made trivial: testing outgoing email

Mailhog catches every outgoing email the app sends and shows it in a web UI at localhost:8025 instead of actually sending it anywhere — genuinely useful for testing a password-reset flow or a notification email without accidentally spamming a real inbox, or worse, someone else's. Wiring the app's mail config to point at the mailhog service by its container name (Docker Compose's built-in DNS resolves service names automatically) meant every environment on the team catches test emails the same way, with zero per-developer SMTP configuration.

MAIL_HOST=mailhog
MAIL_PORT=1025
MAIL_ENCRYPTION=null

Generating a real local DB password instead of a memorable one

The ${DB_PASSWORD} environment variable in the compose file pulls from a local .env file, gitignored as usual, and rather than typing "something memorable" for a value nobody actually needs to remember, a real generated password from a password generator takes the same amount of effort and removes an entirely avoidable category of "wait, is this the same password I used on the last project" confusion when multiple local projects' .env files accumulate over time.

Seeding a fresh database automatically on first boot

A new teammate's first docker compose up shouldn't require a separate manual step to load seed data before the app is actually usable. MySQL's official image runs any .sql or .sh file placed in a specific mounted directory automatically, but only the very first time the data volume is created — a detail that's easy to miss and then wonder why a seed script "isn't working" on a database that already has data in it from a previous run.

db:
  image: mysql:8.0
  environment:
    - MYSQL_DATABASE=app
    - MYSQL_ROOT_PASSWORD=${DB_PASSWORD}
  volumes:
    - db-data:/var/lib/mysql
    - ./docker/seed.sql:/docker-entrypoint-initdb.d/seed.sql:ro

That extra volume line mounts a local seed file into MySQL's well-known initialization directory, and the official image runs everything found there automatically on first startup, in filename order — genuinely useful for getting a new clone from "just cloned the repo" to "has real-looking data to develop against" with zero manual database steps. The gotcha, worth stating explicitly: this only fires on a brand-new, empty data volume. Running docker compose down -v (the -v flag specifically, which also deletes named volumes) before a fresh up is what actually triggers re-seeding, not a plain restart.

.dockerignore matters as much as the volume mounts

The first attempt's build step was noticeably slower than expected, and the cause turned out to be the exact same category of mistake as the missing node_modules exclusion above, just at build time instead of runtime: without a .dockerignore file, every docker compose build was sending the entire project directory — including node_modules, .git, and local log files — as build context to the Docker daemon before the build even started, regardless of whether any of that was actually needed inside the image.

# .dockerignore
node_modules
.git
storage/logs
.env
vendor

Excluding these cut build-context transfer time noticeably on a project that had accumulated a few hundred megabytes of logs and dependency directories over time — a small file, easy to forget entirely, with an outsized effect on how painful docker compose build feels day to day once a project has accumulated enough incidental cruft over time to actually start mattering to real, measured build performance. It's worth treating .dockerignore as a file to actively maintain alongside .gitignore rather than writing once and forgetting, since a project's local artifacts tend to accumulate new categories worth excluding as it grows, and a stale one just quietly stops paying for itself without anyone noticing.

What this setup still doesn't solve

  • First-time image builds are still slow. Docker Compose doesn't make an initial docker compose build fast — it just makes every startup after that one fast, which is a fair trade for most day-to-day work but not for someone spinning up a fresh clone for the first time.
  • Apple Silicon and platform-specific images occasionally mismatch. A base image without an ARM build can silently run under emulation, which is functionally correct but noticeably slower — worth explicitly checking an image's supported platforms before assuming a slow container is a config problem rather than an architecture one.
  • This doesn't replace a version manager for tools you use outside any one project. Docker Compose handles this project's exact runtime and its services; a version manager still earns its place for CLI tools and languages used across multiple projects, the same distinction covered in more depth in setting up a productive local dev environment from a blank laptop.
  • Debugging inside the container needs its own setup. Step debugging with Xdebug, covered separately in a real Xdebug session, requires the container's Xdebug config to point at the host machine rather than localhost, since "localhost" inside a container refers to the container itself, not your machine — a detail that trips up almost everyone the first time they try to debug containerized PHP.

None of these gaps were dealbreakers, and none of them are why the first two attempts got abandoned — the node_modules mount and the missing healthchecks were. It's worth being honest about what a setup still doesn't solve rather than presenting it as a finished, complete answer, since the next person adopting it will hit these edges regardless of whether this post mentioned them first, and it's better they hit a known, documented edge than assume the whole setup is broken because of something this post could have flagged in advance.

Comments

Join the conversation on this article.

Comments are rendered server-side so the discussion stays visible to readers without relying on a separate widget or client-side app.

No comments yet.

Be the first visitor to add a thoughtful comment on this article.

Leave a comment

Share a useful thought, question, or response.

Be constructive, stay on topic, and avoid posting personal or sensitive information.

Back to Blog More in Developer Tools Free Resources Explore Tools