Designing Docker Compose for Local Development Environments the Right Way
AI generated
Docker Compose · Development Environment · DevEx · Team
Designing Docker Compose for Local Development Environments
the right way, from day one

A local development environment built with Docker Compose is more than a running container. Get bind mounts wrong and you lose performance. Hardcode secrets in the compose file and you put the whole project at risk. Skip an onboarding concept and you lose hours with every new team member. This guide shows how to get it right from day one.

18 min read Bind Mounts · Hot Reload · Secrets · Overrides · Onboarding Docker Compose v2 · macOS · Linux · Windows (WSL2)

1. The Right Philosophy for Dev Environments

A local development environment built with Docker Compose serves a different goal than a production environment: it should give developers a fast feedback loop, provide every required service at the push of a button, and create as little friction as possible along the way. That means code changes should be visible immediately, startup should be reproducible and fast, and the configuration should be simple enough to understand without a lengthy onboarding.

The fundamental difference between a good and a bad local development environment is often not technical complexity but the design decisions made during the initial setup. An environment that "just grew" on the first developer's machine tends to carry historical baggage: hardcoded paths, missing healthchecks, secrets in plain text. Designing a development environment from scratch with Docker Compose is an investment that pays off over the entire lifetime of a project: through faster onboarding, fewer "works on my machine" situations, and a foundation that can be reused for CI test environments as well.

Three principles should guide the architecture of every local development environment. First, the entire environment must be startable with a single command. Second, no secret, no password and no API key may ever live directly in a versioned file. Third, the difference between development and production configuration must be explicit, not implicit, expressed through separate files rather than commented-out blocks.

2. Bind Mounts: Performance and Consistency

Bind mounts are the core of every local development environment built with Docker Compose: they mount the local source code into the container so that code changes are visible inside the container immediately. On Linux, bind mount performance is nearly identical to native filesystem access. On macOS and Windows (without WSL2), however, bind mounts have historically been a performance bottleneck because the filesystem communicates through a virtualization layer.

Docker Desktop for macOS has offered the VirtioFS implementation since version 4.6, which improves bind mount performance considerably. The correct configuration in the Docker Compose file for macOS optimization: consistency: cached is deprecated on modern Docker versions and no longer necessary, since VirtioFS handles the optimization automatically. What still helps is selective mounting instead of mounting the entire project directory. node_modules, vendor and other large dependency directories should be overridden with an anonymous volume that lives inside the container itself and is never synced to the host.


# docker-compose.yml: base configuration for the local development environment
# Optimized bind-mounts: override large dependency directories with anonymous volumes

services:
  app:
    build:
      context: .
      target: development         # Multi-stage: separate dev/prod image layers
    volumes:
      # Bind-mount: source code syncs bidirectionally with host
      - ./src:/var/www/html:rw
      # Anonymous volume: node_modules lives inside container only
      # Prevents slow cross-filesystem sync on macOS/Windows
      - /var/www/html/node_modules
      # Named volume: Composer vendor stays in container (fast installs)
      - vendor:/var/www/html/vendor
    environment:
      APP_ENV: development
      PHP_IDE_CONFIG: "serverName=docker"
    ports:
      - "8080:8080"               # HTTP
      - "9003:9003"               # Xdebug port
    networks:
      - dev-network

  db:
    image: mysql:8.4
    volumes:
      # Named volume: persistent between restarts, removed with docker compose down -v
      - db-data:/var/lib/mysql
      - ./docker/mysql/conf.d:/etc/mysql/conf.d:ro
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MYSQL_DATABASE: ${DB_NAME}
      MYSQL_USER: ${DB_USER}
      MYSQL_PASSWORD: ${DB_PASSWORD}
    ports:
      - "${DB_PORT:-3306}:3306"   # Configurable host port via .env
    networks:
      - dev-network

volumes:
  db-data:
  vendor:

networks:
  dev-network:
    driver: bridge

3. Hot Reload for Backend and Frontend

Hot reload is the most important trait of a productive local development environment. For frontend assets built with Webpack, Vite or Parcel, hot reload means that changes to CSS and JavaScript files are visible in the browser immediately, without a manual build step. The corresponding dev server runs as its own service in Docker Compose and exposes its port on the host.

