SSD Nodes Learn Hosting plans →
Hướng dẫn Matt ConnorBởi Matt Connor · Cập nhật ngày 2026-10-28

Docker Compose nhiều file: merge, override và include

Hiểu cách compose.override.yaml tự nạp, thứ tự file merge, bẫy ports khiến port vẫn mở và dùng include để tách cấu hình dev, prod rõ ràng.

Compose xử lý nhiều file như thế nào

Docker Compose có thể xây dựng một project từ nhiều file. Compose đọc các file theo đúng thứ tự nhận được, rồi gộp chúng thành một model duy nhất. Vì vậy, file đọc sau sẽ ghi đè mọi giá trị xung đột. Có 2 cơ chế thực hiện việc này từ command line: file override mà Compose tự động nạp và flag -f mà bạn tự truyền vào. Cơ chế thứ 3 nằm ngay trong file, là phần tử include, và hoạt động khác với cả 2 cơ chế trên.

Việc merge không chỉ là ghi đè toàn bộ. Các mapping được merge theo từng key, các sequence được nối thêm, còn một nhóm nhỏ field được thay thế toàn bộ. Sự khác biệt này gây ra các kết quả bất ngờ. Danh sách ports là phần khiến gần như mọi người đều gặp lỗi.

Toàn bộ nội dung dưới đây giả định bạn dùng Compose v2 trở lên, plugin docker compose thay vì script docker-compose cũ. Các bản release hiện tại được đánh số v5, và plugin hoạt động theo cùng cách. Chạy docker compose version để kiểm tra. Nếu bạn chưa viết file Compose, hãy bắt đầu với hướng dẫn cơ bản về Docker Compose rồi quay lại.

Compose tự động nạp override file

Chạy docker compose up mà không có flag -f, Compose sẽ tìm trong thư mục làm việc và các thư mục cha để tìm compose.yaml hoặc docker-compose.yaml. Nếu override file nằm cạnh file base, Compose sẽ tự nạp file đó sau file base.

ls compose.yaml compose.override.yaml
docker compose up -d

Khi cả hai file cùng tồn tại, kết quả giống như bạn tự nhập cả hai file theo thứ tự.

docker compose -f compose.yaml -f compose.override.yaml up -d

Các tên Compose nhận diện là compose.override.yaml, compose.override.yml, cùng với docker-compose.override.yml và docker-compose.override.yaml cũ hơn. Tên khác, chẳng hạn compose.dev.yaml, chỉ được nạp khi bạn chỉ rõ tên đó bằng -f.

Ngay khi bạn truyền một -f, Compose sẽ dừng việc tự động nạp file. docker compose -f compose.yaml up chỉ đọc đúng file đó và bỏ qua override file. Đây là thuộc tính mà mô hình dev và prod ở phần sau của hướng dẫn này dựa vào.

Điều này có thể gây hậu quả theo cả hai hướng trên server. Một file override để lại trong thư mục deploy sẽ được mọi lệnh docker compose không có tham số chạy từ thư mục đó nạp vào, kể cả lệnh do cron job chạy. Vì vậy, production stack có thể bind-mount một thư mục source mà không ai định đưa vào bản phát hành. Chạy docker compose config sau mỗi lần deploy và đọc kết quả. Khi deploy chạy tự động, bước kiểm tra chỉ hữu ích nếu có cơ chế báo cho bạn biết đã xảy ra lỗi. Đây là vai trò của một push channel như ntfy server tự host, nơi cron job hoặc một unit systemd OnFailure có thể gửi thông báo đến.

Thứ tự với -f và nơi các đường dẫn tương đối được phân giải

Compose dựng cấu hình theo thứ tự bạn cung cấp các file. Các file đứng sau sẽ ghi đè và bổ sung cho các file đứng trước. Từ trái sang phải, file cuối cùng sẽ được ưu tiên.

docker compose -f compose.yaml -f compose.prod.yaml config
docker compose -f compose.yaml -f compose.prod.yaml up -d

Mọi command trong project đó phải dùng cùng một danh sách file. Chạy up với 2 file và logs với 1 file nghĩa là bạn đang làm việc với một merged model khác. Đây là cách nhanh để gặp service mà Compose báo là không tồn tại. Rủi ro còn cao hơn với stack có các bước upgrade chạy bằng one-off command, chẳng hạn bước database migration trong một support desk Chatwoot tự host, khi docker compose run được chạy với danh sách file sai và âm thầm nhắm đến một model khác với model mà các service hiện đang sử dụng. Thay vào đó, đặt danh sách một lần bằng biến môi trường COMPOSE_FILE.

