SSD Nodes Learn Hosting plans →
Hướng dẫn Matt ConnorBởi Matt Connor · Cập nhật ngày 2026-08-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.

Nhiều file hoạt động như thế nào trong Compose

Docker Compose có thể xây dựng một project từ nhiều file. Compose đọc các file theo thứ tự nhận được rồi gộp chúng thành một model duy nhất. Vì vậy, file đứng sau sẽ thắng ở mọi giá trị bị xung đột. Có 2 cơ chế thực hiện việc này từ command line: file override mà Compose tự động tải 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ỉ đơn giản là ghi đè. 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 bị thay thế toàn bộ. Những khác biệt này là nguyên nhân 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 nhầm.

Toàn bộ nội dung dưới đây giả định bạn dùng Compose v2, tức plugin docker compose, không phải script docker-compose cũ. Chạy docker compose version để kiểm tra. Nếu bạn chưa viết Compose file, 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.ymldocker-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, entrypointmem_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, volumesdevices 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 environmentlabels, key là tên variable hoặc label. Với volumesdevices, key là container path.
  • Các sequence được nối thêm. dns, dns_search, expose, tmpfsexternal_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, secretsconfigs đối chiếu theo target. ports đối chiếu theo tổ hợp ip, target, publishedprotocol.

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 base thiết lập

!reset xóa một thuộc tính, đưa thuộc tính đó về giá trị mặc định hoặc null. Lệnh này 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ệ và rỗng.

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

!reset yêu cầu Compose v2.24 trở lên. Dùng lệnh này khi bạn không thể chỉnh sửa file base, chẳng hạn một fragment của vendor được kéo vào. Một stack upstream được publish là trường hợp điển hình: file Compose phía sau workspace AFFiNE tự host khai báo 4 container mà bạn không viết, còn !reset cho phép xóa một thuộc tính trên một container mà không cần fork file và phải tự theo dõi các thay đổi của file đó.

include, dùng để ghép stack từ nhiều thành phần

include đưa 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 thành một model ứng dụng Compose riêng, với thư mục project riêng. Vì vậy, các đường dẫn tương đối bên trong file đó được phân giải theo chính thư mục 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 thư mục khác hoặc repository khác. Đây là dạng thường gặp của vendor stack mà bạn không tự viết: file Compose nhiều service phía sau cài đặt Authentik SSO tự host có thể nằm trong thư mục riêng và giữ nguyên các đường dẫn tương đối của nó, còn file của bạn chỉ chứa các service của bạn.

Dạng đầy đủ nhận 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 danh sách, và 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 cấp cho file được include một bộ biến riêng để interpolation, nhờ đó fragment dùng chung không âm thầm đọc .env của project bạn. include cần Compose v2.20.0 trở lên. Các tùy chọn này cũng phù hợp với một add-on chạy trong một container duy nhất cho stack bạn đang vận hành, chẳng hạn Halcyon, ứng dụng đổi 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 việc nâng cấp không bao giờ buộc bạn sửa file chứa media stack.

Nếu file của bạn và file được include có tên resource trùng nhau, Compose sẽ báo lỗi thay vì âm thầm merge. Đây là hành vi có chủ ý. Để thay đổi nội dung do file được include khai báo, hãy đặt thay đổi trong compose.override.yaml: override được áp dụng lên model đã ghép, nên có thể chỉnh các resource được include mà không gây xung đột. Cách này đặc biệt hữu ích với stack có file upstream bị viết lại sau mỗi release, chẳng hạn các photo server nhiều container được so sánh trong PhotoPrism và Immich, nơi binding vào localhost hoặc volume bổ sung nên nằm trong override thay vì trong file sẽ bị thay thế ở lần nâng cấp tiếp theo.

Tóm lại: include ghép các ứng dụng riêng biệt, còn -f xếp thêm cấu hình lên một ứng dụng.

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ế infoenvironment 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 có 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.ymldocker-compose.override.yaml. Truyền 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 merge theo thứ tự nào?

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

Tại sao port của tôi vẫn được publish sau khi override?

Vì các entry ports được nhận diện bằng toàn bộ các thành phần ip, target, publishedprotocol. Việc override 127.0.0.1:8080:80 trên base là 8080:80 khác ở phần ip. Vì vậy Compose coi đó là port thứ hai và giữ lại cả hai. Chạy docker compose config, bạn sẽ thấy 2 entry này. Dùng ports: !override trên Compose v2.24.4 trở lên, hoặc không khai báo ports trong base file để không có giá trị nào được merge với nó.

include khác -f như thế nào?

-f xếp nhiều file thành một application. Các relative path trong mọi file đều được resolve theo thư mục của file đầu tiên. include đưa vào một Compose application riêng. Mỗi path được include giữ project directory riêng, nên relative path được resolve theo chính application đó. Dùng -f cho các environment layer 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ị mà base file đã đặt?

Dùng tag !reset trên Compose v2.24 trở lên. Trong file override, ghi 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.