SSD Nodes Learn Hosting plans →
Hướng dẫn Matt ConnorBởi Matt Connor

Docker Compose trên VPS Ubuntu 24.04 từ A đến Z

Cài Docker Engine và Compose plugin trên Ubuntu 24.04, chạy Miniflux cùng PostgreSQL, xử lý bẫy ufw khi publish port và backup named volume đúng cách.

Bạn sẽ xây dựng gì

Docker Compose là nền tảng cho gần như mọi thứ khác trên site này. Nextcloud, Vaultwarden, n8n, Immich, Rocket.Chat, mọi guide đó đều bắt đầu bằng câu “viết file compose này”, và đây là trang giải thích file đó thực sự có ý nghĩa gì. Bạn sẽ cài Docker Engine và Compose plugin từ apt repository chính thức của Docker trên Ubuntu 24.04, sau đó chạy một stack gồm 2 service thực tế: Miniflux, một RSS reader nhỏ, cùng PostgreSQL. Cặp này bao quát mọi pattern mà các app lớn hơn sử dụng: image được pin version, database có healthcheck, named volume, secrets trong file .env và một port chỉ publish trên localhost.

Việc cài đặt mất 5 phút. Phần còn lại của guide này tập trung vào những vấn đề thường gây lỗi về sau: docker group thực chất vẫn có quyền như root, các port được publish có thể đi thẳng qua rule của ufw, và một flag trên docker compose down có thể xóa database mà không hiển thị prompt xác nhận.

Điều kiện cần: một Ubuntu 24.04 KVM VPS mới, user có quyền sudo và ít nhất 1 gigabyte RAM. Nếu đã cài Docker thì vẫn được; phần đầu tiên sẽ hướng dẫn cách gỡ bản cài hiện có.

Cài từ repository của Docker, không phải của Ubuntu

Trước command đầu tiên, cần tránh 2 lựa chọn sai. Gói docker.io của Ubuntu vẫn hoạt động, nhưng chậm hơn các bản phát hành của Docker và thiếu layout plugin mà các thành phần khác đều mặc định sử dụng. Còn command docker-compose có dấu gạch nối là hướng mà các tutorial cũ thường dẫn bạn đi theo. Trên server cũ, command đó thường là Compose v1, được viết bằng Python, đã được Docker tuyên bố hết vòng đời và là nguyên nhân khiến các tutorial đó không còn hoạt động. Docker vẫn phát hành binary docker-compose độc lập được build từ code Go hiện tại, nhưng trang cài đặt của Docker ghi rõ đây là cách không được khuyến nghị và chỉ được hỗ trợ để tương thích ngược. Compose hiện nay là docker compose có dấu cách, một CLI plugin được cài từ cùng repository với engine.

Nếu bất kỳ thành phần nào trong số đó đã có trên máy, hãy gỡ trước, bao gồm cả docker-compose-v2 và docker-buildx, là các gói plugin của Ubuntu, để mọi thành phần đều đến từ cùng một repository:

sudo apt remove -y docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc

Package 'docker.io' is not installed, so not removed là output thông thường trên một VPS mới. Sau đó thêm repository của Docker và cài đặt:

sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Xác minh cả 3 lớp:

docker --version
docker compose version
sudo docker run --rm hello-world

2 lệnh đầu in ra version string. Docker Compose version v5.x.x xác nhận bạn đang dùng plugin, không phải binary v1 đã hết vòng đời. Plugin đã nhảy từ v2 lên thẳng v5 vào tháng 12 năm 2025, bỏ qua 3 và 4 để tránh nhầm với các version format của compose file cũ. Vì vậy, các hướng dẫn hiển thị v2.x.x ở đây đang nói đến cùng một tool. Lệnh chạy hello-world phải kết thúc bằng Hello from Docker!. Package sẽ enable service khi boot; systemctl is-enabled docker in ra enabled.

Nhóm docker là root, hãy cân nhắc rõ ràng trước khi sử dụng

Hiện tại, mọi lệnh docker đều cần sudo, vì socket của daemon tại /var/run/docker.sock thuộc sở hữu của root và nhóm docker. Nếu không là thành viên của nhóm này, bạn sẽ gặp lỗi Docker được tìm kiếm nhiều nhất:

