Skip to content

Docker Compose in 2026: best practices that actually matter

Tuan Nguyen Duc Anh
Published date:
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