For PHP backends, hot reload works differently: since PHP scripts are recompiled on every request, code changes take effect immediately with an active bind mount, provided the PHP opcode cache is disabled or configured to invalidate instantly. In the local development environment, OPcache should be configured with opcache.validate_timestamps=1 and opcache.revalidate_freq=0 so file changes are detected right away. For Node.js backends with Nodemon or Go backends with Air, the watcher process runs directly inside the container and reacts to file changes in the mounted source directory.

4. Managing Secrets and Environment Variables Securely

The most common security problem in local development environments is embedding passwords and API keys directly into docker-compose.yml. This file is typically checked into the repository, which means credentials become visible to anyone with repository access. The correct pattern is a strict separation: the compose file contains only variable references (${DB_PASSWORD}), while the actual values live in a .env file that is listed in .gitignore.

Docker Compose automatically reads a .env file in the same directory as the compose file and substitutes the variable references. For the team, you provide a .env.example file that contains every required variable with placeholder values and is checked into the repository. The onboarding process then includes the step: cp .env.example .env, followed by filling in the real values. Docker Compose secrets (secrets: in the compose file) are primarily designed for production use with Docker Swarm, but can also be used in the local development environment for especially sensitive credentials such as private keys or certificates.


# .env.example: checked into git, contains placeholder values only
# Copy to .env and fill in real values. .env is in .gitignore

APP_ENV=development
APP_KEY=base64:CHANGE_ME_generate_with_php_artisan_key_generate

DB_HOST=db
DB_PORT=3306
DB_NAME=myapp
DB_USER=myapp
DB_PASSWORD=CHANGE_ME_local_password_only
DB_ROOT_PASSWORD=CHANGE_ME_root_password_only

REDIS_HOST=redis
REDIS_PORT=6379

MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_FROM=dev@localhost

# External API keys, obtained from the team password manager
STRIPE_KEY=sk_test_CHANGE_ME
STRIPE_SECRET=CHANGE_ME

# Xdebug configuration
XDEBUG_MODE=debug
XDEBUG_CLIENT_HOST=host.docker.internal
XDEBUG_CLIENT_PORT=9003

5. Override Files: Separating Dev From Prod

The override system of Docker Compose enables a clean separation between the base configuration and environment-specific adjustments. The base file docker-compose.yml defines the service structure, images and fundamental configuration. A docker-compose.override.yml is automatically read by Docker Compose and overrides or extends the base configuration. This file contains development-specific settings: bind mounts for the source code, Xdebug configuration, debug logging and local port forwarding.

Since docker-compose.override.yml should also be checked into the repository (it contains no secrets, only structural configuration for the local development environment), it is the right place for everything that applies to local work but not to production. A separate docker-compose.prod.yml contains production-specific configuration such as health probes for Kubernetes, resource limits and read-only filesystems. Explicitly naming both files in the deploy command (docker compose -f docker-compose.yml -f docker-compose.prod.yml up) makes the composition transparent and easy to follow.

6. Making Local DNS and Network Access Convenient

Accessing services in the local development environment through meaningful hostnames instead of localhost:8080 considerably improves the developer experience. Tools like Traefik, running as a reverse proxy container in Docker Compose, make it possible to reach services through local hostnames such as app.local.mironsoft.de. Traefik reads labels from the service definitions and automatically generates routing rules from them.

For local HTTPS certificates, mkcert is the established standard: it creates self-signed certificates that browsers accept as trustworthy because mkcert installs a local CA into the browser's certificate store. The resulting certificate is mounted as a volume into the Traefik container. The result is a local development environment that behaves exactly like the production environment, complete with HTTPS, correct hostnames and no browser warnings, while all traffic stays local.


# docker-compose.override.yml: development-only additions
# Automatically merged by Docker Compose with docker-compose.yml