export COMPOSE_FILE=compose.yaml:compose.prod.yaml
docker compose config
docker compose up -d

Dấu phân cách là : trên Linux, và COMPOSE_PATH_SEPARATOR sẽ thay đổi dấu phân cách này. Bạn cũng có thể đặt COMPOSE_FILE trong file .env của project. Khi đó, thiết lập này nằm trong checkout thay vì trong lịch sử shell. Giá trị được đặt rõ ràng trên command line sẽ có ưu tiên cao hơn biến môi trường.

Bây giờ là quy tắc dễ làm hỏng bind mount. Khi dùng nhiều file với -f, mọi đường dẫn tương đối trong tất cả các file đều được phân giải dựa trên thư mục của file đầu tiên, không phải dựa trên file chứa đường dẫn đó. Nếu bạn ghi ./data:/var/lib/postgresql/data trong deploy/prod/compose.prod.yaml, Compose vẫn tìm ./data bên cạnh file base. Sau đó Docker tạo một thư mục rỗng tại đường dẫn sai này và container khởi động mà không có dữ liệu bên trong. Điều này trông giống như mất dữ liệu nhưng thực tế không phải vậy. Truyền --project-directory để tự đặt đường dẫn base, hoặc dùng include để mỗi file được phân giải dựa trên thư mục riêng của nó.

Tên project cũng được lấy từ thư mục base đó. Vì vậy, thay đổi file nào đứng đầu có thể đổi tên project. Project bị đổi tên sẽ tạo tên container và tên volume mới. Volume cũ vẫn còn trên disk với tên cũ. Để cố định tên, đặt name: ở cấp cao nhất trong file base.

name: myapp

Trường nào được merge và trường nào bị thay thế

Compose merge theo kiểu dữ liệu của value, không theo tên field.

  • Các field chỉ có một value sẽ bị thay thế. image, command, entrypoint và mem_limit nhận hoàn toàn value ở file sau. Bạn không thể thêm một argument vào command, vì file override sẽ ghi đè toàn bộ dòng này.
  • Các mapping được merge theo từng key. environment, labels, volumes và devices giữ lại mọi key từ cả hai file. Nếu cùng một key xuất hiện trong cả hai file, file sau sẽ được ưu tiên. Với environment và labels, key là tên variable hoặc label. Với volumes và devices, key là container path.
  • Các sequence được nối thêm. dns, dns_search, expose, tmpfs và external_links được nối lại. Một base chứa expose: ["3000"] khi merge với override chứa ["4000", "5000"] sẽ tạo ra ["3000", "4000", "5000"].

Bốn sequence có identity key, vì vậy các entry trùng key sẽ được merge thay vì nối thêm. volumes, secrets và configs đối chiếu theo target. ports đối chiếu theo tổ hợp ip, target, published và protocol.

Hãy đọc kỹ quy tắc ports này, vì đây là chỗ dễ mắc lỗi. Hai port entry chỉ được xem là cùng một entry khi cả bốn phần đó đều giống nhau. Chỉ cần thay đổi một phần, Compose sẽ xem đó là port thứ hai, không liên quan, nên giữ lại cả hai.

Vì sao port của bạn vẫn được publish sau khi dùng override

File base publish một service trên mọi interface:

services:
  web:
    image: nginx:1.27
    ports:
      - "8080:80"

File override bind service chỉ vào localhost vì reverse proxy sẽ đứng phía trước:

services:
  web:
    ports:
      - "127.0.0.1:8080:80"

Hãy kiểm tra kết quả trước khi cho rằng cấu hình đã có hiệu lực.

docker compose -f compose.yaml -f compose.prod.yaml config

Cả hai entry đều xuất hiện trong output. Phần ip khác nhau: 0.0.0.0 so với 127.0.0.1. Vì vậy, đối với quá trình merge, đây là hai port khác nhau, và binding public mà bạn cố xóa vẫn còn trong model. Điều này đặc biệt đáng chú ý với Docker, vì port đã publish được ghi vào iptables trước các firewall rule của bạn. Cơ chế này được giải thích trong vì sao port Docker đã publish có thể đi qua ufw.

Có 2 cách xử lý. Cách rõ ràng là dùng tag !override. Tag này thay thế toàn bộ attribute và bỏ qua các quy tắc merge:

services:
  web:
    ports: !override
      - "127.0.0.1:8080:80"

