•6 min read

Docker Compose for Local Development: A Complete Setup Guide

Docker Compose for Local Development: A Complete Setup Guide

Modern web applications are rarely a single service. A typical 2026 stack includes a Node.js or Python API, a PostgreSQL database, a Redis cache, a Celery or BullMQ worker, and perhaps a local S3-compatible store like MinIO. Managing these dependencies manually — installing specific Postgres versions, configuring ports, juggling terminal tabs — is a recipe for environment drift and the classic "it works on my machine" failure.

Docker Compose solves this by expressing your entire local infrastructure as a single declarative YAML file. In 2026, Docker Compose v2 ships with Docker Desktop and Docker Engine, integrates BuildKit natively, and supports GPU profiles, healthchecks, and secrets — making it production-capable for local workflows.

Audio Briefing
0:00 / 0:00
Interactive Dev Tool
100% Client-Side & Private

Docker Compose to Kubernetes Converter

Generate Deployments, Services, ConfigMaps & Ingress

Convert multi-service Docker Compose v2/v3 YAML into production-grade Kubernetes manifests with custom replicas, health probes, resource limits, and PVC storage.

K8s DeploymentsConfigMapsPVC StorageIngress YAML

Why Your docker compose up Should Mirror Production

The goal is not just convenience. A Compose file that mirrors production catches environment-specific bugs before they reach staging. Concrete parity points to enforce:

  • Same PostgreSQL version, same encoding (UTF8), same locale (en_US.UTF-8)
  • Same Redis version and eviction policy (volatile-lru for session caches)
  • Same environment variable names (sourced from .env, never hardcoded)
  • Same network topology (services discover each other by service name, not localhost)
  • Same user permissions (API container runs as non-root, mirrors K8s runAsNonRoot)
Advertisement

Step 1: The docker-compose.yml File

Here is a production-mirrored Compose file for a Node.js API with PostgreSQL, Redis, and a background worker:

# docker-compose.yml (Docker Compose v2 — no "version:" key needed)
name: myapp

services:
  api:
    build:
      context: ./api
      target: development
      cache_from:
        - type=local,src=/tmp/.buildx-cache
    ports:
      - "3000:3000"
    volumes:
      - ./api:/usr/src/app
      - node_modules:/usr/src/app/node_modules  # Prevent host node_modules leak
    env_file:
      - .env
    environment:
      NODE_ENV: development
      DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db:5432/myappdb
      REDIS_URL: redis://redis:6379/0
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    command: npm run dev
    user: "1001:1001"   # Match your host UID to avoid root-owned volume files

  worker:
    build:
      context: ./api
      target: development
    volumes:
      - ./api:/usr/src/app
      - node_modules:/usr/src/app/node_modules
    env_file:
      - .env
    environment:
      NODE_ENV: development
      DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db:5432/myappdb
      REDIS_URL: redis://redis:6379/0
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    command: npm run worker
    user: "1001:1001"

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: myappdb
      POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=en_US.UTF-8"
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./db/init.sql:/docker-entrypoint-initdb.d/10-init.sql:ro
      - ./db/seed.sql:/docker-entrypoint-initdb.d/20-seed.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d myappdb"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 10s

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    ports:
      - "6379:6379"
    command: >
      redis-server
      --maxmemory 256mb
      --maxmemory-policy volatile-lru
      --save ""
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

  minio:
    image: minio/minio:latest
    ports:
      - "9000:9000"
      - "9001:9001"   # MinIO Console UI
    environment:
      MINIO_ROOT_USER: ${MINIO_USER}
      MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD}
    command: server /data --console-address ":9001"
    volumes:
      - minio_data:/data
    healthcheck:
      test: ["CMD", "mc", "ready", "local"]
      interval: 10s
      timeout: 5s
      retries: 3

volumes:
  pgdata:
  node_modules:
  minio_data:

Why depends_on with condition: service_healthy Matters

The naive depends_on: [db] only waits for the container to start, not for PostgreSQL to accept connections. Your API's first database query hits "connection refused" and crashes on startup. Using condition: service_healthy with a proper healthcheck ensures Postgres is actually ready before the API boots.

The node_modules Volume Trick

