Best Practice #

Local development with Docker Compose is the meeting point of developer productivity and environment consistency. The difference from production: in local development, developer experience (DX) is the main feature — not image size, security hardening, or zero-downtime deployment. This article summarizes best practices that apply across languages and frameworks (Go, Java, Node.js, Python, Rust, Angular, React, Vue, and others), and will make your local development setup consistent, fast, and pleasant to use.

The ten principles below are ordered by priority: starting with the most-skipped items (override files, healthchecks), moving up to security and operations (network isolation, secrets, logging). Apply them gradually — even one or two principles will already make your workflow feel more solid.


1. Always Use Override Files for Development #

One of the most fundamental decisions in local development is not mixing development configuration with production configuration. Override files are Docker Compose’s official mechanism for separating the two without duplication.

# docker-compose.yml — base, the same for all environments
services:
  api:
    build:
      context: ./api
      dockerfile: Dockerfile
    image: myapp/api:${VERSION:-latest}
    environment:
      - DATABASE_URL=postgres://app:pass@db:5432/myapp
      - REDIS_URL=redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
    networks:
      - backend

  db:
    image: postgres:16.2-alpine
    environment:
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD=secret
    volumes:
      - db-data:/var/lib/postgresql/data
    networks:
      - backend

networks:
  backend:

volumes:
  db-data:
# docker-compose.override.yml — AUTO-MERGED during `docker compose up`
services:
  api:
    command: npm run dev
    volumes:
      - ./api/src:/app/src  # bind-mount source code
    ports:
      - "3000:3000"  # expose to the host
    environment:
      - NODE_ENV=development
      - DEBUG=*

  db:
    ports:
      - "5432:5432"  # expose only for debugging
# Development: the override is loaded automatically
docker compose up

# Production: does NOT load the override
docker compose -f docker-compose.yml up -d
# ANTI-PATTERN: one file for all environments
services:
  api:
    command: npm run dev  # MUST be overridden for production
    volumes:
      - ./api/src:/app/src  # bind mounts MUST NOT exist in production
    ports:
      - "3000:3000"  # port mappings must differ in production

Without override files, you’ll often be adding # if development comments or manually changing commands. That’s error-prone, hard to review, and frustrating for teammates. Override files also provide a single source of truth for the same service — the base file defines “what this service looks like”, the override file defines “for development, which parts differ”.

Override files are only for development. For other environments (staging, production, preview), use explicit files: docker-compose -f docker-compose.yml -f docker-compose.prod.yml up. Don’t use docker-compose.override.yml for production — it’s an invitation for disaster.

2. Use Healthchecks for Dependent Services #

depends_on only controls start order, not service readiness. A database can be running but not yet accepting connections — the app will crash with a connection refused error. The solution: healthchecks.

services:
  db:
    image: postgres:16.2-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 10s

  cache:
    image: redis:7.2-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3

  api:
    depends_on:
      db:
        condition: service_healthy  # wait until the db healthcheck is "healthy"
      cache:
        condition: service_healthy
# ANTI-PATTERN: depends_on without healthchecks
services:
  api:
    depends_on:
      - db
      - cache
    # this only ensures db/cache start first, not that they're "ready"
# ANTI-PATTERN: a sleep workaround in the entrypoint
#!/bin/sh
sleep 30  # DON'T. Unreliable, race conditions, slow
node server.js
# Check the healthcheck status
docker compose ps
# STATUS: Up 2 minutes (healthy) ← what we want
# STATUS: Up 2 minutes (health: starting) ← wait

Healthchecks in local development are just as important as in production. Without them, you’ll often see startup errors that shouldn’t actually happen — errors that disappear after a few seconds. But while they appear, developers lose time debugging things that aren’t bugs.

Choose a lightweight, fast healthcheck command. pg_isready is faster than psql -c "SELECT 1". For HTTP services, wget -q --spider or curl -f is faster than a full HTTP request. Healthchecks run every few seconds — optimize for speed.

3. Bind-Mount Source Code for Hot Reload #

One of Docker Compose’s biggest strengths in local development is bind mounting source code. With a bind mount, editing a file on the host is immediately visible in the container — no rebuild, no restart.

