Local Development #
Local development is Docker Compose’s most common and most valuable use case. With one docker-compose.yml file, every developer can run your complete application stack within minutes — without manually installing Postgres, Redis, or other services.
This article covers patterns and best practices for effective local development setups.
The Basic Pattern #
A docker-compose.yml for local development usually has these characteristics:
- Real production-like services (Postgres, Redis, etc.) — not mocks.
- Source code bind-mounted from host to container — for hot reload.
- Override files (optional) for development-specific configuration.
- Seed data for testing.
# docker-compose.yml
services:
api:
build:
context: ./api
target: development # multi-stage build
command: npm run dev
volumes:
- ./api/src:/app/src:ro # source code, read-only
- api-node-modules:/app/node_modules # avoid override
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- DATABASE_URL=postgres://app:pass@db:5432/myapp
- REDIS_URL=redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
web:
build: ./web
command: npm run dev
volumes:
- ./web/src:/app/src
ports:
- "5173:5173"
depends_on:
- api
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=dev
- POSTGRES_DB=myapp
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 10s
timeout: 5s
retries: 5
cache:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
volumes:
db-data:
api-node-modules:
Override Files for Development #
For a base file shared with production, separate it with an override file.
# docker-compose.yml (base — same for all environments)
services:
api:
build: ./api
environment:
- DATABASE_URL=postgres://app:pass@db:5432/myapp
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=prod
- POSTGRES_DB=myapp
# docker-compose.override.yml (auto-loaded for development)
services:
api:
command: npm run dev # override the production command
volumes:
- ./api/src:/app/src # hot reload
environment:
- NODE_ENV=development
ports:
- "3000:3000"
db:
environment:
- POSTGRES_PASSWORD=dev
ports:
- "5432:5432"
The override file is auto-loaded when docker compose up runs without the -f flag. For production, run without the override or with a different one.
Hot Reload #
Hot reload lets source code changes on the host appear in the container immediately, without rebuilds or restarts.
Node.js with nodemon:
# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npm", "run", "dev"]
// package.json
{
"scripts": {
"dev": "nodemon --watch src --ext js,json src/index.js"
},
"devDependencies": {
"nodemon": "^3.0.0"
}
}
# docker-compose.yml
services:
api:
build: ./api
command: npm run dev
volumes:
- ./api/src:/app/src # hot reload
- api-node-modules:/app/node_modules
When you edit a file in ./api/src, nodemon restarts automatically and the change appears.
Python with watchdog or uvicorn reload:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
services:
api:
build: ./api
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
volumes:
- ./api:/app
Go with Air or fresh:
FROM golang:1.22-alpine
WORKDIR /app
RUN go install github.com/cosmtrek/air@latest
COPY go.mod go.sum ./
RUN go mod download
COPY . .
CMD ["air"]
Vue / Vite:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npm", "run", "dev"]
Vite already has built-in HMR (Hot Module Replacement). A bind mount of the source code is enough.
Next.js / Nuxt:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npm", "run", "dev"]
Next.js Fast Refresh automatically reloads on changes.
The hot reload principle: bind-mount the source code, exclude dependencies from the bind mount (use anonymous/named volumes), and use a watcher tool inside the container (nodemon, air, uvicorn –reload).
Seed Data #
For development, you usually need seed data so your application has data to work with.
The init container pattern:
services:
app:
depends_on:
seed:
condition: service_completed_successfully
seed:
image: myapp:latest
volumes:
- ./seeds:/seeds:ro
command: python /seeds/load.py
restart: "no"
The seed container runs once, loads data, exits. The app starts afterward.
Alternative: SQL scripts in init:
services:
db:
image: postgres:16-alpine
volumes:
- ./db/init:/docker-entrypoint-initdb.d:ro
/docker-entrypoint-initdb.d is a special directory in the Postgres image. .sql scripts here run automatically when the database is first initialized. For existing volumes, you need to reset the volume to trigger re-initialization.
Fixtures inside the application:
For some stacks, it’s simpler to put seeds in the application code and trigger them via an endpoint or CLI.
Database Tools #
For development databases, extra tools are useful.
Adminer / pgAdmin:
services:
adminer:
image: adminer
ports:
- "8080:8080"
depends_on:
- db
Access the UI at http://localhost:8080 to query the database via the web. Lighter than pgAdmin.
RedisInsight:
services:
redis-insight:
image: redislabs/redisinsight
ports:
- "8001:8001"
MailHog (email testing):
services:
mailhog:
image: mailhog/mailhog
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
Access http://localhost:8025 to see emails your application “sent”.
File Watching #
By default, Docker sync between host and container on bind mounts uses a Virtual File System (varies by OS). There are some quirks:
Mac with Docker Desktop:
- Host file events aren’t always propagated to containers. Some tools (nodemon, webpack) don’t detect changes.
- Workaround: use
--pollingor file polling in the tool. - Docker Desktop 4.x+ is better but still can lag.
Linux:
- File events usually propagate directly. No polling needed.
Windows (WSL2 or Hyper-V):
- Filesystem performance can be slower. Consider file cache settings.
To work around file watching issues, some tools have polling options:
// nodemon
{
"watch": ["src"],
"ext": "js,json",
"legacyWatch: true" // use polling
}
Debugging #
When something goes wrong with the local stack, a few debugging tricks.
Access containers directly:
# Shell into a running container
docker compose exec api sh
# Run a one-off command
docker compose run --rm api python manage.py shell
View real-time logs:
# All services
docker compose logs -f
# A specific service
docker compose logs -f api
# With timestamps
docker compose logs -f -t api
Inspect state:
# Status of all services
docker compose ps
# Container details
docker inspect myapp-api-1
# Resource usage
docker stats
Restart services:
# Restart one service
docker compose restart api
# Rebuild the image
docker compose build api
# Force recreate
docker compose up -d --force-recreate api
Reset state:
# Remove all containers, networks (volume data stays)
docker compose down
# Remove everything including volumes
docker compose down --volumes
Network debugging from inside a container:
docker compose exec api sh
apk add curl # or apt-get
curl http://db:5432 # test the connection
nslookup db # test DNS
Multi-Repo Setups #
For monorepos or multi-repos, Compose can manage everything.
Monorepo:
services:
api:
build: ./services/api
web:
build: ./services/web
worker:
build: ./services/worker
db:
image: postgres:16-alpine
cache:
image: redis:7-alpine
All services in one Compose file, source code in the same subfolders.
Multi-repo:
For different repositories, use multiple Compose files.
# Repo 1: backend
cd ~/work/backend
docker compose up
# Repo 2: frontend
cd ~/work/frontend
docker compose up
Or unify with an external network:
# Create a shared network
docker network create shared
# Backend uses this network
# Frontend also uses this network
# They can resolve each other by hostname
Extra Development Tools #
Tilt / Skaffold (for Kubernetes-style development):
If you deploy to Kubernetes, consider tools that sync the Kubernetes workflow with local development.
Devbox (per-project package manager):
For managing language and tool versions per project, without Docker.
Act (run GitHub Actions locally):
Test CI workflows before pushing.
Best Practices #
Use Override Files for Development #
Separate the base config (for all envs) from development-specific config (hot reload, debug ports, etc.).
Add Development Tools, Don’t Remove Production Ones #
Compose for development should have more tools (debuggers, admin UIs) than production. Not fewer.
Always Bind-Mount Source Code #
For applications under development, source code must be bind-mounted so hot reload works.
Use Stable Image Tags #
# GOOD
image: postgres:16-alpine
# BAD
image: postgres:latest
latest can change without you noticing, causing confusing debugging.
Volumes for Dependencies #
volumes:
- api-node-modules:/app/node_modules
- web-node-modules:/app/node_modules
Don’t bind-mount node_modules — it’s large, platform-specific, and will be overwritten by the container.
Document in the README #
The README.md should have a quickstart:
## Local Development
1. Install Docker and Docker Compose
2. Clone the repo: `git clone ...`
3. Copy `.env.example` to `.env` and edit if needed
4. Run: `docker compose up`
5. Access:
- Web: http://localhost:5173
- API: http://localhost:3000
- Adminer: http://localhost:8080
6. Stop: `docker compose down`
New developers can be productive within minutes.
A Complete Example: A SaaS Local Development Setup #
Say you have a SaaS application with a frontend, API, worker, database, cache, and search engine.
services:
frontend:
build:
context: ./frontend
target: development
command: npm run dev
volumes:
- ./frontend/src:/app/src
- frontend-deps:/app/node_modules
ports:
- "3000:3000"
environment:
- VITE_API_URL=http://localhost:8080
depends_on:
- api
api:
build:
context: ./api
target: development
command: uvicorn main:app --reload --host 0.0.0.0 --port 8080
volumes:
- ./api:/app
ports:
- "8080:8080"
environment:
- DATABASE_URL=postgres://app:pass@db:5432/myapp
- REDIS_URL=redis://cache:6379
- ELASTICSEARCH_URL=http://search:9200
- JWT_SECRET=devsecret
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
search:
condition: service_healthy
worker:
build: ./worker
command: python -m worker
environment:
- DATABASE_URL=postgres://app:pass@db:5432/myapp
- REDIS_URL=redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=dev
- POSTGRES_DB=myapp
volumes:
- db-data:/var/lib/postgresql/data
- ./db/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
interval: 10s
timeout: 5s
retries: 5
ports:
- "5432:5432"
cache:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
ports:
- "6379:6379"
search:
image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- ES_JAVA_OPTS=-Xms512m -Xmx512m
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:9200 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
ports:
- "9200:9200"
adminer:
image: adminer
ports:
- "8081:8080"
depends_on:
- db
mailhog:
image: mailhog/mailhog
ports:
- "1025:1025"
- "8025:8025"
volumes:
db-data:
frontend-deps:
This stack has 8 services — frontend, API, worker, database, cache, search, plus adminer and mailhog for development. Each service has hot reload or development-appropriate configuration.
How to use it:
# Initial setup
cp .env.example .env
docker compose up -d
# Access:
# - Frontend: http://localhost:3000
# - API: http://localhost:8080/docs
# - Adminer: http://localhost:8081
# - MailHog UI: http://localhost:8025
# - Elasticsearch: http://localhost:9200
# View logs
docker compose logs -f
# Stop
docker compose down
Performance Tips #
Limit Resources During Development #
Even locally, set limits so your machine doesn’t slow down.
services:
api:
deploy:
resources:
limits:
memory: 1G
Use Smaller Images #
For development, avoid large images when not needed. E.g. postgres:16-alpine instead of postgres:16.
Pre-Pull Images #
# Download images up front, before starting development
docker compose pull
Saves time on the first up.
Cache Layers #
Build an optimal Dockerfile:
# dependencies first (rarely change)
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
# source code (often changes)
COPY . .
The npm ci layer is cached; source code changes don’t invalidate the cache.
Secret Management for Development #
For development, there are several secret management patterns.
Plain .env files (small teams):
DATABASE_PASSWORD=devsecret
JWT_SECRET=devjwt
The file goes in .gitignore. Each developer has their own file.
Docker secrets (safer):
services:
api:
secrets:
- db_password
environment:
- DATABASE_PASSWORD_FILE=/run/secrets/db_password
secrets:
db_password:
file: ./secrets/db_password.txt
The ./secrets/ folder goes in .gitignore. Each developer fills it manually or generates it automatically.
Secret managers (for large teams):
- 1Password CLI
- Bitwarden CLI
- Doppler
- Infisical
These tools can inject secrets into the environment before docker compose up.
IDE Integration #
VS Code #
Dev Containers extension:
Open the repo in VS Code, click “Reopen in Container”, and VS Code automatically attaches to the development container.
// .devcontainer/devcontainer.json
{
"name": "MyApp Dev",
"dockerComposeFile": "../docker-compose.yml",
"service": "api",
"workspaceFolder": "/app",
"extensions": [
"ms-python.python"
]
}
Docker extension:
View containers, images, volumes, and networks from VS Code. Restart, view logs, exec shells.
JetBrains IDEs (GoLand, PyCharm, IntelliJ) #
Built-in Docker plugin:
- Run/Debug configurations can target containers.
- File watchers for hot reload.
- Database tools for querying container databases directly.
Recap Cheatsheet #
| Pattern | Purpose |
|---|---|
docker-compose.override.yml | Default override for development |
command: <dev-mode> | Hot reload watcher |
| Bind-mount source | Hot reload of source code |
| Anonymous volume deps | Persist node_modules in the container |
| Init containers | Seed data, schema migrations |
| Adminer / pgAdmin | Database UI |
| MailHog | Email testing |
| File-watching tweaks | Mac/Windows polling |
| Dev Containers | IDE integration |
| Production overrides | -f docker-compose.yml -f docker-compose.prod.yml |
Summary #
- Local development is Docker Compose’s most common and most valuable use case. Set up a complete stack within minutes.
- Override files let the same base configuration serve dev/staging/production. Compose auto-loads
docker-compose.override.ymlif present.- Hot reload with source bind mounts + in-container watcher tools (nodemon, air, uvicorn –reload, Vite HMR).
- Volumes for dependencies (
node_modules,vendor/,__pycache__) — don’t bind-mount them from the host.- Seed data via init containers, SQL scripts in
/docker-entrypoint-initdb.d, or application fixtures.- Database tools (Adminer, pgAdmin, RedisInsight) for UI-based debugging.
- MailHog for testing emails without sending to real recipients.
- File-watching quirks — Mac with Docker Desktop sometimes needs polling. Linux usually propagates directly.
- Debugging with
docker compose exec,logs -f,inspect,restart,down --volumesfor resets.- Best practices: override files for dev, hot reload, dependencies in volumes, README documentation, stable image tags, more tools for dev rather than fewer.
- Multi-repo Compose: use multiple files or an external network.
- For a production-grade dev experience, consider Tilt/Skaffold for a Kubernetes-like workflow.