Skip to content

Docker Compose: Production Patterns and Best Practices

Learn production-ready Docker Compose patterns including environment management, custom images, and multi-service architectures

• 6 min read

You know how to run applications with Docker Compose. Now let’s learn patterns you’ll use in real projects.

Using .env Files for Configuration

Hardcoding passwords in docker-compose.yml is fine for learning. For real projects, use .env files.

Create .env in your project root:

# Database
POSTGRES_PASSWORD=mysecretpassword
POSTGRES_DB=myapp
POSTGRES_USER=postgres

# Application
NODE_ENV=development
APP_PORT=3000

# Redis
REDIS_PASSWORD=anothersecret

Update docker-compose.yml to use these variables:

services:
  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
    volumes:
      - postgres_data:/var/lib/postgresql/data

  web:
    image: node:18-alpine
    working_dir: /app
    volumes:
      - ./app:/app
    ports:
      - "${APP_PORT}:3000"
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      NODE_ENV: ${NODE_ENV}
    depends_on:
      db:
        condition: service_healthy

volumes:
  postgres_data:

Important: Add .env to your .gitignore:

.env
.env.local
.env.production

Commit a .env.example with dummy values instead:

POSTGRES_PASSWORD=changeme
POSTGRES_DB=myapp
POSTGRES_USER=postgres
NODE_ENV=development
APP_PORT=3000

Adding More Services: Redis Cache

Real applications often need caching. Let’s add Redis:

services:
  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    command: redis-server --requirepass ${REDIS_PASSWORD}
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

  web:
    image: node:18-alpine
    working_dir: /app
    volumes:
      - ./app:/app
    ports:
      - "${APP_PORT}:3000"
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
      NODE_ENV: ${NODE_ENV}
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    command: sh -c "npm install && npm start"

volumes:
  postgres_data:
  redis_data:

Update app/package.json to include Redis:

{
  "dependencies": {
    "express": "^4.18.2",
    "pg": "^8.11.0",
    "redis": "^4.6.0"
  }
}

Now your app can use both PostgreSQL and Redis, all starting with one command.

Building Custom Docker Images

Instead of using node:18-alpine and running npm install every time, create a custom image.

Create app/Dockerfile:

FROM node:18-alpine

WORKDIR /app

# Copy package files
COPY package*.json ./

# Install dependencies
RUN npm ci --only=production

# Copy application code
COPY . .

# Expose port
EXPOSE 3000

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"

# Start application
CMD ["node", "index.js"]

Update docker-compose.yml to build this image:

services:
  web:
    build:
      context: ./app
      dockerfile: Dockerfile
    ports:
      - "${APP_PORT}:3000"
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
      NODE_ENV: ${NODE_ENV}
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy

Build and run:

docker compose up --build

The --build flag rebuilds your custom image.

Separating Development and Production

Create two compose files:

docker-compose.yml (base configuration):

services:
  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

  web:
    build:
      context: ./app
      dockerfile: Dockerfile
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      NODE_ENV: ${NODE_ENV}
    depends_on:
      db:
        condition: service_healthy

volumes:
  postgres_data:

docker-compose.dev.yml (development overrides):

services:
  web:
    volumes:
      - ./app:/app
      - /app/node_modules
    ports:
      - "3000:3000"
    command: sh -c "npm install && npm run dev"
    environment:
      NODE_ENV: development

docker-compose.prod.yml (production overrides):

services:
  web:
    restart: always
    environment:
      NODE_ENV: production
    deploy:
      resources:
        limits:
          cpus: '0.50'
          memory: 512M

  db:
    restart: always

Run with:

# Development
docker compose -f docker-compose.yml -f docker-compose.dev.yml up

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

Or add scripts to package.json:

{
  "scripts": {
    "docker:dev": "docker compose -f docker-compose.yml -f docker-compose.dev.yml up",
    "docker:prod": "docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d"
  }
}

Common Multi-Service Patterns

Adding Nginx as Reverse Proxy

services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - web

  web:
    # Remove ports - only nginx needs to expose
    expose:
      - "3000"

Background Workers

services:
  web:
    build: ./app
    command: npm start
    ports:
      - "3000:3000"

  worker:
    build: ./app
    command: npm run worker
    # No ports needed - background process
    depends_on:
      - db
      - redis

Same code, different commands. Perfect for job queues or scheduled tasks.

Resource Limits

Prevent containers from using all system resources:

services:
  web:
    build: ./app
    deploy:
      resources:
        limits:
          cpus: '0.50'
          memory: 512M
        reservations:
          cpus: '0.25'
          memory: 256M

Logging Configuration

services:
  web:
    build: ./app
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

This prevents log files from filling your disk.

Essential Debugging Commands

# View logs for all services
docker compose logs

# View logs for specific service
docker compose logs web

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

# Execute commands in running containers
docker compose exec web sh
docker compose exec db psql -U postgres

# Rebuild everything from scratch
docker compose down -v
docker compose build --no-cache
docker compose up

# See what's using resources
docker compose ps
docker compose top

Security Best Practices

  1. Never commit secrets: Use .env files and add them to .gitignore
  2. Don’t run as root: Add USER node to Dockerfiles
  3. Keep images updated: Regularly rebuild with latest base images
  4. Scan for vulnerabilities: Use docker scout or similar tools
  5. Limit exposed ports: Only expose what’s necessary
  6. Use specific image tags: Not latest, use node:18-alpine or postgres:15

Performance Tips

Use .dockerignore

Create app/.dockerignore:

node_modules
npm-debug.log
.git
.env
.DS_Store
*.md
.vscode

This excludes unnecessary files from your Docker build context, making builds faster.

Multi-stage Builds

For smaller production images:

# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .

# Production stage
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/*.js ./

USER node
EXPOSE 3000
CMD ["node", "index.js"]

When Things Go Wrong

Container exits immediately:

docker compose logs web

Can’t connect to database:

# Check if database is healthy
docker compose ps

# Try connecting manually
docker compose exec db psql -U postgres

Changes not appearing:

# Rebuild the image
docker compose up --build

# Or force recreate
docker compose up --force-recreate

Port conflicts:

# See what's using the port
lsof -i :3000  # macOS/Linux
netstat -ano | findstr :3000  # Windows

What You’ve Accomplished

You started this series knowing nothing about Docker. Now you can:

  • Run multi-service applications with Docker Compose
  • Configure services with environment variables
  • Build custom Docker images
  • Separate development and production configurations
  • Add caching, reverse proxies, and background workers
  • Debug issues effectively
  • Apply security and performance best practices

Real-World Usage

This isn’t just for learning. Docker Compose is used for:

  • Local development: Run your entire stack locally
  • Testing: Spin up test environments in CI/CD
  • Staging environments: Deploy to staging servers
  • Small production deployments: Single-server applications

For larger production deployments, you’d graduate to Kubernetes or similar orchestration platforms. But the concepts you’ve learned—services, volumes, networks, environment variables—all translate.

Continue Learning

  • Docker Documentation: Official guides and references
  • Docker Compose Specification: All YAML options explained
  • Example Projects: Search GitHub for “docker-compose.yml” in your stack
  • Kubernetes: When you’re ready for container orchestration at scale

Final Thoughts

Docker Compose changes how you think about applications. Instead of “install these dependencies,” you think “describe the services I need.” Instead of “configure this manually,” you write it once in a file.

This is infrastructure as code. Reproducible, shareable, version-controlled.

You started as a beginner. Now you have skills that professional developers use daily. Keep building, keep experimenting, and keep learning.

Welcome to the world of containerized applications.

Share Twitter LinkedIn
That's the idea.
← All posts