The node_modules:/usr/src/app/node_modules named volume prevents your host node_modules (compiled for macOS or Windows) from overwriting the container's Linux-compiled binaries. Without this, native addons like bcrypt or sharp fail inside the container.

Step 2: Multi-Stage Dockerfile with BuildKit Cache Mounts

BuildKit cache mounts dramatically accelerate rebuilds by persisting the npm cache across builds:

# api/Dockerfile
FROM node:20-alpine AS base
WORKDIR /usr/src/app
RUN addgroup -g 1001 appgroup && adduser -u 1001 -G appgroup -s /bin/sh -D appuser

# Development stage — includes devDependencies, live reload
FROM base AS development
COPY package*.json ./
# BuildKit cache mount: npm cache persists across `docker compose build` runs
RUN --mount=type=cache,target=/root/.npm \
    npm install
COPY . .
# CMD is overridden by docker-compose.yml

# Build stage — compiles TypeScript, runs tests
FROM base AS build
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci
COPY . .
RUN npm run build && npm run test:ci

# Production runtime — minimal image, no devDependencies
FROM node:20-alpine AS production
WORKDIR /usr/src/app
RUN addgroup -g 1001 appgroup && adduser -u 1001 -G appgroup -s /bin/sh -D appuser
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev
COPY --from=build /usr/src/app/dist ./dist
USER appuser
EXPOSE 3000
CMD ["node", "dist/main.js"]

The --mount=type=cache directive is a BuildKit feature. The first build downloads dependencies; subsequent builds read from the cache layer, cutting build time from minutes to seconds for most iterative changes.

Step 3: Environment Secrets Management

Never commit credentials to version control. Use .env with a committed .env.example:

# .env.example — committed to git
POSTGRES_PASSWORD=change_me_in_env
MINIO_USER=minioadmin
MINIO_PASSWORD=change_me_in_env
REPORTER_SECRET=change_me_in_env
# .env — gitignored, copied from .env.example
POSTGRES_PASSWORD=dev_local_secret_32chars
MINIO_USER=minioadmin
MINIO_PASSWORD=dev_local_minio_secret
REPORTER_SECRET=dev_reporter_secret

For production-like secrets in local testing, Docker Compose Secrets provide a more secure alternative that mounts secrets as files rather than environment variables:

secrets:
  db_password:
    file: ./secrets/db_password.txt

services:
  api:
    secrets:
      - db_password
    environment:
      DATABASE_PASSWORD_FILE: /run/secrets/db_password
Advertisement

Step 4: Daily Workflow Commands

# Start the entire stack (foreground — see all logs)
docker compose up

# Start detached, tail only API logs
docker compose up -d && docker compose logs -f api

# Rebuild only the api image after Dockerfile changes
docker compose up --build api

# Run a one-off command inside the running api container
docker compose exec api npm run migrate:latest

# Run a new ephemeral container (doesn't affect the running api)
docker compose run --rm api npm run db:seed

# Stop containers, preserve volumes
docker compose down

# Nuclear reset: stop, remove containers, networks, AND volumes
docker compose down -v

# View resource usage
docker compose stats

Networking and Service Discovery

Docker Compose creates a private network named myapp_default (prefixed with the name: value). Every service is reachable at http://<service-name>:<internal-port>:

Access FromTargetURL
api containerPostgreSQLpostgres://app:…@db:5432/myappdb
api containerRedisredis://redis:6379/0
api containerMinIO APIhttp://minio:9000
Your browserAPIhttp://localhost:3000
Your browserMinIO Consolehttp://localhost:9001

Port mappings (ports:) expose container ports to your host machine. Without a mapping, a service is reachable from other containers but not from your browser — useful for isolating internal services.

Profiles for Optional Services

Use profiles to start optional services (like a Jaeger trace collector) only when needed:

services:
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686"
      - "4317:4317"
    profiles: ["observability"]
# Start with optional observability stack
docker compose --profile observability up

# Start without (default)
docker compose up

Conclusion

A well-crafted docker-compose.yml eliminates environment drift, cuts onboarding from hours to minutes, and gives your CI pipeline the same deterministic environment as every developer's machine. The investment is an hour of setup; the return is months of saved debugging time across your team.

You Might Also Like

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement