Skip to content

Docker Compose in 2026: best practices that actually matter

Rahul Woll
Published date:
1 min read
Edit this post

docker-compose up is the first command you learn. What comes next β€” networking, secrets, healthchecks, profiles for different environments β€” is what separates a functional configuration from a production-ready one.

Table of contents

Open Table of contents

Clean base structure

name: my-app

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
      target: production # multi-stage target
    environment:
      NODE_ENV: production
    env_file: .env.production # never hardcode credentials
    ports:
      - "3000:3000"
    depends_on:
      db:
        condition: service_healthy # wait for DB to be ready
    restart: unless-stopped

  db:
    image: postgres:17-alpine
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

volumes:
  postgres_data:

secrets:
  db_password:
    file: ./secrets/db_password.txtcompose.yml

Multi-stage builds: fewer MBs, more security

A production Dockerfile should never include development tools:

# Stage 1: dependencies and build
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci                    # [!code highlight]
COPY . .
RUN npm run build

# Stage 2: minimal final image
FROM node:22-alpine AS production  # [!code ++]
WORKDIR /app                       # [!code ++]
# Only copy what's necessary       
COPY --from=builder /app/dist ./dist  
COPY --from=builder /app/node_modules ./node_modules  
USER node                          # do not run as root // [!code ++]
EXPOSE 3000
CMD ["node", "dist/server.js"]Dockerfile

The difference in size can be from 600 MB β†’ 80 MB.

Profiles for different environments

With profiles you can activate services based on context without maintaining multiple Compose files:

services:
  api:
    # no profile = always active
    build: .

  adminer:
    image: adminer
    profiles: [dev, debug] # only in dev
    ports:
      - "8080:8080"

  prometheus:
    image: prom/prometheus
    profiles: [monitoring] # only when you need it
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.ymlcompose.yml
# Only bring up API + DB
docker compose up

# Bring up with dev tools
docker compose --profile dev up

# Entire monitoring stack
docker compose --profile monitoring up

Healthchecks that actually work

The basic depends_on only waits for the container to start, not for the service to be ready. The difference matters:

services:
  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 10s # initial grace period

  worker:
    build: .
    depends_on:
      redis:
        condition: service_healthy # wait for green healthcheckcompose.yml

Networking: isolation by default

Every compose.yml creates its own network. To communicate separate stacks:

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true # no internet access

services:
  nginx:
    networks: [frontend, backend] # the only one touching both networks

  api:
    networks: [backend] # isolated from the outside

  db:
    networks: [backend] # dittocompose.yml

Checklist before production

The difference between a tutorial compose.yml and a production one is not in the number of lines β€” it’s in knowing what can fail and having accounted for it.

Previous
Vibe Coding: programming with AI at the speed of thought
Next
Modern CSS in 2026: container queries, :has() and anchor positioning