Use docker compose for Local Dev Stacks
Developing modern applications often involves a complex interplay of services: databases, caching layers, message queues, and custom APIs. Replicating…
Developing modern applications often involves a complex interplay of services: databases, caching layers, message queues, and custom APIs. Replicating this multi-service environment consistently across developer workstations can be challenging. Docker Compose provides a powerful, declarative way to define and run multi-container Docker applications, making it an indispensable tool for local development stacks. This article details how to effectively leverage Docker Compose for robust, reproducible, and efficient local development.
Structuring Your compose.yaml for Local Development
The core of your Docker Compose setup is the compose.yaml file (or docker-compose.yml for older versions). This file defines the services, networks, and volumes that make up your application stack. For local development, prioritize readability and explicit configuration.
Defining Services
Each service in your stack gets its own entry. Best practice for local development often involves binding container ports to host ports, mounting local source code, and setting environment variables specific to development.
version: '3.8' # Always specify a version for compatibility and features
services:
webapp:
build:
context: ./app # Path to your application's Dockerfile
dockerfile: Dockerfile.dev # Use a specific Dockerfile for development if needed
ports:
- "8000:8000" # Map container port 8000 to host port 8000
volumes:
- ./app:/app # Mount local source code into the container
- webapp_node_modules:/app/node_modules # Example: use a named volume for dependencies
environment:
NODE_ENV: development
DATABASE_URL: postgres://user:password@db:5432/myapp_dev
depends_on:
db:
condition: service_healthy # Ensure DB is healthy before starting webapp
healthcheck: # Define a health check for the webapp itself
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 20s # Give the app time to start up
db:
image: postgres:14-alpine # Use a specific, stable version
ports:
- "5432:5432"
volumes:
- db_data:/var/lib/postgresql/data # Persist database data
environment:
POSTGRES_DB: myapp_dev
POSTGRES_USER: user
POSTGRES_PASSWORD: password
healthcheck: # Critical for `depends_on: service_healthy`
test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 5
volumes:
db_data:
webapp_node_modules: # Define named volumes
buildvs.image: For local development,buildis common to incorporate recent code changes. For external dependencies (like databases),imageis preferred for specific versions.- Port Mapping:
"HOST_PORT:CONTAINER_PORT"allows access to services from your host machine. - Volume Mounting:
./app:/appmounts your local application directory directly into the container, enabling live code changes without rebuilding the image. Named volumes (e.g.,db_data,webapp_node_modules) are crucial for persistent data and avoiding re-downloading dependencies on every container start. - Environment Variables: Use these to pass configuration like database credentials or API keys. Avoid hardcoding sensitive information; use
.envfiles or environment variables on the host for production, but for local, explicit declarations are fine. depends_on: Specifies dependencies between services. Crucially, thecondition: service_healthyensures that a service is fully functional (not just started) before its dependents attempt to connect. This prevents "connection refused" errors during startup.healthcheck: Defines a command to check if a container is still working. Essential fordepends_on: service_healthyand for identifying failing services quickly.
Overriding Configuration with compose.override.yaml
Each developer's local setup might have unique requirements (e.g., different host ports, specific resource limits, or local-only tools). Rather than modifying the main compose.yaml, which should be committed to version control, use a compose.override.yaml file. This file is automatically merged by Docker Compose and should be gitignored.
# compose.override.yaml (ignored by Git)
version: '3.8'
services:
webapp:
ports:
- "8080:8000" # Developer A prefers port 8080
environment:
DEBUG_MODE: "true" # Enable debug mode locally
volumes:
- /Users/developer_a/my_custom_log_path:/app/logs # Mount a custom log path
db:
ports:
- "5433:5432" # Developer A prefers to run two Postgres instances
Docker Compose merges files in alphabetical order by default, so compose.override.yaml takes precedence over compose.yaml for conflicting keys. You can also specify multiple override files or a custom override file using the -f flag (e.g., docker compose -f compose.yaml -f compose.dev.yaml up).
Efficient Development with docker compose watch
A common pain point in containerized development is the need to rebuild and restart containers after every code change. docker compose watch (introduced in Docker Compose v2.17.0) provides a hot-reloading mechanism that streamlines this process significantly. Instead of rebuilding images, it monitors your local filesystem and performs predefined actions on changes.
Configuring watch in compose.yaml
Add a develop section to your service definition:
version: '3.8'
services:
webapp:
build:
context: ./app
dockerfile: Dockerfile.dev
ports:
- "8000:8000"
volumes:
- ./app:/app
- webapp_node_modules:/app/node_modules
environment:
NODE_ENV: development
DATABASE_URL: postgres://user:password@db:5432/myapp_dev
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 20s
develop: # New section for watch
watch:
- path: ./app # Watch the entire app directory
action: sync # Sync changes directly into the container
target: /app
- path: ./app/package.json # Watch specific files for different actions
action: rebuild # Rebuild if package.json changes (e.g., new dependencies)
- path: ./app/src/**/*.js # Or watch specific file patterns
action: sync
target: /app/src
db:
image: postgres:14-alpine
ports:
- "5432:5432"
volumes:
- db_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: myapp_dev
POSTGRES_USER: user
POSTGRES_PASSWORD: password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 5
volumes:
db_data:
webapp_node_modules:
watch Actions Explained
sync: The most common action for local development. When files in the specifiedpathchange on the host, they are automatically copied into the container at thetargetpath. This is significantly faster than rebuilding.rebuild: If a change in thepathnecessitates a full image rebuild (e.g., changes toDockerfileor dependency manifest files likepackage.jsonorrequirements.txt), this action triggers a rebuild and restart of the service.sync+restart: (Not shown above) Synchronizes files and then restarts the service. Useful for applications that don't have built-in hot-reloading but can benefit from a quick restart.
To start your development stack with watch enabled, simply run: docker compose watch. This will start all services and then monitor the specified paths for changes.
Practical Commands and Workflows
- Start services:
docker compose up -d(-dfor detached mode) - Start services with watch:
docker compose watch - Stop services:
docker compose down(removes containers and default networks) - Stop services and remove volumes:
docker compose down -v(useful for a clean slate) - Rebuild specific service:
docker compose build webapp - View logs:
docker compose logs -f webapp(-ffor follow mode) - Execute a command in a service:
docker compose exec webapp bash(opens a shell) - Run a one-off command:
docker compose run --rm webapp npm test(--rmremoves the container after exit)
Common Pitfalls and Troubleshooting
- Port Conflicts: If you get an error like
Error starting userland proxy: listen tcp 0.0.0.0:8000: bind: address already in use, another process on your host is using the specified port. Either stop the conflicting process or change the host port in yourcompose.yaml/compose.override.yaml. - Volume Permission Issues: Especially on Linux, the user inside the container might not have permissions to write to mounted host volumes. This can manifest as "permission denied" errors when the application tries to create files or directories. Solutions include:
- Setting the
userdirective incompose.yamlto match your host user's UID/GID. - Adjusting permissions on the host directory (e.g.,
sudo chown -R $USER:$USER ./app). - Using named volumes for directories requiring writes by the container user.
- Setting the
depends_onand Health Checks: Without properhealthcheckdefinitions,depends_ononly waits for the container to start, not for the application inside to be ready. This leads to "connection refused" errors from dependent services. Always define health checks for critical services like databases and backend APIs.- Caching Issues with
build: If your Dockerfile caches dependencies and you update yourpackage.jsonorrequirements.txtwithout triggering a rebuild, your service might run with outdated dependencies. Ensure yourdevelop.watchconfiguration or manual rebuilds address this. For example, explicitly trigger a rebuild when dependency files change. docker compose watchPerformance: Watching large directories with many files can sometimes consume significant CPU resources, especially on macOS. Be judicious with yourwatchpaths; specify only the directories that contain actively developed code.