permission denied while trying to connect to the Docker daemon socket at
unix:///var/run/docker.sock

Cách sửa tiêu chuẩn:

sudo usermod -aG docker $USER

Quyền thành viên nhóm được áp dụng khi đăng nhập, nên lỗi vẫn xuất hiện trong shell hiện tại. Chạy newgrp docker cho phiên này, hoặc đăng xuất rồi đăng nhập lại; khi đó id sẽ liệt kê docker trong các nhóm của bạn.

Nói thẳng phần quan trọng: thành viên của nhóm docker có quyền root trên host. Không phải “gần giống root”, không phải “được nâng quyền”, mà là root. Bất kỳ ai thuộc nhóm này đều có thể chạy docker run --rm -it -v /:/host alpine chroot /host và chiếm quyền kiểm soát toàn bộ filesystem mà không cần nhập mật khẩu. Nhóm này tồn tại để thuận tiện, không phải để cô lập quyền.

Rootless mode của Docker là lựa chọn thay thế thực sự; daemon tự chạy dưới user không có đặc quyền của bạn. Cách này có chi phí: các cổng dưới 1024 cần thiết lập thêm, networking chạy qua một userspace shim với overhead có thể đo được, và một số image hoạt động không đúng nếu không có root thực sự. Trên một VPS chỉ có một admin, trong đó tài khoản đăng nhập duy nhất đã có sudo, nhóm này thực tế không thay đổi gì. Đây là cách mà mọi hướng dẫn ở đây giả định, nhưng đừng cấp quyền nhóm này như thể nó ít quyền hơn sudo.

Cấu trúc của một file Compose

Tạo một thư mục riêng cho mỗi stack. Tên thư mục sẽ trở thành project name và được thêm vào trước tên container, network và volume:

sudo mkdir -p /opt/miniflux && sudo chown $USER /opt/miniflux && cd /opt/miniflux

Tạo compose.yml (tên hiện đại; docker-compose.yml vẫn hoạt động). Bỏ qua key version: cũ vì nó đã lỗi thời, và Compose sẽ cảnh báo khi gặp key này.

services:
  miniflux:
    image: miniflux/miniflux:2.2.9
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:8080"
    environment:
      - DATABASE_URL=postgres://miniflux:${POSTGRES_PASSWORD}@db/miniflux?sslmode=disable
      - RUN_MIGRATIONS=1
      - CREATE_ADMIN=1
      - ADMIN_USERNAME=admin
      - ADMIN_PASSWORD=${ADMIN_PASSWORD}
    depends_on:
      db:
        condition: service_healthy

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

volumes:
  db-data:

Mỗi dòng ở trên đều thể hiện một quyết định. Hãy xem xét từng dòng một.

Ghim version của image, :latest kèm pull là một lần nâng cấp không giám sát

Dùng postgres:16-alpine, không dùng postgres:latest. Tag không bị cố định: :latest sẽ được phân giải lại thành version mà maintainer vừa push gần đây nhất mỗi lần bạn pull. Kết hợp với thói quen nâng cấp định kỳ mà bạn sắp học, docker compose pull && docker compose up -d, thì :latest có nghĩa là các lần nhảy major version sẽ xảy ra ngay khi upstream phát hành, không phải khi bạn chọn. Với PostgreSQL, đây không phải tình huống giả định: việc nhảy bất ngờ từ 16 lên 17 khiến container crash-loop vì data directory không tương thích. PostgreSQL major upgrade cần dump và restore, không phải restart.

Ít nhất hãy ghim major version (postgres:16-alpine sẽ nhận các patch release của 16.x), và ghim application vào một release chính xác như miniflux/miniflux:2.2.9. Hãy kiểm tra trang releases của project và dùng version hiện tại tại thời điểm bạn viết file. Khi đó, một lần nâng cấp chỉ là một dòng bạn chủ động sửa, và thay đổi này hiển thị trong git diff.

Publish vào 127.0.0.1 vì Docker đi vòng qua ufw

"127.0.0.1:8080:8080" gồm host address và host port, container port. Hầu hết tutorial viết "8080:8080". Đây là cách viết tắt của 0.0.0.0:8080:8080: service lắng nghe trên mọi interface, kể cả interface public.