# CORRECT: bind mount for source code
services:
  api:
    build:
      context: ./api
      dockerfile: Dockerfile.dev  # development-specific Dockerfile
    command: npm run dev  # run in watch/hot reload mode
    volumes:
      - ./api/src:/app/src  # source code bound from the host
      - /app/node_modules    # anonymous volume, DON'T overwrite
    environment:
      - NODE_ENV=development
# ANTI-PATTERN: copy source code in the Dockerfile, rebuild on every edit
services:
  api:
    build:
      context: ./api
    command: node server.js
    # No bind mount → host edits are invisible to the container
# CORRECT: separate dependency caches from source code
services:
  api:
    volumes:
      - ./api/src:/app/src           # source code bind mount
      - node_modules:/app/node_modules  # dependencies in a named volume
      - /app/dist                    # anonymous build output
# Verify the bind mount is active
docker compose exec api ls -la /app/src
# Host files must appear

The key trick: anonymous volumes (empty host paths) to override directories that exist in the image. Example in Node.js: when npm install creates node_modules in the image, a ./src:/app/src bind mount creates a new src directory — but node_modules stays intact. If you bind-mount the whole project (./api:/app), node_modules will disappear because the host doesn’t have that folder. The solution: bind-mount specific subfolders + anonymous volumes for things that must not be overwritten.

This pattern also applies to other languages:

# Java/Maven: cache the local repository
services:
  api:
    volumes:
      - ./api/src:/app/src
      - maven-repo:/root/.m2/repository  # named volume
      - /app/target                       # anonymous, build output

# Rust/Cargo: cache registry + target
services:
  api:
    volumes:
      - ./api/src:/app/src
      - cargo-registry:/usr/local/cargo/registry
      - cargo-target:/app/target

# Python: cache pip + venv
services:
  api:
    volumes:
      - ./api/app:/app
      - pip-cache:/root/.cache/pip
      - /app/.venv  # if using a venv

4. Named Volumes for Persistent Data #

