MervCodes

Tech Reviews From A Programmer

Docker Compose for Local Development: A Practical Guide for Developers

8 min read

You're juggling PostgreSQL, Redis, a Node.js API, and a React frontend on your machine. One service breaks, you spend 20 minutes debugging environment variables. Another dev clones your repo and can't get it running without three Slack messages. Docker Compose solves this—it's the difference between "works on my machine" and "works everywhere."

TL;DR: Master Docker Compose for local development. Learn to orchestrate multi-container applications with practical examples, best practices, and real-world workflows.

If you've been manually spinning up containers or worse, installing everything locally, you're burning time you don't have. This guide shows you exactly how to set up Docker Compose for real-world development workflows, complete with the patterns I use across projects.

Why Docker Compose Matters for Local Development

Docker itself lets you containerize applications. Docker Compose orchestrates multiple containers as a single system. For developers, this means:

  • Reproducible environments: Every team member runs identical services
  • No pollution: Your machine stays clean—no globally installed databases
  • Service networking: Containers talk to each other seamlessly
  • Volume mounting: Live code changes without rebuilding
  • One-command startup: docker compose up and everything runs

The alternative is shell scripts, makefiles, or worse—memory. Compose is declarative, version-controlled, and self-documenting.

Your First Docker Compose Setup

Let's build a realistic stack: a Node.js API, PostgreSQL database, and Redis cache.

Create a docker-compose.yml file at your project root:

version: '3.9'

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: my-api
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgresql://postgres:password@db:5432/myapp
      - REDIS_URL=redis://cache:6379
    volumes:
      - .:/app
      - /app/node_modules
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    networks:
      - app-network

  db:
    image: postgres:16-alpine
    container_name: my-postgres
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=myapp
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - app-network

  cache:
    image: redis:7-alpine
    container_name: my-redis
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    networks:
      - app-network

volumes:
  postgres_data:
  redis_data:

networks:
  app-network:
    driver: bridge

This is a production-grade template. Let me break down the critical parts.

Understanding the Core Concepts

Service Definition

Each service under services: is a container. The api service uses a local Dockerfile, while db and cache use pre-built images from Docker Hub.

# Dockerfile for the API
FROM node:20-alpine

WORKDIR /app

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

COPY . .

EXPOSE 3000
CMD ["node", "src/index.js"]

Keep your Dockerfile clean and lightweight. Alpine images reduce size; npm ci ensures reproducible builds.

Volumes: The Game Changer

volumes:
  - .:/app              # Bind current directory to /app
  - /app/node_modules   # Keep node_modules in container

The first volume syncs your code into the container. Changes locally reflect instantly—no rebuild needed. The second prevents local node_modules from overwriting container dependencies.

For databases, named volumes persist data:

volumes:
  - postgres_data:/var/lib/postgresql/data

This survives docker compose down. Data lives even when containers stop.

Environment Variables

Pass config through environment:

environment:
  - DATABASE_URL=postgresql://postgres:password@db:5432/myapp
  - REDIS_URL=redis://cache:6379

Inside containers, services reference each other by name (db, cache). Docker's embedded DNS resolves these to container IPs automatically.

Never hardcode secrets in docker-compose.yml. Use .env files instead:

# .env
POSTGRES_PASSWORD=supersecret
API_TOKEN=dev-token-12345

Reference them in your compose file:

environment:
  - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
  - API_TOKEN=${API_TOKEN}

Health Checks and Dependencies

depends_on:
  db:
    condition: service_healthy
  cache:
    condition: service_started

Wait for the database to actually be ready (not just running) before starting the API. This prevents connection errors on startup.

The healthcheck on PostgreSQL ensures it's truly accepting connections:

healthcheck:
  test: ["CMD-SHELL", "pg_isready -U postgres"]
  interval: 10s
  timeout: 5s
  retries: 5

Real-World Workflows

Starting Your Dev Environment

docker compose up

This builds and starts all services. The -d flag runs in background:

docker compose up -d

View logs from all services:

docker compose logs -f

Or logs from a specific service:

docker compose logs -f api

Making Database Changes

Migrations live in your repo. On startup, run them automatically:

api:
  command: sh -c "npm run migrate && node src/index.js"

Or for manual runs:

docker compose exec api npm run migrate

Debugging Inside Containers

docker compose exec api bash

This drops you into a shell inside the container. Useful for checking environment variables, inspecting files, or running commands.

docker compose exec api npm test

Run tests without leaving your terminal.

Resetting Everything

docker compose down