services:
  app:
    volumes:
      # Bind-mount for live code sync
      - ./src:/var/www/html:rw
      - /var/www/html/vendor          # Keep vendor inside container
    environment:
      XDEBUG_MODE: ${XDEBUG_MODE:-debug}
      XDEBUG_CLIENT_HOST: host.docker.internal
      PHP_OPCACHE_VALIDATE_TIMESTAMPS: "1"
      PHP_OPCACHE_REVALIDATE_FREQ: "0"
    labels:
      # Traefik routing labels for local DNS
      - "traefik.enable=true"
      - "traefik.http.routers.app.rule=Host(`app.localhost`)"
      - "traefik.http.routers.app.tls=true"
      - "traefik.http.services.app.loadbalancer.server.port=8080"

  # Local mail catcher: captures all outgoing mail
  mailpit:
    image: axllent/mailpit:latest
    ports:
      - "8025:8025"                   # Web UI for viewing captured emails
    networks:
      - dev-network

  # Traefik reverse proxy with local HTTPS
  traefik:
    image: traefik:v3.1
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --entrypoints.web.address=:80
      - --entrypoints.websecure.address=:443
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./docker/traefik/certs:/certs:ro
    networks:
      - dev-network

7. Handling Database State Sensibly in Development

The database strategy in the local development environment has to balance two opposing requirements: persistence for daily work (developers do not want an empty database on every restart) and the ability to reset to a defined starting state whenever needed. Named volumes in Docker Compose provide persistence between container restarts, but are removed entirely with docker compose down -v.

The recommended pattern for the local development environment: a collection of seed scripts that generate realistic (but anonymized) test data and can be invoked through a Makefile target or an npm script. Regular database dumps from the staging environment, distributed through a shared volume or an internal repository, keep the local database current with realistic data. For migration work, a separate compose override is useful, one that starts the database at a known schema state on top of which new migrations can be developed.

8. One Command Onboarding for New Team Members

The goal of a good Docker Compose setup for local development is that a new team member has a complete, working development environment after cloning the repository with a single command. In practice, that means having a setup script or Makefile target that copies .env.example to .env (if it does not exist yet), builds all images, starts all services, runs database migrations and loads a seed database.

The local development environment documents itself best through the code: a docker-compose.yml with meaningful service names, commented port forwardings and a .env.example with explanatory comments for every variable beats an outdated README any day. A Makefile as a central entry point for common operations (make up, make down, make reset-db, make shell) lowers the barrier to entry and hides complex Docker Compose commands behind simple, self-documenting targets.


# Makefile: single entry point for all common development operations
# Usage: make up | make down | make shell | make reset-db | make logs

.PHONY: up down shell logs reset-db test setup

# First-time setup: copies .env, builds images, starts services, runs migrations
setup:
	@[ -f .env ] || cp .env.example .env
	@echo "Please fill in .env values if this is your first run."
	@docker compose build --parallel
	$(MAKE) up
	@docker compose exec app php bin/console doctrine:migrations:migrate --no-interaction
	@docker compose exec app php bin/console doctrine:fixtures:load --no-interaction
	@echo "Setup complete. App available at https://app.localhost"

# Start all services in background
up:
	docker compose up --detach --remove-orphans

# Stop all services (keep volumes)
down:
	docker compose down

# Open shell in app container
shell:
	docker compose exec app bash

# Follow all service logs
logs:
	docker compose logs --follow --tail=100

# Drop and recreate database with fresh seed data
reset-db:
	docker compose exec app php bin/console doctrine:database:drop --force
	docker compose exec app php bin/console doctrine:database:create
	docker compose exec app php bin/console doctrine:migrations:migrate --no-interaction
	docker compose exec app php bin/console doctrine:fixtures:load --no-interaction
	@echo "Database reset complete."

# Run test suite
test:
	docker compose exec app php vendor/bin/phpunit

9. Dev Environment Comparison: Patterns and Anti Patterns

The most common mistakes when designing local development environments with Docker Compose are well documented and repeat themselves across many projects. Comparing anti patterns with recommended patterns shows where the biggest levers for improvement lie.