Distinguish two kinds of volumes: bind mounts for source code (see principle #3), named volumes for data that must survive container restarts.

# CORRECT: a named volume for persistent data
services:
  db:
    image: postgres:16.2-alpine
    volumes:
      - db-data:/var/lib/postgresql/data

volumes:
  db-data:  # Docker manages it, portable, backup-friendly
# NOT GREAT for development: bind mount to an absolute path
services:
  db:
    volumes:
      - /Users/you/data/postgres:/var/lib/postgresql/data
    # Fragile: move machines, move OSes, gone
# Inspect a volume
docker volume inspect <project>_db-data
# Location: /var/lib/docker/volumes/<project>_db-data/_data
# Independent of host absolute paths
# Back up a named volume
docker run --rm \
  -v <project>_db-data:/source:ro \
  -v $(pwd):/backup \
  alpine tar -czf /backup/db-data.tar.gz -C /source .

# Restore
docker run --rm \
  -v <project>_db-data:/target \
  -v $(pwd):/backup \
  alpine tar -xzf /backup/db-data.tar.gz -C /target
# Reset the volume if the development database is corrupted
docker compose down -v  # REMOVES volumes, NOT containers
docker compose up
Don’t bind-mount /var/lib/postgresql/data to the host for development. Every time the host reboots, the path can change (especially on macOS/Windows with Docker Desktop, which hides the VM behind a layer). Docker-managed named volumes are stable across all environments.

A practical guide: bind mounts for source code, named volumes for data, anonymous volumes for caches that get rebuilt. Mix all three deliberately — each has its purpose. For production data, consider a backup strategy (see the example above) — development data can also be valuable (schema migration tests, fixture data for testing).


5. Non-Root Users for Container Security #

Containers running as root are the default for many official images. This isn’t a big problem in local development because namespace isolation means root in a container has no direct host access, but defense in depth teaches us not to rely on a single security layer.

# CORRECT: the container runs as a non-root user
services:
  api:
    user: "1000:1000"  # UID:GID
# CORRECT: in the Dockerfile, switch to a non-root user
FROM node:20-alpine

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

COPY --chown=node:node . .

USER node  # Built-in user in the node:alpine image
# ANTI-PATTERN: the container runs as root
services:
  api:
    # No `user:` → runs as root (UID 0)
# Check the user running in the container
docker compose exec api whoami
# Must be: node or appuser, NOT root
# Check the UID of the main process
docker compose exec api stat -c '%u' /proc/1/
# Must be: 1000 (or another non-zero value)
If your app writes files to a bind mount (e.g. mounted source code), running as root will make host files owned by root. This causes permission errors in your IDE/editor. The solution: set a UID matching your host user (1000:1000 for regular users on Linux/macOS).

For local development, focus on matching the UID with the host to avoid permission drama. For production, focus on defense in depth so a container escape doesn’t immediately become host root. The two complement each other — good development practices are easier to promote to production.

# Find your host user's UID
id -u
# Output: 1000 (or another number)
# Use this value in compose: user: "1000:1000"
# Multi-service with a consistent UID
services:
  api:
    user: "1000:1000"
  worker:
    user: "1000:1000"
  scheduler:
    user: "1000:1000"

6. Resource Limits for Every Service #

Resource limits prevent one service from consuming all your laptop’s memory/CPU. Without limits, a single runaway service can hang the entire Docker Desktop, forcing a host reboot.

# CORRECT: set resource limits
services:
  api:
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 1G
        reservations:
          cpus: "0.25"
          memory: 256M
# Alternative syntax (top-level, simpler)
services:
  api:
    mem_limit: 1g
    mem_reservation: 256m
    cpus: "1.0"
# ANTI-PATTERN: no limits
services:
  api:
    # No constraints → can consume all memory/CPU
# Monitor resource usage
docker stats
# CONTAINER    CPU %    MEM USAGE / LIMIT    MEM %
# myapp-api    0.5%     245MiB / 1GiB        24%
# myapp-db     2.1%     380MiB / 2GiB        19%
In local development, limit according to your laptop’s resources. If the laptop has 16GB RAM and 4 cores, divide evenly: API 1GB/1 core, DB 2GB/2 cores, cache 256MB/0.5 core — 3.25GB / 3.5 cores total, leaving plenty for the IDE, browser, and OS. Don’t copy-paste limits from online tutorials without considering your hardware context.

Resource limits also matter for testing failure scenarios. With a 512MB memory limit, you can see whether your app gracefully handles OOM (out of memory) — or crashes. Without a limit, you’ll never know until production. This is a light form of chaos engineering you can do every day.

# Resource allocation guideline for development
services:
  api:        # memory: 512M-1G, cpu: 0.5-1
  web:        # memory: 256M-512M, cpu: 0.25-0.5
  worker:     # memory: 512M-1G, cpu: 0.5-1
  db:         # memory: 1G-2G, cpu: 1-2
  cache:      # memory: 128M-256M, cpu: 0.25-0.5
  queue:      # memory: 256M-512M, cpu: 0.25-0.5

7. Network Isolation by Role #

Network isolation might sound like a production concept, but it’s relevant in local development too — especially for preventing accidental exposure and speeding up cold starts.

# CORRECT: separate networks by role
services:
  web:
    networks:
      - frontend

  api:
    networks:
      - frontend  # reachable from web
      - backend   # reachable from db, cache

  db:
    networks:
      - backend   # NOT on frontend → not reachable from web
    # No `ports:` → not exposed to the host

  cache:
    networks:
      - backend

networks:
  frontend:
  backend:
# ANTI-PATTERN: one network for everything + expose everything
services:
  db:
    networks:
      - default
    ports:
      - "5432:5432"  # anyone can access the DB from the host

  cache:
    networks:
      - default
    ports:
      - "6379:6379"  # and Redis too
flowchart LR
    subgraph HOST["Host (your laptop)"]
        DEV[Developer / IDE]
    end

    subgraph FRONTEND["Network: frontend"]
        WEB[web :3000]
        API[api :8080]
    end

    subgraph BACKEND["Network: backend (internal)"]
        DB[db :5432]
        CACHE[cache :6379]
        QUEUE[queue :5672]
    end

    DEV -->|localhost:3000| WEB
    DEV -->|localhost:8080| API
    API --> DB
    API --> CACHE
    API --> QUEUE
    WEB -.cannot.-> DB

    style BACKEND fill:#f5f5f5,stroke:#333
    style FRONTEND fill:#e3f2fd,stroke:#1976d2
# Test the isolation: try accessing db from a container that shouldn't
docker compose exec web ping db
# Must fail — web has no access to the backend network

Network separation also gives a performance benefit. Docker bridge networks have a small but real overhead. If frequently-communicating services (api ↔ db) are on the same network, latency is lower than hopping across many networks. More importantly, this is a production simulation — in production, the frontend can never access the database directly. If your development setup mirrors that pattern, you’ll be more confident that your code is truly safe in production.


8. Secret Management — Don’t Hardcode #

Secrets (passwords, API keys, tokens) must not live in docker-compose.yml files committed to Git. Once committed, it’s too late — Git history remembers them forever.

# CORRECT: secrets via a .env file
services:
  db:
    environment:
      - POSTGRES_PASSWORD=${DB_PASSWORD}
# .env (gitignored)
DB_PASSWORD=local-dev-password
REDIS_PASSWORD=local-dev-redis
API_KEY=sk-test123
# .env.example (committed, without values)
DB_PASSWORD=changeme
REDIS_PASSWORD=changeme
API_KEY=your-key-here
# .gitignore
.env
.env.local
.env.*.local
# CORRECT: secrets via Docker secrets (more secure)
services:
  api:
    secrets:
      - db_password
      - api_key

secrets:
  db_password:
    file: ./secrets/db_password.txt
  api_key:
    file: ./secrets/api_key.txt
# DON'T: hardcode in the compose file
services:
  db:
    environment:
      - POSTGRES_PASSWORD=supersecret123  # in Git forever
# Check for secrets in Git history
git log -p | grep -iE 'password|secret|key|token' | head -20
# If found → rotate the secret NOW, then clean the history
If a production secret was ever committed to Git, rotate that secret. Cleaning Git history doesn’t remove data from other people’s clones. Use tools like git-filter-repo or BFG Repo-Cleaner, but still treat the secret as compromised.

For local development, .env is enough. Docker secrets are overkill. The important part: separate secrets from code and .gitignore the .env.

# Use env_file for grouping (cleaner than an environment list)
services:
  api:
    env_file:
      - .env
      - .env.local  # optional, overrides for environment-specific values
    environment:
      - APP_ENV=local

9. Stable Image Tags — Avoid latest #

The latest tag is a dangerous default — the image behind that tag can change at any time without you noticing. Today your app works; tomorrow it suddenly breaks because upstream released a major version.

# CORRECT: pin to a specific version
services:
  db:
    image: postgres:16.2-alpine  # major.minor.patch + variant

  cache:
    image: redis:7.2-alpine

  api:
    image: myapp/api:1.4.2-alpine
# ANTI-PATTERN: use latest
services:
  db:
    image: postgres:latest  # can change at any time

  cache:
    image: redis  # defaults to latest, just as dangerous
# NOT GREAT: too loose
services:
  db:
    image: postgres:16  # gets minor/patch updates automatically
# Check available image tags
docker search postgres --limit 5
# See all tags on Docker Hub: https://hub.docker.com/_/postgres/tags
# CORRECT: the Dockerfile also pins the base image
FROM node:20.11-alpine  # NOT node:latest or node:20

The best tag patterns:

  • Production: image:postgres:16.2.1-alpine (full version)
  • Staging: image:postgres:16.2-alpine (minor pinned)
  • Local dev: image:postgres:16-alpine (major pinned) — still OK as long as you’re aware
  • Avoid: latest, no tag, or major-only (postgres:16 gets minor updates)

This principle applies both ways: both language/tool images (Node, Python, Go) and application images (the ones you build yourself). For internal apps, tag with the app version (e.g. 1.4.2) plus an environment label (e.g. 1.4.2-staging).

# See the image tag and digest (for full reproducibility)
docker pull postgres:16.2-alpine
docker inspect postgres:16.2-alpine | jq '.[0].RepoDigests'
# Pin to the digest for full reproducibility
# image: postgres@sha256:abc123...

10. Logging Configuration for Log Rotation #

Containers default to the json-file driver, which has no automatic rotation. Logs can fill the disk within days, especially in development with verbose logs.

# CORRECT: set a logging limit
services:
  api:
    logging:
      driver: json-file
      options:
        max-size: "10m"  # max 10MB per file
        max-file: "3"    # keep max 3 files (30MB total)
# ANTI-PATTERN: no logging config
services:
  api:
    # Default: unlimited json-file
    # Can fill the disk, hard to trace, hard to rotate
# View logs with a limit
docker compose logs --tail=100 api

# Follow logs in real-time
docker compose logs -f api

# See the current log size
ls -lah ~/.docker/containers/*/\(api\)*.log
# CONSEQUENCE: logs without limits fill the disk
# /var/lib/docker/containers/<id>/<id>-json.log can reach GBs
# Disk full → Docker can't start new containers
# Manual fix: docker system prune --volumes
# For development, output to STDOUT only — let `docker compose logs` handle it
services:
  api:
    # Inside the container, the app logs to STDOUT/STDERR, not files
    # docker compose logs captures and displays it
For local development, logging to STDOUT is the cleanest pattern. Your app logs to the console, Docker captures it, and docker compose logs displays it. No logrotate, file rotation, or external aggregator setup needed. If you need log persistence (rare in development), use a volume mount to a host log directory.

Anti-Patterns to Avoid #

Here are the most common local-development anti-patterns to watch out for.

1. Using latest for All Images #

# ✗ DON'T
services:
  db:
    image: postgres
  cache:
    image: redis

# ✓ CORRECT: pin to a specific version
services:
  db:
    image: postgres:16.2-alpine
  cache:
    image: redis:7.2-alpine

Can change at any time. Today’s build can differ from tomorrow’s. Debugging becomes a nightmare because you’re unsure which image is running.

2. Hardcoding Secrets in the Compose File #

# ✗ DON'T
services:
  db:
    environment:
      - POSTGRES_PASSWORD=supersecret

# ✓ CORRECT: use .env (gitignored)
services:
  db:
    environment:
      - POSTGRES_PASSWORD=${DB_PASSWORD}

Secrets in files committed to Git are stored forever in history. Even after you delete them in the next commit, history still remembers.

3. No Healthchecks #

# ✗ DON'T
services:
  api:
    depends_on:
      - db
      - cache
    # depends_on does NOT wait for service readiness, only start order

# ✓ CORRECT: add healthchecks + condition: service_healthy
services:
  db:
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  api:
    depends_on:
      db:
        condition: service_healthy

depends_on alone is unreliable. Dependent services can start before the dependency is truly ready — connection refused errors at startup.

4. Bind Mounts for Persistent Data #

# ✗ DON'T for development
services:
  db:
    volumes:
      - /var/lib/postgresql/data:/var/lib/postgresql/data

# ✓ CORRECT: use a named volume
services:
  db:
    volumes:
      - db-data:/var/lib/postgresql/data

volumes:
  db-data:

Bind mounts to absolute paths are fragile — move machines, move OSes, gone. Docker-managed named volumes are stable across environments.

5. Containers Running as Root #

# ✗ DON'T
services:
  api:
    # default: runs as root (UID 0)

# ✓ CORRECT: explicit non-root user
services:
  api:
    user: "1000:1000"

Many images default to running as root. Override with user: in compose or USER in the Dockerfile. In local development, this also prevents permission drama in bind mounts.

6. Using sleep to Wait for Dependencies #

# ✗ DON'T
#!/bin/sh
sleep 30  # magic number, race conditions, slow
node server.js

# ✓ CORRECT: retry logic in the app, or healthchecks + depends_on

A sleep in the entrypoint is an unreliable band-aid. Use healthchecks + condition: service_healthy — or retry logic in the app (which is also production-ready).

7. Exposing All Ports to the Host #

# ✗ DON'T
services:
  db:
    ports:
      - "5432:5432"  # no need to be reachable from the host
  cache:
    ports:
      - "6379:6379"

# ✓ CORRECT: only expose what's actually needed
services:
  db:
    expose:
      - "5432"  # only accessible on the internal network

Every port exposed to the host is an attack surface. Only expose services that genuinely need host reachability (API, frontend). Databases, caches, message brokers — keep them internal.

8. Logging Without Limits #

# ✗ DON'T
services:
  api:
    # default: json-file without limits

# ✓ CORRECT: set max-size + max-file
services:
  api:
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

Logs without limits fill the disk within days (especially in development with verbose debugging). Set limits or output to STDOUT.


Local Development Review Checklist #

Before your Compose file is considered ready for local development, run this checklist.

DOCKERFILE:
  □ Base image pinned to a specific version (not :latest)
  □ Multi-stage build for production
  □ Dockerfile.dev separate from the production Dockerfile
  □ Non-root user set in the production stage
  □ .dockerignore excludes unneeded files (.git, node_modules, etc.)

COMPOSE:
  □ Override file for development (docker-compose.override.yml)
  □ Descriptive service names (api, db, cache, queue, web)
  □ env_file for grouping environment variables
  □ .env gitignored, .env.example committed
  □ Consistent naming conventions
  □ Comments explain WHY (not WHAT)
  □ docker compose config validates (no warnings)

HEALTHCHECK:
  □ Healthchecks for services with dependents (db, cache, queue)
  □ Lightweight, fast healthcheck commands
  □ interval/timeout/retries tuned sensibly
  □ depends_on uses condition: service_healthy
  □ No sleep in the entrypoint to wait for dependencies

VOLUME:
  □ Bind mounts for source code (hot reload)
  □ Named volumes for persistent data (db, cache state)
  □ Anonymous volumes for caches that get rebuilt (node_modules, target)
  □ No bind mounts to host absolute paths for data
  □ Backup strategy for important data (optional in dev)

NETWORK:
  □ Network isolation by role (frontend/backend)
  □ Database on an internal network (not reachable from web)
  □ Only expose the ports that genuinely need host access
  □ Inter-service communication uses service names (not IPs)

SECURITY:
  □ No hardcoded secrets in the compose file
  □ Containers run as non-root users
  □ Base images as small as possible (alpine/slim)
  □ Resource limits set for every service
  □ Logging configuration with max-size and max-file

Summary #

  • Override files — separate development configuration from the base. docker-compose.override.yml auto-loads during docker compose up.
  • Healthchecksdepends_on only controls start order, not readiness. Add healthchecks + condition: service_healthy for dependent services.
  • Bind mounts for source code (hot reload), named volumes for persistent data, anonymous volumes for caches that get rebuilt.
  • Named volumes for databases, cache state, upload files. Don’t bind-mount to host absolute paths.
  • Non-root usersuser: "1000:1000" in compose or USER in the Dockerfile. Match the UID with the host to avoid permission drama.
  • Resource limitsdeploy.resources.limits or the top-level mem_limit/cpus. Allocate per your laptop’s resources.
  • Network isolation — separate frontend/backend. Databases on internal networks, not exposed to the host.
  • Secret management — use .env (gitignored) or secrets:. NEVER hardcode in committed compose files.
  • Stable image tags — pin to major.minor.patch + variant. Avoid latest and major-only tags.
  • Logging configuration — set max-size + max-file for automatic rotation. Output to STDOUT, let docker compose logs handle it.
  • Checklists — Dockerfile, Compose, Healthcheck, Volume, Network, Security. Run a review before every commit to the main branch.
  • Anti-patterns: latest tags, hardcoded secrets, no healthchecks, bind mounts for data, root users, sleep in entrypoints, exposing all ports, unlimited logs.
  • Local development ≠ production — focus on DX and iteration speed, not image size or maximal security hardening.
  • Gradual iteration — apply one or two principles first, feel the improvement, then add more.
  • Best practices are about consistency — pick patterns and apply them across all projects. Mixing patterns makes onboarding new developers hard.

← Previous: Angular
About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact