A Practical Guide to Docker Compose for Local Development
Docker Compose is one of those tools that almost every developer has used at some point, yet surprisingly few use well. Most teams I’ve worked with either over-engineer their Compose files or treat them as an afterthought. This guide covers what I’ve learned from running Docker Compose across dozens of projects over the past five years.
Why Compose for Local Dev?
The pitch is straightforward: define your entire application stack in a single YAML file and spin it up with one command. Database, cache, message queue, API server — all running in isolated containers that don’t pollute your host system.
The reality is more nuanced. Docker Compose shines when your application depends on services that are painful to install locally. Running PostgreSQL, Redis, and Elasticsearch on your Mac is doable but annoying to maintain. Compose makes those dependencies disposable.
A Real-World Compose File
Here’s a Compose configuration I use for a typical Node.js API project:
services:
api:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
environment:
DATABASE_URL: postgres://dev:dev@db:5432/appdb
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
environment:
POSTGRES_USER: dev
POSTGRES_PASSWORD: dev
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dev"]
interval: 5s
timeout: 5s
retries: 5
cache:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
pgdata:
A few things to note about this setup that make the difference between a Compose config that helps and one that frustrates.
The Volume Mount Trick
The line /app/node_modules as an anonymous volume is critical. Without it, your host’s node_modules directory would overwrite the container’s — and if you’re on macOS or Windows, that means Linux-compiled native modules get replaced with platform-incompatible ones.
This anonymous volume ensures the container keeps its own copy of node_modules while still syncing your source code in real time.
Health Checks Matter
The depends_on directive without a health check condition is almost useless. It only waits for the container to start, not for the service inside to be ready. I’ve lost count of how many “connection refused” errors I’ve debugged that were caused by an API trying to connect to a database that was still initialising.
Always add health checks to your database and cache services. The five extra lines save hours of confusion.
Development Dockerfiles
Don’t use your production Dockerfile for local development. Create a separate Dockerfile.dev that:
- Installs development dependencies
- Runs your dev server with hot reload
- Doesn’t optimise for image size
- Includes debugging tools
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]
The production Dockerfile should use multi-stage builds, run as a non-root user, and minimise layers. Your development Dockerfile should prioritise fast rebuilds and developer convenience.
Environment Variable Management
For local development, I keep environment variables directly in the Compose file. Yes, I know the convention is to use .env files, but for local dev, having everything visible in one place reduces cognitive load.
For any shared or staging environments, absolutely use .env files and keep them out of version control. But locally? Inline variables are fine.
Common Pitfalls
Bind mount performance on macOS. File syncing between your host and Docker containers on macOS has historically been slow. Docker Desktop has improved this with VirtioFS, but if you’re working with large node_modules or Python virtual environments, you’ll still notice latency. The solution is to keep those directories as named volumes rather than bind mounts.
Port conflicts. If you’re running multiple projects with Compose, you’ll inevitably hit port conflicts. Use different port mappings per project, or better yet, use a reverse proxy like Traefik that can route based on hostnames.
Stale images. Remember that docker compose up doesn’t rebuild images by default. If you’ve changed your Dockerfile, you need docker compose up --build. I alias this in my shell because I forget it roughly once a week.
When to Skip Compose
Not every project needs Docker Compose locally. If your application is a simple API that talks to a single SQLite database, Compose adds complexity without meaningful benefit. Use it when the dependency graph justifies the overhead.
Docker Compose isn’t glamorous, but a well-configured setup eliminates an entire category of “works on my machine” problems. Invest thirty minutes in getting it right, and your future self will be grateful.