Đây là bẫy mà gần như ai cũng gặp ít nhất một lần. Docker publish một port bằng cách ghi một rule DNAT để đổi destination của packet thành IP nội bộ của container trước khi filtering diễn ra. Vì vậy packet đi theo đường FORWARD và không bao giờ đi qua INPUT, nơi các rule ufw của bạn được áp dụng. sudo ufw deny 8080 báo thành công, ufw status hiển thị port bị deny, nhưng service vẫn trả lời toàn bộ Internet. Firewall của bạn không bị hỏng; thiết kế của Docker đang bypass nó. Vì sao Docker bypass ufw và cách filter container traffic đúng cách giải thích cơ chế này và cách sửa DOCKER-USER cho các port bắt buộc phải public.

Thói quen giúp loại bỏ hoàn toàn vấn đề này là bind các port đã publish vào 127.0.0.1, trừ khi bạn có lý do cụ thể để làm khác. Với mọi service cần public, hãy đặt một reverse proxy ở phía trước. Đó chính xác là cách hướng dẫn reverse proxy Traefik xây dựng bước tiếp theo sau trang này: một container quản lý các port 80 và 443, định tuyến đến mọi service khác theo hostname và bật TLS. Nếu bạn đang dùng Traefik v2, hướng dẫn migration từ Traefik v2 sang v3 giải thích các thay đổi về tên và rule.

Kiểm tra bind sau khi start stack: sudo ss -tlnp | grep 8080 phải hiển thị 127.0.0.1:8080, không phải 0.0.0.0:8080 hoặc *:8080.

Named volume và bind mount

db-data:/var/lib/postgresql/data là một named volume. Docker tạo và quản lý một directory bên dưới /var/lib/docker/volumes/ rồi mount directory đó vào container. Cách còn lại là bind mount, ./data:/var/lib/postgresql/data, ánh xạ một path do bạn chọn trên host.

Cách phân chia phù hợp trong thực tế là: dùng named volume cho dữ liệu chỉ được container truy cập, đặc biệt là database, vì Docker khởi tạo volume với ownership mà image yêu cầu và permissions sẽ hoạt động đúng. Dùng bind mount cho các file bạn thao tác từ host, như file config chỉnh bằng text editor, media library được rsync vào host, hoặc mọi dữ liệu mà bạn muốn path hiển thị rõ ràng. Lỗi bind mount kinh điển là ownership: image chạy process bằng một UID cố định (image Miniflux chạy bằng UID 65534, postgres:16-alpine chuyển xuống UID 70), trong khi directory trên host thuộc UID 1000. App sẽ dừng khi startup và ghi permission denied vào log. Named volume phần lớn loại bỏ nhóm lỗi này, đổi lại dữ liệu nằm tại một path do Docker quản lý, được đề cập bên dưới.

environment và .env, không đưa secret vào git

Compose điền giá trị cho ${POSTGRES_PASSWORD} từ file có tên .env nằm cạnh compose.yml, nhưng chỉ khi shell của bạn chưa định nghĩa biến đó. Giá trị được export trong shell environment sẽ được ưu tiên hơn file .env. Vì vậy, một export POSTGRES_PASSWORD=... bị bỏ quên từ lần thử trước có thể âm thầm ghi đè giá trị trong file, còn docker compose config bên dưới sẽ hiển thị giá trị nào đã được chọn. Tạo file:

cat > .env <<'EOF'
POSTGRES_PASSWORD=change-me-to-something-long
ADMIN_PASSWORD=change-me-too
EOF
chmod 600 .env
echo ".env" >> .gitignore

Tạo giá trị thật bằng openssl rand -hex 24. Dùng hex thay vì base64 có chủ ý: password này nằm trong DATABASE_URL connection string, còn các ký tự /, + và = mà base64 tạo ra sẽ làm hỏng việc parse URL. Lỗi chỉ xuất hiện dưới dạng authentication error, không phải syntax error, và có thể khiến bạn mất cả buổi tối. Dòng .gitignore phải được thêm trước commit đầu tiên: compose file an toàn để publish và version, còn file .env thì không bao giờ được publish. Một secret đã xuất hiện trong git history là một secret cần được rotate. Nếu bạn start stack khi thiếu một biến, Compose sẽ cảnh báo rõ ràng nhưng vẫn tiếp tục với empty string. Với Postgres password, điều này tạo ra một deployment bị hỏng:

WARN[0000] The "POSTGRES_PASSWORD" variable is not set. Defaulting to a blank string.

docker compose config in ra file sau khi đã interpolate đầy đủ. Đây là cách nhanh nhất để kiểm tra các container thực tế sẽ nhận giá trị nào. Hãy nhớ output có chứa secret của bạn.

depends_on không chờ gì cả nếu bạn không thêm healthcheck

Một depends_on: [db] đơn giản chỉ kiểm soát thứ tự start: Compose launch Postgres trước rồi launch app sau đó một lúc, trong khi Postgres vẫn cần thêm vài giây mới accept connection. App kết nối đến database, thất bại rồi crash hoặc retry, tùy chất lượng implementation của app.

Cách đáng tin cậy là cấu hình trong file ở trên: service db định nghĩa một healthcheck (Postgres có sẵn pg_isready cho đúng mục đích này), còn app khai báo depends_on cùng với condition: service_healthy. Compose start database, poll check mỗi 10 giây, và chỉ start Miniflux sau khi check pass. Nếu database không bao giờ chuyển sang trạng thái healthy, chẳng hạn do password sai hoặc volume bị corrupt, app sẽ không start và Compose cho biết dependency nào thất bại:

dependency failed to start: container miniflux-db-1 is unhealthy

Thông báo đó hướng bạn đến docker compose logs db, nơi chứa lỗi thực sự.

restart: unless-stopped

restart: unless-stopped trên cả hai service có nghĩa là container sẽ chạy lại sau crash và sau khi VPS reboot, nhưng vẫn dừng nếu bạn chủ động chạy docker compose stop. Tùy chọn always còn lại sẽ khởi động lại container ngay cả sau khi bạn stop thủ công, thường không phải điều bạn muốn. Nếu không có restart policy, một lần reboot lúc 4 a.m. sau kernel update sẽ âm thầm làm các service dừng cho đến khi bạn phát hiện ra.

Các lệnh dùng hằng ngày

Các thao tác hằng ngày chỉ cần 5 lệnh, chạy từ thư mục dự án.

docker compose up -d        # create and start; idempotent, recreates only what changed
docker compose ps           # status, ports, and health of this project's containers
docker compose logs -f miniflux   # follow one service's logs; --tail 100 for recent history
docker compose pull && docker compose up -d   # upgrade to the pinned tags
docker compose down         # stop and remove containers and the network

up -d an toàn khi chạy nhiều lần. Lệnh này so sánh file với trạng thái thực tế và chỉ tác động đến những service có config hoặc image đã thay đổi. Cặp lệnh upgrade sẽ tải phiên bản mà các tag đã pin hiện trỏ đến: các bản patch trong phạm vi postgres:16-alpine; với pin chính xác thì không có gì được tải cho đến khi bạn sửa pin, và đó chính là mục đích. Image cũ sẽ tích tụ sau các lần upgrade; dùng docker image prune -f để thu hồi dung lượng đĩa.

Bây giờ là lệnh có tính phá hủy, cần nhấn mạnh: docker compose down an toàn vì container và network có thể tạo lại, còn dữ liệu nằm trong volume. docker compose down -v cũng xóa các volume được đặt tên. Khi đó database của bạn sẽ biến mất ngay lập tức, không có prompt xác nhận và không thể hoàn tác. Flag -v dùng để xóa các môi trường thử nghiệm; với stack đang chứa dữ liệu thật, hãy xem nó nguy hiểm như rm -rf. /var/lib/docker/volumes/ không có thùng rác.

Để mở shell dùng một lần bên trong container đang chạy: docker compose exec db psql -U miniflux đưa bạn vào database, còn docker compose exec miniflux sh mở shell trong app.

Dữ liệu của bạn thực sự nằm ở đâu

Named volume nhận tiền tố của project, vì vậy db-data trong một thư mục có tên miniflux sẽ trở thành miniflux_db-data:

docker volume ls
docker volume inspect miniflux_db-data

Kết quả inspect có dòng cần chú ý:

"Mountpoint": "/var/lib/docker/volumes/miniflux_db-data/_data"

Thư mục đó là nơi lưu database, thuộc sở hữu của root trên filesystem của host. Dữ liệu vẫn tồn tại sau down, các lần nâng cấp và việc rebuild container. Đây cũng chính xác là phần mà backup của bạn phải sao lưu.

