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.
Table of Contents
- 1. The Right Philosophy for Dev Environments
- 2. Bind Mounts: Performance and Consistency
- 3. Hot Reload for Backend and Frontend
- 4. Managing Secrets and Environment Variables Securely
- 5. Override Files: Separating Dev From Prod
- 6. Making Local DNS and Network Access Convenient
- 7. Handling Database State Sensibly in Development
- 8. One Command Onboarding for New Team Members
- 9. Dev Environment Comparison: Patterns and Anti Patterns
- 10. Summary
- 11. FAQ
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?
2Should docker-compose.override.yml go in the repository?
3How do I keep passwords out of Git?
4How does hot reload work for PHP?
validate_timestamps=1 and revalidate_freq=0, otherwise changes go unnoticed.5How do I set up local HTTPS?
6How do I manage different DB states?
make reset-db for reinitialization. docker compose down -v for a full reset including all volumes.7Multiple projects at once in Docker Compose?
8Integrating Xdebug into Docker Compose?
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.