Tại sao cần Docker Compose?

Khi app của bạn có nhiều service (API, database, cache, worker, reverse proxy), chạy từng container bằng docker run sẽ gặp vài vấn đề:

  • Phải nhớ chính xác tất cả flag, port, volume, env cho từng container
  • Không có cách nào khai báo quan hệ giữa các service (API cần database chạy trước)
  • Mỗi lần khởi động lại phải gõ lại toàn bộ lệnh, đúng thứ tự

Docker Compose giải quyết những vấn đề này: khai báo toàn bộ stack multi-container trong một file YAML duy nhất, rồi khởi động tất cả bằng một lệnh duy nhất.


  graph LR
    N[Nginx :80] --> A[API :3000]
    A --> R[(Redis)]
    A --> P[(Postgres)]
    W[Worker] --> R
    W --> P

Bài này mình sẽ đi từ zero đến thành thạo Docker Compose cơ bản: cấu trúc file, service, network, volume, biến môi trường, và các lệnh CLI thường dùng.


docker-compose.yml: Cấu trúc tổng quan

Một file docker-compose.yml điển hình trông như thế này:

services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://postgres:secret@db:5432/myapp
    depends_on:
      - db
      - redis

  worker:
    build: ./worker
    environment:
      - REDIS_URL=redis://redis:6379

  db:
    image: postgres:16-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: secret

  redis:
    image: redis:7-alpine

volumes:
  pgdata:

Ba thành phần chính:

Thành phầnVai trò
servicesĐịnh nghĩa từng container (image, port, env, volume, network…)
networksTùy chỉnh mạng ảo giữa các service (không bắt buộc — Compose tự tạo mặc định)
volumesKhai báo named volume hoặc bind mount để lưu dữ liệu
Từ Compose v2 trở đi (Docker Compose plugin, dùng docker compose thay vì docker-compose), field version: ở top-level không còn cần thiết nữa. Nếu bạn đọc tài liệu cũ thấy version: "3.8", cứ bỏ đi — Compose tự động nhận diện và xử lý đúng phiên bản schema.

Service definition: Trái tim của Compose

Mỗi service là một container. Bạn khai báo nó chạy từ đâu, port nào, biến môi trường gì, mount volume gì, và restart policy ra sao.

image vs build

Có hai cách chỉ định source cho service:

# Dùng image có sẵn từ registry
redis:
  image: redis:7-alpine

# Build từ Dockerfile cục bộ
api:
  build:
    context: ./api # thư mục chứa Dockerfile
    dockerfile: Dockerfile.dev # tên Dockerfile (mặc định là Dockerfile)

Dùng image cho các service off-the-shelf (Redis, Postgres, Nginx). Dùng build cho code của team mình — nhất là trong môi trường dev, nơi bạn thay đổi code liên tục và cần rebuild nhanh.

Build context và cache: Mỗi service có build.context riêng. Compose gửi toàn bộ context directory lên Docker daemon (giống docker build). Nếu context của bạn chứa node_modules hay .git, thời gian build sẽ chậm đi đáng kể. Luôn dùng .dockerignore — mình đã bàn kỹ ở bài Dockerfile thực chiến — để loại bỏ những file không cần thiết khỏi build context.

ports

Cú pháp "HOST:CONTAINER":

ports:
  - "3000:3000" # host:container
  - "127.0.0.1:5432:5432" # chỉ bind localhost, không expose ra ngoài
Đừng map port database ra ngoài public network trong production. Compose file cho production nên bỏ hẳn ports với database, hoặc chỉ bind 127.0.0.1. Database chỉ cần accessible từ trong internal network của Compose là đủ.

volumes

Hai loại mount phổ biến:

services:
  db:
    volumes:
      - pgdata:/var/lib/postgresql/data # named volume (dữ liệu persist)

  api:
    volumes:
      - ./src:/app/src # bind mount (hot reload khi dev)
      - /app/node_modules # anonymous volume (giữ node_modules trong container)