!override yêu cầu Compose v2.24.4 trở lên. Cách portable không cần tag: không đặt ports trong file base, mà chỉ khai báo nó trong các file dành cho từng environment. Không có gì để merge thì không có gì bị lộ. Đây là pattern được dùng trong ví dụ hoàn chỉnh bên dưới.

Xóa một giá trị do file cơ sở đặt

!reset xóa một thuộc tính, đưa thuộc tính đó về giá trị mặc định hoặc null. Nó nhận một giá trị rồi bỏ qua giá trị đó, vì vậy hãy ghi một giá trị hợp lệ nhưng rỗng.

services:
  web:
    ports: !reset []
    environment:
      DEBUG: !reset null

!reset hoạt động trên Compose v2.18.0 trở lên, tức là đã có từ vài bản phát hành trước khi !override xuất hiện. Hãy dùng nó khi bạn không được chỉnh sửa file cơ sở, chẳng hạn một fragment của nhà cung cấp mà bạn pull về. Một stack được upstream phát hành là đúng trường hợp này: file Compose phía sau một workspace AFFiNE tự host khai báo 4 container mà bạn không viết, còn !reset cho phép bạn xóa một thuộc tính trên một container mà không cần fork file và tự gánh việc theo dõi các thay đổi của file đó.

include, dùng để ghép các stack từ nhiều phần

include kéo một ứng dụng Compose khác vào model của bạn. Đây là phần tử cấp cao nhất, không phải flag.

include:
  - path: ../commons/compose.yaml

Mỗi đường dẫn trong include được tải như một model ứng dụng Compose riêng, với project directory riêng. Vì vậy, các đường dẫn tương đối trong file đó được phân giải dựa trên thư mục riêng của file đó. Đây là điểm khác biệt thực sự so với -f, và là lý do include phù hợp khi fragment nằm trong một thư mục hoặc repository khác. Đây là cấu trúc thường gặp của một stack do vendor cung cấp mà bạn không tự viết: file Compose nhiều service phía sau một cài đặt Authentik SSO tự host có thể nằm trong thư mục riêng với các đường dẫn tương đối được giữ nguyên, còn file của bạn chỉ chứa các service của bạn.

Dạng đầy đủ có các tùy chọn con.

include:
  - path:
      - ../monitoring/compose.yaml
      - ../monitoring/compose.vps.yaml
    project_directory: ../monitoring
    env_file: ../monitoring/.env

path nhận một list. Các file đó được merge theo các rule thông thường trước khi kết quả được đưa vào model của bạn. project_directory đặt đường dẫn cơ sở dùng để phân giải các đường dẫn tương đối trong file được include. env_file chỉ định file mà file Compose được include dùng để lấy các giá trị mặc định cho interpolation. Mặc định, đó là .env trong project directory riêng của file đó. Environment của project cục bộ vẫn được ưu tiên hơn, nhờ vậy bạn có thể override một biến của nó mà không cần sửa fragment. include yêu cầu Compose v2.20.0 hoặc mới hơn. Các tùy chọn tương tự cũng phù hợp với một add-on chạy một container được thêm vào stack bạn đang chạy, chẳng hạn Halcyon, công cụ biến giao diện thư viện Jellyfin thành một cửa hàng cho thuê băng đĩa thập niên 90: file của nó giữ image tag riêng và env_file riêng, nên khi upgrade, bạn không cần sửa file chứa media stack.

Tài liệu Docker nói rằng nếu một resource name được khai báo trong cả file của bạn và file được include thì sẽ bị từ chối do conflict. Loader trong release hiện tại không làm như vậy: tính đến Compose v5.6.1, được kiểm tra với source của loader đi kèm release vào ngày 2026-10-27, nó merge hai định nghĩa, trong đó file của bạn thắng ở mọi giá trị conflict, thay vì dừng với error. Vì vậy, đừng dựa vào việc tên trùng sẽ luôn fail rõ ràng. Để thay đổi một khai báo trong file được include, hãy đặt thay đổi vào compose.override.yaml: override được áp dụng lên model đã ghép, nên thay đổi này rõ ràng và không phụ thuộc vào cách một release cụ thể xử lý xung đột. Thói quen này đặc biệt hữu ích với stack có file upstream bị viết lại trong mỗi release, chẳng hạn các photo server nhiều container được phân tích trong PhotoPrism và Immich, nơi binding vào localhost hoặc volume bổ sung nên nằm trong override thay vì file sẽ bị thay thế ở lần upgrade tiếp theo.

