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 --polling or 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 #

PatternPurpose
docker-compose.override.ymlDefault override for development
command: <dev-mode>Hot reload watcher
Bind-mount sourceHot reload of source code
Anonymous volume depsPersist node_modules in the container
Init containersSeed data, schema migrations
Adminer / pgAdminDatabase UI
MailHogEmail testing
File-watching tweaksMac/Windows polling
Dev ContainersIDE 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.yml if 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 --volumes for 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.

← Previous: Healthcheck   Next: Best Practice →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact