# 7 Docker Compose Patterns Every Developer Should Know

# 7 Docker Compose Patterns Every Developer Should Know

I'll never forget the day I deployed my first multi-container application. Five services, each with its own Dockerfile, environment variables scattered across sticky notes, and a deployment script that looked like it was written by someone who'd had too much coffee. It worked—barely—but maintaining it was a nightmare.

That's when I discovered Docker Compose patterns that changed everything. Not just the basic `docker-compose up` stuff, but real architectural patterns that make your containerized applications maintainable, scalable, and actually enjoyable to work with.

If you're tired of wrestling with container orchestration or want to level up from basic Docker usage, these seven patterns will transform how you build and deploy applications. I've used every single one in production, and I'm sharing the lessons I learned the hard way so you don't have to.

## Table of Contents

1. [The Multi-Stage Build Pattern](#1-the-multi-stage-build-pattern)
2. [The Shared Volume Pattern](#2-the-shared-volume-pattern)
3. [The Service Dependency Pattern](#3-the-service-dependency-pattern)
4. [The Environment-Specific Override Pattern](#4-the-environment-specific-override-pattern)
5. [The Health Check Pattern](#5-the-health-check-pattern)
6. [The Network Isolation Pattern](#6-the-network-isolation-pattern)
7. [The Secrets Management Pattern](#7-the-secrets-management-pattern)

## 1. The Multi-Stage Build Pattern

You know what's worse than a slow build? A bloated production image that's 2GB when it should be 200MB. I learned this the hard way when my Node.js application image ballooned to ridiculous sizes because I was including all my dev dependencies.

The multi-stage build pattern solves this by separating your build environment from your runtime environment.

### Why You Need This

- **Smaller images**: Cut your image size by 60-80%
- **Faster deployments**: Less data to transfer means quicker rollouts
- **Better security**: Fewer packages mean fewer vulnerabilities
- **Cleaner separation**: Build tools stay in build stage, not production

### Implementation

Here's how I structure a typical Node.js application with multi-stage builds:

```yaml
# docker-compose.yml
version: '3.8'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      target: production
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: production
```

```dockerfile
# Dockerfile
# Stage 1: Build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build

# Stage 2: Production
FROM node:18-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

# Stage 3: Development (optional)
FROM node:18-alpine AS development
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]
```

### Real-World Impact

| Metric | Before Multi-Stage | After Multi-Stage |
|--------|-------------------|-------------------|
| Image Size | 1.2 GB | 180 MB |
| Build Time | 4 min | 2.5 min |
| Deploy Time | 45 sec | 12 sec |
| Security Vulnerabilities | 47 | 8 |

## 2. The Shared Volume Pattern

I once spent three hours debugging why my application couldn't read uploaded files. Turns out, my web server and background worker were writing to different volumes. Face, meet palm.

The shared volume pattern ensures multiple services can access the same data reliably.

### When to Use This

- File uploads that need processing by multiple services
- Shared cache directories
- Log aggregation from multiple containers
- Static assets served by different services

### Implementation

```yaml
version: '3.8'

services:
  web:
    image: nginx:alpine
    volumes:
      - shared-data:/usr/share/nginx/html
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    ports:
      - "80:80"
    depends_on:
      - app

  app:
    build: ./app
    volumes:
      - shared-data:/app/public
      - uploads:/app/uploads
    environment:
      UPLOAD_DIR: /app/uploads

  worker:
    build: ./worker
    volumes:
      - uploads:/worker/process
      - processed:/worker/output
    environment:
      INPUT_DIR: /worker/process
      OUTPUT_DIR: /worker/output

volumes:
  shared-data:
    driver: local
  uploads:
    driver: local
  processed:
    driver: local
```

### Pro Tips from the Trenches

1. **Use named volumes**: They're easier to manage and persist across container restarts
2. **Set proper permissions**: Use `user` directives to avoid permission issues
3. **Consider volume drivers**: For production, look into network volume drivers like NFS or cloud-specific options
4. **Monitor volume size**: Implement cleanup strategies to prevent disk space issues

```yaml
# Example with proper permissions
services:
  app:
    build: ./app
    user: "1000:1000"  # Match your host user
    volumes:
      - uploads:/app/uploads:rw
```

## 3. The Service Dependency Pattern

Nothing's more frustrating than your application crashing because it tried to connect to a database that wasn't ready yet. I've seen this cause countless failed deployments.

The service dependency pattern ensures your services start in the correct order and wait for dependencies to be truly ready.

### The Problem with Basic depends_on

```yaml
# DON'T DO THIS - It's not enough!
services:
  app:
    depends_on:
      - db  # Only waits for container to start, not for DB to be ready
```

### The Right Way

```yaml
version: '3.8'

services:
  app:
    build: ./app
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    environment:
      DATABASE_URL: postgresql://user:pass@db:5432/mydb
      REDIS_URL: redis://redis:6379
    restart: on-failure

  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: mydb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    volumes:
      - postgres-data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    volumes:
      - redis-data:/data

volumes:
  postgres-data:
  redis-data:
```

### Application-Level Retry Logic

Even with health checks, I always add retry logic in my application:

```javascript
// Example Node.js connection with retry
const connectWithRetry = async (maxRetries = 5, delay = 5000) => {
  for (let i = 0; i < maxRetries; i++) {
    try {
      await db.connect();
      console.log('Database connected successfully');
      return;
    } catch (error) {
      console.log(`Connection attempt ${i + 1} failed. Retrying...`);
      if (i === maxRetries - 1) throw error;
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
};
```

## 4. The Environment-Specific Override Pattern

I used to maintain separate docker-compose files for dev, staging, and production. It was a maintenance nightmare—change one thing, update three files. Then I discovered override files.

### The Base Configuration

```yaml
# docker-compose.yml (base configuration)
version: '3.8'

services:
  app:
    build: .
    environment:
      NODE_ENV: production
    restart: unless-stopped

  db:
    image: postgres:15-alpine
    volumes:
      - db-data:/var/lib/postgresql/data

volumes:
  db-data:
```

### Development Override

```yaml
# docker-compose.override.yml (automatically loaded in dev)
version: '3.8'

services:
  app:
    build:
      target: development
    volumes:
      - .:/app
      - /app/node_modules
    environment:
      NODE_ENV: development
      DEBUG: "app:*"
    ports:
      - "3000:3000"
      - "9229:9229"  # Node.js debugger
    command: npm run dev

  db:
    ports:
      - "5432:5432"  # Expose for local tools
    environment:
      POSTGRES_PASSWORD: dev_password
```

### Production Override

```yaml
# docker-compose.prod.yml
version: '3.8'

services:
  app:
    image: myregistry.com/app:${VERSION}
    deploy:
      replicas: 3
      resources:
        limits:
          cpus: '0.5'
          memory: 512M
    environment:
      NODE_ENV: production
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

  db:
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

secrets:
  db_password:
    external: true
```

### Usage Commands

```bash
# Development (uses docker-compose.override.yml automatically)
docker-compose up

# Production
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d

# Staging
docker-compose -f docker-compose.yml -f docker-compose.staging.yml up -d
```

### Comparison Table

| Environment | Config Files | Hot Reload | Debug Ports | Resource Limits |
|-------------|-------------|------------|-------------|-----------------|
| Development | base + override | ✅ Yes | ✅ Exposed | ❌ None |
| Staging | base + staging | ❌ No | ⚠️ VPN Only | ⚠️ Moderate |
| Production | base + prod | ❌ No | ❌ Closed | ✅ Strict |

## 5. The Health Check Pattern

I once had a container that was "running" for three days but wasn't actually serving traffic. The container was up, the process was running, but the application was deadlocked. Health checks would've caught this immediately.

### Why Health Checks Matter

- **Automatic recovery**: Docker restarts unhealthy containers
- **Load balancer integration**: Only route traffic to healthy instances
- **Deployment safety**: Don't mark deployments complete until services are healthy
- **Early problem detection**: Catch issues before users do

### Comprehensive Health Check Implementation

```yaml
version: '3.8'

services:
  web:
    build: ./web
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s
    deploy:
      restart_policy:
        condition: on-failure
        delay: 5s
        max_attempts: 3

  api:
    build: ./api
    healthcheck:
      test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/api/health || exit 1"]
      interval: 20s
      timeout: 5s
      retries: 3
      start_period: 30s

  db:
    image: postgres:15-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

  rabbitmq:
    image: rabbitmq:3-management-alpine
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s
```

### Creating Effective Health Endpoints

Here's how I implement health checks in my applications:

```javascript
// Node.js/Express health endpoint
app.get('/health', async (req, res) => {
  const checks = {
    uptime: process.uptime(),
    timestamp: Date.now(),
    status: 'ok'
  };

  try {
    // Check database connection
    await db.query('SELECT 1');
    checks.database = 'connected';

    // Check Redis connection
    await redis.ping();
    checks.redis = 'connected';

    // Check critical dependencies
    checks.dependencies = 'ok';

    res.status(200).json(checks);
  } catch (error) {
    checks.status = 'error';
    checks.error = error.message;
    res.status(503).json(checks);
  }
});
```

### Health Check Best Practices

| Practice | Why It Matters | Example |
|----------|----------------|---------|
| Lightweight checks | Avoid overloading your service | Simple SELECT 1 queries |
| Appropriate intervals | Balance detection speed vs. overhead | 30s for web, 10s for databases |
| Generous start_period | Allow time for initialization | 40s+ for complex apps |
| Test dependencies | Ensure the whole stack is healthy | Check DB, cache, external APIs |
| Return proper status codes | Enable proper orchestration | 200 for healthy, 503 for unhealthy |

## 6. The Network Isolation Pattern

Security incident story time: A junior developer accidentally exposed our internal admin API to the internet because everything was on the default network. We caught it during a security audit, but it could've been catastrophic.

Network isolation is your first line of defense in container security.

### The Architecture

```yaml
version: '3.8'

services:
  # Public-facing services
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    networks:
      - frontend
      - backend
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro

  web:
    build: ./web
    networks:
      - frontend
    environment:
      API_URL: http://api:8080

  # Internal services (no external access)
  api:
    build: ./api
    networks:
      - backend
      - database
    environment:
      DATABASE_URL: postgresql://db:5432/mydb

  # Data layer (most restricted)
  db:
    image: postgres:15-alpine
    networks:
      - database
    volumes:
      - db-data:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

  # Background workers
  worker:
    build: ./worker
    networks:
      - backend
      - database
      - queue
    depends_on:
      - rabbitmq

  rabbitmq:
    image: rabbitmq:3-management-alpine
    networks:
      - queue
    volumes:
      - rabbitmq-data:/var/lib/rabbitmq

  # Admin tools (restricted network)
  adminer:
    image: adminer
    networks:
      - database
      - admin
    ports:
      - "127.0.0.1:8080:8080"  # Only accessible from localhost

networks:
  frontend:
    driver: bridge
    ipam:
      config:
        - subnet: 172.20.0.0/24
  backend:
    driver: bridge
    internal: false  # Can reach external services
  database:
    driver: bridge
    internal: true  # Cannot reach external services
  queue:
    driver: bridge
    internal: true
  admin:
    driver: bridge

volumes:
  db-data:
  rabbitmq-data:

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

### Network Isolation Levels

| Network | Services | External Access | Internet Access | Use Case |
|---------|----------|-----------------|-----------------|----------|
| frontend | nginx, web | ✅ Yes | ✅ Yes | Public-facing services |
| backend | api, worker | ❌ No | ✅ Yes | Internal APIs, external API calls |
| database | db, cache | ❌ No | ❌ No | Data persistence layer |
| queue | rabbitmq, worker | ❌ No | ❌ No | Message queuing |
| admin | admin tools | ⚠️ Localhost only | ✅ Yes | Management interfaces |

### Security Rules I Live By

1. **Default deny**: Start with internal networks, open only what's necessary
2. **Principle of least privilege**: Each service gets minimal network access
3. **No database exposure**: Never expose database ports to the internet
4. **Localhost binding**: Admin tools should bind to 127.0.0.1
5. **Network segmentation**: Separate concerns into different networks

### Testing Network Isolation

```bash
# Test that database is NOT accessible from web container
docker-compose exec web nc -zv db 5432
# Should fail or timeout

# Test that API CAN reach database
docker-compose exec api nc -zv db 5432
# Should succeed

# Verify no external access to internal networks
docker network inspect myapp_database | grep internal
# Should show "internal": true
```

## 7. The Secrets Management Pattern

I cringe when I see this in production code:

```yaml
# NEVER DO THIS!
environment:
  DATABASE_PASSWORD: super_secret_password_123
```

Secrets in plain text are a security disaster waiting to happen. Here's how to do it right.

### Docker Secrets (Swarm Mode)

```yaml
version: '3.8'

services:
  app:
    image: myapp:latest
    secrets:
      - db_password
      - api_key
      - jwt_secret
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password
      API_KEY_FILE: /run/secrets/api_key
      JWT_SECRET_FILE: /run/secrets/jwt_secret

  db:
    image: postgres:15-alpine
    secrets:
      - db_password
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password

secrets:
  db_password:
    external: true
  api_key:
    external: true
  jwt_secret:
    external: true
```

### Environment File Pattern (Development)

```yaml
# docker-compose.yml
version: '3.8'

services:
  app:
    build: .
    env_file:
      - .env
      - .env.local  # Git-ignored file for local secrets
    environment:
      NODE_ENV: development
```

```bash
# .env (committed to git)
NODE_ENV=development
LOG_LEVEL=debug
API_URL=http://api:8080

# .env.local (in .gitignore)
DATABASE_PASSWORD=local_dev_password
API_KEY=dev_api_key_12345
JWT_SECRET=dev_jwt_secret
```

### Vault Integration Pattern (Production)

For production, I use HashiCorp Vault or cloud provider secret managers:

```yaml
version: '3.8'

services:
  app:
    build: .
    environment:
      VAULT_ADDR: https://vault.example.com
      VAULT_TOKEN_FILE: /run/secrets/vault_token