Area Anti Pattern Recommended Pattern Why
Secrets Passwords in docker-compose.yml .env + .env.example Credentials never in VCS
Vendor mount vendor/ as a bind mount Anonymous volume overrides the bind Massively faster PHP performance
Onboarding 10-page README make setup automates everything Reproducible, error resistant
Dev vs. prod One compose file for everything Base + override.yml Explicit separation of contexts
Mail in dev Real SMTP or no mail at all Mailpit container Catch all mail locally

The biggest performance win for local development environments on macOS regularly comes from a single change: overriding the bind mount for vendor/ and node_modules/ with anonymous volumes. These directories contain thousands of small files, exactly the kind of workload where bind mounts on macOS are slow. After making this change, teams typically report a page load time improvement in the development environment of a factor of 3 to 10.

Mironsoft

Docker Compose, developer experience and local development environments

A local development environment the team actually uses?

We design Docker Compose development environments that start in seconds, have performant bind mounts, manage secrets securely and let new developers get started right away.

Dev Environment Setup

Docker Compose with bind mount optimization, hot reload and local HTTPS

Secrets Management

.env strategy, .env.example and secure credential distribution across the team

Onboarding Automation

make setup as a one command entry point with migration, seed and healthcheck verification

10. Summary

A professionally designed local development environment with Docker Compose is not a question of effort, but of making the right design decisions. Bind mounts for source code, anonymous volumes for dependency directories, .env files for secrets and override files for environment-specific configuration form the solid foundation. Hot reload for frontend and backend, local HTTPS with Traefik and mkcert, and a mail catcher like Mailpit round out a developer experience that matches production without imitating it.

The decisive quality measure for every local development environment is simple: how long does onboarding a new team member take? If the answer is "a day", there is room for improvement. With the right Docker Compose patterns, make setup, full automation and self-documenting configuration, the goal should be a single command and a few minutes. That is not an exaggeration but a realistic expectation for modern development infrastructure.

Local Development Environment With Docker Compose: The Essentials at a Glance

Bind Mount Performance

Override vendor/ and node_modules/ with anonymous volumes. No bind mount for large dependency directories, especially critical on macOS.

Secrets Strategy

.env in .gitignore, .env.example in the repository. No passwords in docker-compose.yml or docker-compose.override.yml.

Override System

docker-compose.override.yml for dev-specific configuration. Merged automatically. Explicit separation of base and environment configuration.

One Command Onboarding

make setup automates: .env copy, image build, startup, migration, seed. Goal: a new team member productive in under 10 minutes.

11. FAQ: Docker Compose for Local Development Environments

1Why is Docker Compose slower on macOS?
Bind mounts run through a virtualization layer. VirtioFS improves this considerably. Anonymous volumes for vendor/ and node_modules/ work around the problem.
2Should docker-compose.override.yml go in the repository?
Yes, as long as it holds no secrets. Bind mounts, debug config, local ports: all shared by the team. Put dev-specific adjustments in docker-compose.local.yml (gitignored).
3How do I keep passwords out of Git?
.env in .gitignore. Only ${VARIABLE} references in compose files. Provide .env.example with placeholder values. Add a pre-commit hook.
4How does hot reload work for PHP?
PHP reloads scripts on every request. Configure OPcache with validate_timestamps=1 and revalidate_freq=0, otherwise changes go unnoticed.
5How do I set up local HTTPS?
mkcert creates browser-trusted certificates for *.localhost. Traefik in Docker Compose terminates TLS. All internal services stay without TLS.
6How do I manage different DB states?
Named volume for persistence. make reset-db for reinitialization. docker compose down -v for a full reset including all volumes.
7Multiple projects at once in Docker Compose?
Different COMPOSE_PROJECT_NAME values and host ports. Traefik as a shared reverse proxy with local DNS eliminates port conflicts entirely.
8Integrating Xdebug into Docker Compose?
Expose port 9003. XDEBUG_CLIENT_HOST=host.docker.internal. Set the IDE to listen mode. Make XDEBUG_MODE configurable via .env.
9up --build vs. docker compose build?
build builds without starting. up --build builds and starts. For daily work skip --build; rebuild only after Dockerfile changes.
10Debugging network problems in Docker Compose?
docker compose exec service ping other-service checks DNS. docker network inspect shows all connected containers and IPs.