volumes:
  pgdata: # khai báo named volume ở top-level
  • Named volume: Compose quản lý, dữ liệu sống sót qua docker compose down (trừ khi dùng -v).
  • Bind mount: Map trực tiếp thư mục host vào container. Lý tưởng cho dev vì code thay đổi trên host được reflect ngay trong container.
  • Anonymous volume: Volume không tên, thường dùng để “che” một thư mục trong container khỏi bị bind mount ghi đè (như /app/node_modules ở trên).

environment và env_file

services:
  api:
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgres://postgres:${DB_PASS}@db:5432/myapp # ${VAR} từ shell hoặc .env
    env_file:
      - .env.api # load biến từ file

Thứ tự ưu tiên: environment ghi đè env_file, biến shell ghi đè giá trị mặc định trong .env. Compose tự động đọc file .env trong cùng thư mục với docker-compose.yml.

depends_on

services:
  api:
    depends_on:
      - db
      - redis

depends_on đảm bảo thứ tự khởi động: dbredis start trước, api start sau. Nhưng — và đây là cái bẫy kinh điển — Compose không đợi service sẵn sàng (ready). Postgres có thể đã start container nhưng vẫn đang chạy init script, Redis có thể chưa listen port.

depends_on chỉ kiểm soát thứ tự start, không kiểm tra ready. API của bạn có thể crash vì connect DB quá sớm. Cách giải quyết: dùng healthcheck kết hợp với depends_on condition (Compose v2.1+) hoặc dùng script wait-for-it trong entrypoint.
services:
  db:
    image: postgres:16-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

  api:
    depends_on:
      db:
        condition: service_healthy # chỉ start khi db healthy

restart policy

services:
  api:
    restart: unless-stopped # luôn restart, trừ khi stop thủ công
  worker:
    restart: always # restart trong mọi trường hợp
  redis:
    restart: on-failure # chỉ restart nếu exit code != 0

Trong dev: thường không cần restart. Trong production: unless-stopped là lựa chọn an toàn nhất.


Networks: DNS tự động giữa các service

Khi bạn chạy docker compose up, Compose tự động tạo một network bridge riêng cho project. Tất cả service trong cùng Compose file tự động join network này — và điều tuyệt vời nhất: mỗi service có thể resolve service khác bằng chính tên service của nó.

# Không cần khai báo networks ở top-level — Compose tự làm
services:
  api:
    environment:
      - REDIS_URL=redis://redis:6379 # "redis" chính là tên service
      - DB_HOST=db # "db" chính là tên service

Không cần IP, không cần --link, không cần --network flag. Chỉ cần dùng tên service như hostname. Compose DNS resolver lo phần còn lại.

Nếu bạn muốn tạo network tùy chỉnh (ví dụ tách biệt frontend network và backend network):

networks:
  frontend:
  backend:

services:
  api:
    networks:
      - backend
  nginx:
    networks:
      - frontend
      - backend # Nginx nối cả 2 network để proxy đến API
  db:
    networks:
      - backend # DB chỉ nằm trong backend network, không expose ra frontend
Pattern tách network như trên rất phổ biến trong production Compose file: frontend services (Nginx, React app) nằm trong frontend network, backend services (API, DB, Redis) nằm trong backend network. Service nào cần bridge cả hai (như Nginx reverse proxy) thì join cả hai network.

Volumes: Dữ liệu không bay hơi

Named volume trong Compose được khai báo ở top-level volumes::

volumes:
  pgdata:
    driver: local # mặc định
  logs:
    driver: local
    driver_opts:
      type: none
      device: /mnt/data/logs # mount thư mục cụ thể trên host
      o: bind

Dữ liệu trong named volume sống sót qua docker compose down. Muốn xóa sạch:

docker compose down -v   # -v xóa cả anonymous và named volume

Cho dev: bind mount là lựa chọn số một vì bạn muốn code change được reflect ngay. Cho production: named volume hoặc external volume (gắn vào ổ đĩa cụ thể trên host) cho database, log, upload.


Biến môi trường và substitution

Compose hỗ trợ variable substitution với cú pháp ${VAR}:

services:
  api:
    image: myapp:${TAG:-latest} # dùng $TAG từ env, fallback "latest" nếu không có
    environment:
      - NODE_ENV=${ENV:-development}
      - DB_PASS=${DB_PASS:?err} # bắt buộc phải set, nếu không Compose báo lỗi

Compose đọc biến từ:

  1. Shell environment (export TAG=v2.0.1)
  2. File .env trong cùng thư mục với docker-compose.yml
  3. File chỉ định qua --env-file flag

Cú pháp fallback:

  • ${VAR:-default} — dùng default nếu VAR không set hoặc rỗng
  • ${VAR-default} — dùng default nếu VAR không set (nhưng vẫn dùng giá trị rỗng nếu set “”)
  • ${VAR:?err} — báo lỗi nếu VAR không set hoặc rỗng
  • ${VAR?err} — báo lỗi nếu VAR không set
.env vs env_file: File .env được Compose engine đọc để substitute ${VAR} trong chính docker-compose.yml. Còn env_file được inject vào container runtime — tương đương với --env-file flag của docker run. Đây là hai cơ chế khác nhau, đừng nhầm lẫn.

CLI commands: Những lệnh dùng hàng ngày

Khởi động và dừng

# Start tất cả service ở chế độ detached (background)
docker compose up -d

# Start và rebuild image nếu code thay đổi
docker compose up -d --build

# Start với file khác tên mặc định
docker compose -f docker-compose.dev.yml up -d

# Dừng và xóa containers, networks (giữ volumes)
docker compose down

# Dừng + xóa cả volumes
docker compose down -v

# Dừng + xóa images đã build
docker compose down --rmi all

Xem trạng thái

# Xem status tất cả service
docker compose ps

# Xem log (theo dõi real-time, chỉ service cụ thể)
docker compose logs -f api

# Xem log nhiều service
docker compose logs -f api worker

# Xem log 100 dòng cuối
docker compose logs --tail 100

Tương tác với service đang chạy

# Chạy lệnh trong service đang chạy
docker compose exec api sh
docker compose exec db psql -U postgres

# Chạy lệnh one-off (tạo container mới, không ảnh hưởng service đang chạy)
docker compose run --rm api npm run migrate

# Restart một service
docker compose restart worker

# Scale service (chỉ với Compose v2, không dùng port mapping cứng)
docker compose up -d --scale worker=3

Build

# Build lại image cho service có build context
docker compose build

# Build không cache
docker compose build --no-cache

# Build service cụ thể
docker compose build api
Trong dev, mình thường chạy docker compose up -d --build mỗi sáng — nó vừa rebuild code mới nhất vừa start lại service. Một lệnh, một lần gõ. Không cần nhớ xem hôm qua có thay đổi Dockerfile không.

Dev vs Production: Hai mindset, hai Compose file

Dev Compose — Tối ưu cho tốc độ phát triển

# docker-compose.dev.yml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "3000:3000"
    volumes:
      - ./src:/app/src # hot reload
      - /app/node_modules
    environment:
      - NODE_ENV=development
      - DEBUG=*
    command: npm run dev # nodemon / ts-node-dev với hot reload
  db:
    image: postgres:16-alpine
    ports:
      - "127.0.0.1:5432:5432" # dev cần connect trực tiếp bằng DB client
  redis:
    image: redis:7-alpine
    ports:
      - "127.0.0.1:6379:6379"

Đặc trưng của dev Compose:

  • Bind mount để code thay đổi được reflect ngay
  • Port mapping cho DB, Redis để connect bằng GUI client (DBeaver, RedisInsight)
  • Hot reload qua command override
  • Debug flag bật sẵn
  • Không cần restart policy

Production Compose — Tối ưu cho ổn định và bảo mật

# docker-compose.prod.yml
services:
  api:
    image: registry.example.com/myapp:${TAG} # pre-built image, không build tại chỗ
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - DB_PASS=${DB_PASS:?err}
    restart: unless-stopped
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  db:
    image: postgres:16-alpine
    # KHÔNG expose port ra ngoài
    volumes:
      - pgdata:/var/lib/postgresql/data
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD: ${DB_PASS:?err}

