DevOps

Docker Compose for Local Development: Orchestrating Services

Docker Compose For Local Development Orchestrating Services

Introduction

Modern applications rarely rely on a single component. You might have a backend API, a frontend client, a database, a cache layer, and a message queue—all needing to run together. Managing each service manually is messy, inconsistent, and time-consuming. New team members spend hours just getting their local environment working.

That’s where Docker Compose comes in. It lets you define and run multi-container applications with a single YAML file, making local development faster, cleaner, and consistent across the entire team. “Works on my machine” becomes a thing of the past when everyone runs identical containerized environments.

In this comprehensive guide, we’ll explore Docker Compose from basic setup to advanced patterns like health checks, profiles, secrets management, and integration with development workflows.

What Is Docker Compose?

Docker Compose is a tool that helps developers manage multi-container Docker applications. Instead of starting each container separately with long docker run commands, you define all services in one file called docker-compose.yml and bring them up together.

Why It’s Useful

  • Simplifies multi-service setup: Define once, run anywhere
  • Ensures consistency: Same environment for all developers
  • One-command operations: docker compose up starts everything
  • Networking out of the box: Services can communicate by name
  • Volume management: Persist data between container restarts
  • CI/CD integration: Run the same setup in pipelines

Setting Up Docker Compose

Docker Compose comes pre-bundled with Docker Desktop. Verify your installation:

# Check Docker and Compose versions
docker --version
docker compose version

# Output example:
# Docker version 24.0.7, build afdd53b
# Docker Compose version v2.23.3

Creating a Complete Compose File

Here’s a comprehensive example for a typical web application stack:

# docker-compose.yml
version: "3.9"

services:
  # Backend API service
  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
      target: development  # Multi-stage build target
    container_name: app_backend
    ports:
      - "5000:5000"
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgres://postgres:password@db:5432/app_db
      - REDIS_URL=redis://redis:6379
      - JWT_SECRET=${JWT_SECRET:-dev_secret_key}
    volumes:
      # Mount source code for hot reload
      - ./backend/src:/app/src:ro
      # Named volume for node_modules (prevents local override)
      - backend_node_modules:/app/node_modules
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    networks:
      - app_network
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

  # Frontend service
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile.dev
    container_name: app_frontend
    ports:
      - "3000:3000"
    environment:
      - VITE_API_URL=http://localhost:5000
      - CHOKIDAR_USEPOLLING=true  # For hot reload in Docker
    volumes:
      - ./frontend/src:/app/src
      - ./frontend/public:/app/public
      - frontend_node_modules:/app/node_modules
    depends_on:
      - backend
    networks:
      - app_network
    stdin_open: true  # For React dev server
    tty: true

  # PostgreSQL database
  db:
    image: postgres:16-alpine
    container_name: app_postgres
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
      POSTGRES_DB: app_db
    volumes:
      # Persist database data
      - postgres_data:/var/lib/postgresql/data
      # Initialize database with schema
      - ./database/init:/docker-entrypoint-initdb.d:ro
    networks:
      - app_network
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  # Redis for caching and sessions
  redis:
    image: redis:7-alpine
    container_name: app_redis
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    command: redis-server --appendonly yes
    networks:
      - app_network
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

  # Adminer for database management (dev only)
  adminer:
    image: adminer:latest
    container_name: app_adminer
    ports:
      - "8080:8080"
    depends_on:
      - db
    networks:
      - app_network
    profiles:
      - debug  # Only starts with --profile debug

  # Mailhog for email testing (dev only)
  mailhog:
    image: mailhog/mailhog:latest
    container_name: app_mailhog
    ports:
      - "1025:1025"  # SMTP
      - "8025:8025"  # Web UI
    networks:
      - app_network
    profiles:
      - debug

# Named volumes for data persistence
volumes:
  postgres_data:
    driver: local
  redis_data:
    driver: local
  backend_node_modules:
  frontend_node_modules:

# Custom network for service communication
networks:
  app_network:
    driver: bridge

Dockerfile Examples for Development

# backend/Dockerfile
# Multi-stage build for development and production

# Base stage with common dependencies
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./

# Development stage
FROM base AS development
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]

# Production build stage
FROM base AS build
RUN npm ci --only=production
COPY . .
RUN npm run build

# Production stage
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
EXPOSE 5000
CMD ["node", "dist/index.js"]
# frontend/Dockerfile.dev
FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .

# Vite dev server
EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

Running Your Services

# Start all services in foreground (see logs)
docker compose up

# Start in detached mode (background)
docker compose up -d

# Start with rebuild (after Dockerfile changes)
docker compose up --build

# Start specific services only
docker compose up backend db

# Start with debug profile (includes Adminer, Mailhog)
docker compose --profile debug up

# Stop all services
docker compose down

# Stop and remove volumes (clean slate)
docker compose down -v

# View logs
docker compose logs -f           # All services
docker compose logs -f backend   # Specific service

# Scale a service (multiple instances)
docker compose up -d --scale backend=3

Managing Environments

Use environment files to separate configuration from code:

# .env (automatically loaded)
COMPOSE_PROJECT_NAME=myapp
POSTGRES_PASSWORD=secure_password
JWT_SECRET=super_secret_jwt_key
API_PORT=5000

# .env.development
DATABASE_URL=postgres://postgres:password@db:5432/app_dev
LOG_LEVEL=debug

# .env.production
DATABASE_URL=postgres://postgres:${POSTGRES_PASSWORD}@db:5432/app_prod
LOG_LEVEL=info
# docker-compose.override.yml (automatically merged in development)
version: "3.9"

services:
  backend:
    build:
      target: development
    volumes:
      - ./backend:/app
    environment:
      - DEBUG=true
    command: npm run dev

  frontend:
    volumes:
      - ./frontend:/app
# Run with specific environment file
docker compose --env-file .env.development up

# Run with production override
docker compose -f docker-compose.yml -f docker-compose.prod.yml up

Essential Commands Reference

# Container management
docker compose ps                    # List containers
docker compose ps -a                 # Include stopped containers
docker compose top                   # Show running processes

# Logs and debugging
docker compose logs -f               # Follow all logs
docker compose logs -f --tail=100    # Last 100 lines, then follow
docker compose logs --since 1h       # Logs from last hour

# Executing commands in containers
docker compose exec backend sh       # Open shell in backend
docker compose exec db psql -U postgres  # Connect to PostgreSQL
docker compose exec backend npm test     # Run tests

# Run one-off commands in new container
docker compose run --rm backend npm run migrate
docker compose run --rm backend npm run seed

# Resource management
docker compose images                # List images used
docker compose port backend 5000     # Show port mapping

# Cleanup
docker compose down                  # Stop and remove containers
docker compose down -v               # Also remove volumes
docker compose down --rmi all        # Also remove images
docker system prune -a               # Clean all unused Docker resources

Development Workflow Integration

# Makefile for common operations
.PHONY: up down logs shell migrate test

up:
	docker compose up -d

up-debug:
	docker compose --profile debug up -d

down:
	docker compose down

clean:
	docker compose down -v --rmi local

logs:
	docker compose logs -f

shell-backend:
	docker compose exec backend sh

shell-db:
	docker compose exec db psql -U postgres -d app_db

migrate:
	docker compose exec backend npm run migrate

seed:
	docker compose exec backend npm run seed

test:
	docker compose exec backend npm test

lint:
	docker compose exec backend npm run lint

rebuild:
	docker compose up -d --build

restart:
	docker compose restart $(service)

Health Checks and Dependencies

# Proper service dependency with health checks
services:
  backend:
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s  # Give app time to start

  db:
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d app_db"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

Networking Patterns

# Services communicate via container names
# backend can reach db at: postgres://db:5432
# frontend can reach backend at: http://backend:5000

services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - backend
      - frontend
    networks:
      - app_network

# nginx.conf
# upstream backend {
#   server backend:5000;
# }
# upstream frontend {
#   server frontend:3000;
# }

Common Mistakes to Avoid

Not using named volumes for node_modules: Mounting the entire project directory overwrites node_modules installed in the container. Use a named volume to preserve it.

Ignoring health checks: Using depends_on without conditions only waits for containers to start, not for services to be ready. Always use health checks.

Storing secrets in docker-compose.yml: Never commit sensitive data. Use .env files (and add them to .gitignore) or Docker secrets.

Not cleaning up resources: Orphan volumes and images accumulate over time. Run docker system prune periodically.

Using latest tags: Always specify version tags for images to ensure reproducible builds.

Running as root in containers: Create non-root users in your Dockerfiles for security.

Conclusion

Docker Compose simplifies local development by allowing developers to orchestrate multiple services with one configuration file. It’s perfect for replicating production setups locally without complex scripts or manual steps. Once you master Compose, managing microservices, databases, caches, and supporting services becomes effortless.

Start with a basic setup for your stack, add health checks and proper dependency handling, then gradually incorporate profiles for optional services and environment-specific overrides. The investment in proper containerization pays dividends in consistent environments and faster onboarding.

To learn how to deploy these containers at scale, check out Kubernetes 101: Deploying and Managing Containerised Apps. For detailed documentation, visit the Docker Compose documentation.

Leave a Comment