Stops and removes containers, but preserves named volumes.

docker compose down -v

Includes volumes—everything gone, fresh start. Useful when your database schema is corrupted during development.

Multi-Environment Compose Files

For staging or production differences, use overrides:

docker compose -f docker-compose.yml -f docker-compose.prod.yml up

The prod file overrides base settings. Example:

# docker-compose.prod.yml
services:
  api:
    build:
      context: .
      cache_from:
        - my-registry/api:latest
    restart: always
    environment:
      - NODE_ENV=production

Common Pitfalls and Solutions

Ports Already in Use

Error response from daemon: driver failed programming external connectivity on endpoint

Your machine already has something on port 3000. Change it:

ports:
  - "3001:3000"  # Access API at localhost:3001

Containers Can't Reach Each Other

Always define a network explicitly:

networks:
  app-network:
    driver: bridge

Then add all services to it. This ensures service names resolve correctly.

Permission Denied on Volumes

Linux containers run as root by default. Mounted volumes might have permission issues. Set the user explicitly:

api:
  user: "1000:1000"  # Your UID:GID
  volumes:
    - .:/app

Find your UID with id.

Database Won't Start

docker compose logs db

Usually it's a volume permission issue or missing init script. Verify the path exists:

ls -la ./init.sql

Disk Space Blows Up

Images and layers accumulate. Clean up:

docker system prune -a

Remove unused images and containers. Add --volumes to clear volumes too (careful—deletes data).

Performance Optimization

Reduce Build Time

Use .dockerignore to exclude unnecessary files:

node_modules
.git
.env
dist
build

Cache layers efficiently in your Dockerfile:

# Bad: reinstalls on every code change
COPY . .
RUN npm install

# Good: only reinstalls if dependencies change
COPY package*.json ./
RUN npm install
COPY . .

Optimize Volume Performance

On macOS and Windows, bind mounts are slow. Use named volumes for non-source code:

volumes:
  - .:/app            # Source code
  - /app/node_modules  # Keep in container

Use Alpine Images

image: postgres:16-alpine
image: redis:7-alpine
image: node:20-alpine

Alpine is 5-10x smaller than standard images. Pulls faster, starts faster.

Testing and CI Integration

Run tests locally:

docker compose run --rm api npm test

The --rm flag removes the container after completion.

In GitHub Actions:

services:
  api:
    image: node:20-alpine
  postgres:
    image: postgres:16-alpine
    env:
      POSTGRES_PASSWORD: password

Compose isn't needed in CI—use service containers instead. But your local setup should mirror it.

Monitoring and Troubleshooting

Check Container Status

docker compose ps

Shows all containers, their status, and ports.

Inspect Configuration

docker compose config

Prints the resolved compose file after environment variable substitution. Useful when debugging .env issues.

View Resource Usage

docker stats

Real-time CPU and memory usage. Identifies resource hogs.

Production Considerations

Docker Compose isn't for production. Use Kubernetes, ECS, or Cloud Run instead. But your local setup should be production-adjacent:

  • Match image versions exactly (postgres:16-alpine, not postgres:latest)
  • Use restart: always in production, never in development
  • Secrets come from secure vaults, not .env files
  • Health checks are production-critical, worth setting up locally too

Final Checklist

Before shipping your project:

  • All team members run docker compose up and everything works
  • Services are named and networked explicitly
  • Data persists in named volumes, not bind mounts
  • Health checks verify service readiness
  • .env.example documents all required variables
  • .gitignore excludes .env and docker-compose.override.yml
  • README includes setup instructions
  • Database init scripts are version-controlled

Docker Compose is a force multiplier. It removes entire categories of "works on my machine" bugs, accelerates onboarding, and makes your development experience consistent. Invest 30 minutes now to set it up properly, and you'll save hours across your team.

Sources

  1. Docker Documentation
  2. Docker Hub
  3. Docker Compose Documentation

Looking for more? Check out Adaptels.

Related Articles

How to Debug Node.js Memory Leaks (Step-by-Step Guide)

Learn how to detect, diagnose, and fix Node.js memory leaks using heap snapshots, Chrome DevTools, and clinic.js — with real code examples.

How to Set Up GitHub Actions for CI/CD (Beginner-Friendly Guide)

Learn how to set up GitHub Actions for CI/CD pipelines — from your first workflow file to automated deployments with real YAML examples.

Running Local LLMs With Ollama: Developer Setup Guide

Set up Ollama to run local LLMs on your machine. Covers installation, model selection, API usage, and integrating local models into your dev workflow.