volumes:
  pgdata:
    driver: local

Đặc trưng của production Compose:

  • Pre-built image từ registry (không build:)
  • Không expose port cho database ra ngoài
  • Restart policy unless-stopped
  • Log rotation để tránh đầy disk
  • Secret qua biến môi trường với fallback bắt buộc ${VAR:?err}
  • Named volume với driver cụ thể
Pattern phổ biến: dùng docker-compose.yml làm base, rồi dùng docker-compose.override.yml cho dev, docker-compose.prod.yml cho production. Compose tự động merge override file. Khi chạy production: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d.

Ví dụ thực tế: Full-stack app với Compose

Dưới đây là Compose file cho một app web điển hình: Express API, React frontend, Postgres, Redis, và Nginx reverse proxy.

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

  api:
    build:
      context: ./backend
      dockerfile: Dockerfile
    environment:
      - DATABASE_URL=postgres://app:${DB_PASS}@db:5432/myapp
      - REDIS_URL=redis://redis:6379
      - NODE_ENV=production
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped

  worker:
    build:
      context: ./backend
      dockerfile: Dockerfile.worker
    environment:
      - REDIS_URL=redis://redis:6379
    depends_on:
      redis:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASS:?err}
      POSTGRES_DB: myapp
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    volumes:
      - redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

volumes:
  pgdata:
  redisdata:

Một lệnh duy nhất để khởi động toàn bộ stack:

docker compose up -d

So với việc mở 5 terminal và gõ 5 lệnh docker run, Compose đã tiết kiệm cho team mình hàng giờ debug “sao Redis không connect”, “sao port bị conflict”, “ủa container này chưa start à?”.


Tổng kết

Docker Compose là công cụ không thể thiếu khi làm việc với multi-container application. Ba điểm quan trọng nhất mình muốn bạn nhớ sau bài này:

  1. Một file, một lệnh — toàn bộ stack được khai báo trong docker-compose.yml, khởi động bằng docker compose up -d. Không còn cảnh copy-paste docker run với hàng tá flag.
  2. DNS tự động — service giao tiếp với nhau qua tên service, không cần IP, không cần --link. Compose network làm phần việc nặng nhọc cho bạn.
  3. Tách biệt dev/production — dev Compose ưu tiên bind mount và hot reload; production Compose ưu tiên pre-built image, restart policy, và log rotation.

Compose không thay thế Kubernetes hay các orchestrator phức tạp — nhưng với phần lớn dự án từ 1-10 service, nó hoàn toàn đủ dùng và đơn giản hơn nhiều.


Câu hỏi hay gặp

Q: Khi nào dùng docker compose up và khi nào dùng docker compose run?

up start tất cả service trong Compose file và giữ chúng chạy. run chạy một lệnh one-off trong một service (tạo container mới, không ảnh hưởng service đang chạy). Dùng run cho migration, seed data, hay chạy test. Ví dụ: docker compose run --rm api npm run migrate.

Q: Làm sao để rebuild image khi Dockerfile thay đổi?

Dùng docker compose build --no-cache để build lại từ đầu, hoặc docker compose up -d --build để rebuild và restart. Compose cache layer giống docker build, nên nếu Dockerfile không đổi, lần build sau sẽ rất nhanh.

Q: depends_on có đợi service ready không?

Không. depends_on chỉ đảm bảo thứ tự start container. Để đợi service ready (ví dụ Postgres accept connection), bạn cần thêm healthcheck và dùng condition: service_healthy trong depends_on.

Q: Tôi có thể chạy nhiều Compose file cùng lúc không?

Có. Compose tự động merge khi bạn truyền nhiều -f flag: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d. File sau ghi đè file trước. Pattern phổ biến: base file + override file cho từng môi trường.


Bài tiếp theo (Multi-container): Phần 10: Docker Compose nâng cao, profiles, extends, secrets, Compose v5 SDK, và dev/staging/prod parity.