Tóm lại: include ghép các application riêng biệt, còn -f xếp configuration lên một application.

Tách môi trường dev và prod trên một VPS

Đây là toàn bộ mô hình trong 3 file. File base khai báo những cấu hình đúng ở mọi môi trường và không publish cổng nào.

name: myapp

services:
  app:
    image: ghcr.io/example/app:1.4.2
    environment:
      DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db:5432/app
      LOG_LEVEL: info
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

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

volumes:
  db_data:

Điều kiện depends_on khiến app chờ database thực sự phản hồi, thay vì chỉ chờ một container tồn tại. Nội dung này được giải thích trong healthcheck và điều kiện depends_on. POSTGRES_PASSWORD được nội suy từ file .env của project; file này không bao giờ được đưa vào git. Xem file env và Compose secrets để biết các biến thể an toàn hơn.

Tiếp theo là compose.override.yaml, file mà Compose tự động load. Đây là file dành cho developer.

services:
  app:
    build: .
    command: npm run dev
    environment:
      LOG_LEVEL: debug
    ports:
      - "3000:3000"
    volumes:
      - ./src:/app/src

  db:
    ports:
      - "127.0.0.1:5432:5432"

Trên laptop, lệnh docker compose up không có tham số sẽ merge 2 file đó. command thay thế giá trị mặc định của image vì đây là giá trị đơn. LOG_LEVEL thay thế info vì environment merge theo key. Bind mount và 2 cổng được publish chỉ được thêm vào. Cổng database bind vào localhost để laptop trong mạng dùng chung không expose PostgreSQL cho những máy khác.

Cuối cùng là compose.prod.yaml. Tên file này không nằm trong danh sách file mà Compose tìm kiếm, nên nó không bao giờ được load ngoài ý muốn.

services:
  app:
    ports:
      - "127.0.0.1:8000:3000"
    deploy:
      resources:
        limits:
          memory: 512M

Trên VPS, bạn chỉ định cả 2 file. Việc chỉ định này chính là yếu tố loại file override khỏi quá trình load.

docker compose -f compose.yaml -f compose.prod.yaml config
docker compose -f compose.yaml -f compose.prod.yaml up -d
docker compose -f compose.yaml -f compose.prod.yaml ps

ps phải liệt kê cả 2 service đang chạy, trong đó db hiển thị (healthy). Vì bạn đã truyền -f nên compose.override.yaml không được đọc. Do đó command dành cho dev, source bind mount và cổng public 3000 không thể tác động đến production, dù file vẫn nằm trong cùng thư mục. Cổng 8000 chỉ bind vào localhost và sẵn sàng cho proxy. Khi thêm service thứ hai, xem chạy nhiều app phía sau Traefik.

Đặt COMPOSE_FILE=compose.yaml:compose.prod.yaml trong .env của server. Sau đó, các command còn lại của bạn có thể tiếp tục dùng dạng docker compose logs -f app thông thường.

Stack chỉ có một service cũng có cùng cấu trúc. Một trình theo dõi bài tập openGym tự host phải phản hồi qua TLS phía sau proxy trước khi bạn đăng ký passkey đầu tiên. File base không có ports sẽ ngăn một binding public vô tình chiếm quyền xử lý trước proxy.

Đọc model đã merge trước khi deploy

docker compose config in ra model đã merge và nội suy đầy đủ. Đây không phải bản xem trước. Đây là input chính xác mà Compose sẽ sử dụng, vì vậy nếu output khác với dự kiến của bạn thì output mới là kết quả đúng.

docker compose -f compose.yaml -f compose.prod.yaml config
docker compose -f compose.yaml -f compose.prod.yaml config --no-interpolate
docker compose -f compose.yaml -f compose.prod.yaml config --services

--no-interpolate giữ nguyên ${VAR}, không mở rộng biến này. Hãy dùng lệnh này trước khi dán output ở bất kỳ đâu, vì config thông thường sẽ in mọi secret đã resolve dưới dạng clear text. --services chỉ liệt kê tên các service. Đây là cách nhanh để xác nhận một include đã kéo vào đúng những gì bạn mong đợi.

Các lỗi thường gặp và những gì bạn sẽ thấy

no configuration file provided: not found. Compose không tìm thấy file nào để đọc. Bạn đang ở ngoài thư mục project, hoặc COMPOSE_FILE trỏ đến một path không tồn tại. Compose tìm file base mặc định trong các thư mục cha, nhưng không tự tìm ở bất kỳ đâu đối với file bạn tự chỉ định.