Sao lưu named volume

Mẫu chuẩn là dùng một container tạm thời. Container này mount volume ở chế độ chỉ đọc cùng với một thư mục trên host, rồi dùng tar để sao chép dữ liệu:

docker run --rm \
  -v miniflux_db-data:/data:ro \
  -v "$PWD":/backup \
  alpine:3.22 tar czf /backup/miniflux-db-$(date +%F).tar.gz -C /data .

Không cần cài thêm gì và không để lại tiến trình nào đang chạy. Khôi phục là thao tác ngược lại: tar xzf vào một volume mới, đang trống, với các mount được đảo ngược.

Có một điểm cần lưu ý với database: dùng tar trên thư mục dữ liệu Postgres đang chạy có thể chụp phải trạng thái đang ghi dở. Database khi đó có thể không khởi động sạch. Bạn có thể docker compose stop trong vài giây cần để tar chạy xong. Tốt hơn là tạo logical dump, vì cách này bảo đảm tính nhất quán ngay từ đầu:

docker compose exec -T db pg_dump -U miniflux miniflux | gzip > miniflux-$(date +%F).sql.gz

-T tắt pseudo-terminal mà Compose mặc định cấp phát. Việc truyền output của dump qua TTY có thể làm hỏng dữ liệu. Hãy đặt một trong các lệnh này vào cron và copy kết quả ra khỏi VPS. Bản sao lưu nằm trên cùng disk với dữ liệu mà nó bảo vệ chỉ là bản sao, không phải backup. Hướng dẫn Nextcloud xây dựng một quy trình chạy theo lịch đầy đủ dựa chính xác trên 2 mẫu này.

Các dạng lỗi và thông báo bạn sẽ gặp

permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock, bạn chưa nằm trong nhóm docker, hoặc bạn đã được thêm vào nhưng phiên làm việc hiện tại chưa cập nhật. id hiển thị các nhóm hiệu dụng của bạn; newgrp docker sửa lỗi cho shell hiện tại, việc đăng xuất và đăng nhập lại sẽ sửa lỗi cho toàn bộ hệ thống.

Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?, một vấn đề khác: daemon đang dừng. sudo systemctl status docker và sudo journalctl -u docker -n 50 cho biết lý do. Trên VPS, nguyên nhân kinh điển là đầy ổ cứng, hãy kiểm tra bằng df -h /var/lib/docker trước.

Bind for 127.0.0.1:8080 failed: port is already allocated, một container khác đã chiếm cổng host đó. docker ps cho biết container nào; một container cũ từ đợt thử nghiệm docker run vài tuần trước thường là thủ phạm. Nếu docker ps trống, một tiến trình không phải Docker đang giữ cổng đó: sudo ss -tlnp | grep 8080 sẽ chỉ ra tên tiến trình.

yaml: line 14: did not find expected key, lỗi thụt đầu dòng tại hoặc ngay phía trên dòng được chỉ định. Các file Compose là YAML: thụt đầu dòng hai khoảng trắng, chỉ dùng khoảng trắng, và ký tự tab ở bất kỳ đâu cũng gây lỗi. docker compose config kiểm tra tính hợp lệ của file mà không khởi chạy bất cứ thứ gì, việc chạy lệnh này sau mỗi lần chỉnh sửa là một thói quen tốt.

Bất ngờ từ ufw không in ra bất kỳ lỗi nào, đó là lý do nó nguy hiểm: việc deploy thành công, ufw status trông có vẻ đúng, nhưng một lần quét cổng từ bên ngoài vẫn tìm thấy database của bạn. Hãy đọc lại phần cổng ở trên, kiểm tra mọi mục ports: xem có thiếu tiền tố 127.0.0.1: không, và xác nhận từ một máy tính khác bằng curl http://your-vps-ip:8080, "connection refused" là kết quả bạn muốn thấy.

Từ đây, hướng dẫn Traefik sẽ biến stack đơn lẻ này thành nhiều ứng dụng nằm sau một điểm truy cập HTTPS, và những thứ đáng để tự host trong năm 2026 là danh sách các ứng dụng bạn có thể chạy qua nó. Khi đã có vài stack chạy và mỗi stack lại có form đăng nhập riêng, một server SSO tự host như Authentik sẽ gộp chúng lại thành một tài khoản duy nhất nằm sau cùng proxy đó.

Một game server như Minecraft server trên VPS là một dự án Compose đầu tay thân thiện để thực hành. Nếu bạn muốn học trên thứ gì đó bạn mở hàng ngày, openGym, một trình theo dõi tập luyện tự host là một stack nhỏ được ghim vào một git tag thay vì image tag, và nó yêu cầu TLS phía trước trước khi bạn đăng ký passkey đầu tiên. Ảnh thường là thứ đầu tiên mọi người muốn lấy lại từ cloud của người khác, và so sánh PhotoPrism và Immich sẽ xác định mức RAM tối thiểu và quy trình backup mà bạn phải cam kết trước khi gắn volume cho bất kỳ ứng dụng nào. Khi hai dịch vụ không còn là đủ, triển khai AFFiNE như một không gian làm việc kiểu Notion sẽ áp dụng các mô hình tương tự với bốn container, và là bài kiểm tra công bằng xem liệu các thói quen về ghim tag, healthcheck và named volume ở trên đã trở thành phản xạ của bạn hay chưa.

FAQ

Vì sao tôi nhận được lỗi "permission denied while trying to connect to the Docker daemon socket"?

User của bạn chưa thuộc group docker, hoặc được thêm vào group sau khi session hiện tại bắt đầu. Membership chỉ có hiệu lực khi đăng nhập. Chạy sudo usermod -aG docker $USER, sau đó chạy newgrp docker hoặc đăng xuất rồi đăng nhập lại, và xác nhận bằng id. Group này cấp quyền tương đương root trên host, vì vậy chỉ thêm những user mà bạn sẵn sàng cấp quyền sudo.

docker compose down có xóa dữ liệu không?

docker compose down thông thường không xóa dữ liệu. Lệnh này xóa container và network của project; named volume vẫn còn và lần chạy up -d tiếp theo sẽ attach lại chúng. docker compose down -v là dạng destructive: lệnh này xóa named volume, tức là xóa database của bạn, không hỏi xác nhận và không thể undo. Không bao giờ chạy -v trên stack có dữ liệu thật nếu bạn chưa có backup đã xác minh.

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

docker-compose có dấu gạch nối là cách gọi Compose cũ. Trên server cũ, đây thường là Compose v1, bản gốc viết bằng Python mà Docker đã tuyên bố hết vòng đời. Docker vẫn phát hành binary docker-compose độc lập được build từ code Go hiện tại, nhưng ghi rõ rằng cách cài đặt này không được khuyến nghị và chỉ được hỗ trợ để tương thích ngược. Vì vậy, không dùng cách này trên server mới. docker compose có dấu cách là Compose hiện tại, từ v2 trở đi và được đánh số v5 từ tháng 12 năm 2025. Đây là plugin Go cho Docker CLI, được cài dưới dạng docker-compose-plugin từ apt repository của Docker. Command và YAML gần như tương thích hoàn toàn. Vì vậy, khi tutorial cũ ghi docker-compose up, hãy nhập docker compose up.

Vì sao tôi vẫn truy cập được Docker container từ Internet dù ufw chặn cổng?

Vì Docker publish port bằng các rule DNAT trong chain PREROUTING của iptables. Các packet sau khi được rewrite đi theo path FORWARD qua các chain riêng của Docker, nên không đi qua chain INPUT, nơi các rule của ufw được áp dụng. Vì vậy, ufw deny 8080 không có tác dụng với port đã publish của container. Hãy xử lý từ nguồn: publish vào 127.0.0.1: và expose service thông qua reverse proxy.

Nên dùng named volume hay bind mount?

Dùng named volume cho dữ liệu chỉ container truy cập, đặc biệt là database, vì Docker sẽ đặt ownership theo yêu cầu của image và permission thường hoạt động ngay. Dùng bind mount cho các file mà bạn cũng xử lý từ host: config bạn chỉnh sửa, media bạn upload và mọi thứ mà bạn muốn path hiển thị rõ ràng. Nếu container fail khi khởi động với permission denied trên bind mount, việc đầu tiên cần kiểm tra là UID giữa host và container có bị lệch hay không.