WARN[0000] The "POSTGRES_PASSWORD" variable is not set. Defaulting to a blank string. Interpolation được phân giải dựa trên file .env của project và môi trường shell. Thư mục project ở đây là thư mục chứa file -f đầu tiên. Deploy từ một thư mục khác với thư mục chứa .env sẽ tạo ra cảnh báo này, sau đó database sẽ từ chối mọi kết nối.

Thay đổi trong file override không xuất hiện trong docker compose config. Bạn có thể đã truyền -f, khiến Compose tắt việc tự động load file override, hoặc Compose đã tìm thấy compose.yaml trong một thư mục cha còn file override của bạn không nằm cùng thư mục với file đó. Chạy docker compose config không kèm tham số nào sẽ cho biết model mà Compose thực sự đang build.

Bind mount trống và Docker tạo một thư mục mà bạn không yêu cầu. Path tương đối được phân giải dựa trên thư mục của file đầu tiên. Sửa path, truyền --project-directory, hoặc chuyển fragment vào sau include.

Container khởi động lại với tên mới và một volume có vẻ trống. Tên project đã thay đổi vì tên project được xác định theo thư mục của file đầu tiên. Thêm name: ở cấp cao nhất vào file base để tên không tiếp tục thay đổi. Volume cũ vẫn tồn tại dưới prefix cũ, và docker volume ls sẽ hiển thị volume đó.

Một port đã xóa trong file override vẫn đang mở. Thao tác merge của ports đã nối thêm thay vì thay thế. Xác nhận bằng docker compose config, sau đó dùng !override hoặc chuyển ports ra khỏi file base.

FAQ

Compose có tự động nạp compose.override.yaml không?

Có, khi bạn chạy docker compose mà không dùng cờ -f. Compose tìm trong thư mục làm việc và các thư mục cha để tìm compose.yaml hoặc docker-compose.yaml. Nếu có file override nằm cùng thư mục, file đó sẽ được nạp sau. Các tên được nhận diện là compose.override.yaml, compose.override.yml, docker-compose.override.yml và docker-compose.override.yaml. Dùng bất kỳ -f nào sẽ tắt cơ chế này, vì vậy docker compose -f compose.yaml up chỉ đọc một file.

Nhiều file -f được hợp nhất theo thứ tự nào?

Từ trái sang phải. Compose xây dựng cấu hình theo thứ tự bạn cung cấp các file. Mỗi file ghi đè và bổ sung cho các file đứng trước, vì vậy file cuối cùng trên dòng lệnh sẽ thắng khi có xung đột. Phải dùng cùng danh sách này cho mọi command trong project đó. COMPOSE_FILE=compose.yaml:compose.prod.yaml dùng cho mục đích này.

Tại sao port vẫn được publish sau khi tôi ghi đè?

Vì các mục ports được nhận diện dựa trên toàn bộ tập ip, target, published và protocol. Ghi đè 127.0.0.1:8080:80 trên giá trị gốc 8080:80 làm phần ip khác đi, nên Compose xem đó là một port thứ hai và giữ lại cả hai. Chạy docker compose config để thấy hai mục này. Dùng ports: !override trên Compose v2.24.4 trở lên, hoặc không khai báo ports trong file gốc để không có giá trị tương ứng cần hợp nhất.

Sự khác nhau giữa include và -f là gì?

-f ghép nhiều file vào một application. Mọi đường dẫn tương đối trong các file đều được phân giải dựa trên thư mục của file đầu tiên. include đưa vào một Compose application riêng. Mỗi đường dẫn được include giữ project directory riêng, nên các đường dẫn tương đối của nó được phân giải dựa trên chính application đó. Dùng -f cho các lớp cấu hình môi trường của stack do bạn quản lý. Dùng include cho fragment được duy trì ở nơi khác. include cần Compose v2.20.0 trở lên.

Làm thế nào để xóa một giá trị do file gốc thiết lập?

Dùng tag !reset trên Compose v2.18.0 trở lên. Trong file override, viết ports: !reset [] hoặc MY_VAR: !reset null để đưa attribute về giá trị mặc định hoặc null. Giá trị cung cấp cho tag là bắt buộc nhưng sẽ bị bỏ qua. Nếu muốn thay thế một attribute thay vì xóa nó, !override thực hiện việc đó và cần v2.24.4 trở lên.

Bài viết này có hữu ích không?