SSD Nodes Learn
Hướng dẫn Matt ConnorBởi Matt Connor · Cập nhật ngày 2026-07-23

Cách dùng Docker Compose trên VPS Ubuntu 24.04

Hướng dẫn cài Docker Engine và Compose v2 trên Ubuntu 24.04. Cách viết file compose.yml, xử lý lỗi ufw và backup volume an toàn cho VPS của bạn.

Những gì bạn đang xây dựng

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

Quá trình cài đặt mất năm phút. Phần còn lại của hướng dẫn này sẽ đề cập đến những lỗi gây đau đầu về sau: group docker có quyền root dưới một cái tên khác, các port được publish đi xuyên qua các rule của ufw, và một flag trên docker compose down có thể xóa database của bạn mà không cần thông báo xác nhận.

Điều kiện tiên quyết: một VPS KVM Ubuntu 24.04 mới, một user có quyền sudo, và tối thiểu 1GB RAM. Nếu đã cài Docker sẵn cũng không sao — phần đầu tiên sẽ hướng dẫn cách gỡ bỏ.

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

Hãy từ chối hai sai lầm sau trước khi chạy lệnh đầu tiên. Package docker.io của chính Ubuntu có hoạt động, nhưng nó lỗi thời so với các bản release của Docker và thiếu cấu trúc plugin mà mọi thứ khác yêu cầu. Còn binary docker-compose standalone — loại có dấu gạch ngang — là Compose v1: chạy bằng Python, đã hết vòng đời (end-of-life) từ năm 2023, và là lý do khiến các hướng dẫn cũ bị lỗi. Compose hiện nay là docker compose (có dấu cách), một CLI plugin, được cài đặt từ cùng một repository với engine.

Nếu bất kỳ thứ gì trong số đó đã có trên máy, hãy xóa trước — bao gồm cả docker-compose-v2, bản đóng gói plugin của Ubuntu, để mọi thứ đều đến từ một repository duy nhất:

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

Package 'docker.io' is not installed, so not removed là output bình thường trên một VPS mới. Sau đó, hãy 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ả ba lớp:

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

Hai lệnh đầu tiên sẽ in ra version string — Docker Compose version v2.x.x xác nhận bạn đã có plugin, chứ không phải binary v1 đã lỗi thời. Lệnh hello-world nên kết thúc bằng Hello from Docker!. Package này sẽ tự động enable service khi boot; systemctl is-enabled docker sẽ in ra enabled.

Group docker là root — hãy cân nhắc kỹ

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à group docker. Nếu không có quyền thành viên, bạn sẽ gặp lỗi Docker phổ biến nhất trên Google:

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 group chỉ có hiệu lực khi login, nên lỗi vẫn sẽ còn trong shell hiện tại. Hãy chạy newgrp docker cho session này, hoặc logout rồi login lại; id sau đó sẽ liệt kê docker trong các group của bạn.

Bây giờ là phần thành thật, nói thẳng: thành viên của group docker có quyền root trên host. Không phải "gần giống root", không phải "quyền nâng cao" — mà là root. Bất kỳ ai trong group đó đều có thể chạy docker run --rm -it -v /:/host alpine chroot /host và chiếm toàn bộ filesystem mà không cần mật khẩu. Group này tồn tại để tiện lợi, không phải để giới hạn quyền.

Chế độ rootless của Docker là một lựa chọn thay thế thực sự — bản thân daemon sẽ chạy dưới quyền user không có đặc quyền của bạn. Tuy nhiên, nó có cái giá của nó: các port dưới 1024 cần thiết lập thêm, networking chạy qua một shim ở userspace gây tốn tài nguyên, và một số image sẽ hoạt động sai nếu không có quyền root thật. Trên một VPS chỉ có một admin và user đó đã có sudo, việc dùng group này không thay đổi gì về mặt thực tế, và đây là điều mà mọi hướng dẫn ở đây đều mặc định — chỉ là đừng bao giờ cấp quyền này như thể nó nhẹ hơn sudo.

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

Hãy để mỗi stack có một thư mục riêng — tên thư mục sẽ trở thành tên project, dùng làm tiền tố cho các container, network và volume:

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

Tạo compose.yml (tên gọi hiện đại; docker-compose.yml vẫn dùng được). Bỏ qua key version: cũ — nó đã lỗi thời và Compose sẽ cảnh báo nếu thấy nó.

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 là một quyết định. Hãy thực hiện từng cái một.

Pin version của image — dùng :latest cộng với việc pull là nâng cấp không kiểm soát

Dùng postgres:16-alpine, không dùng postgres:latest. Một tag không phải là cố định: :latest sẽ luôn resolve về bản mới nhất mà maintainer vừa push mỗi khi bạn pull. Kết hợp điều đó 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 — và :latest có nghĩa là các bản nhảy version lớn sẽ xuất hiện bất cứ khi nào upstream phát hành, chứ không phải khi bạn chọn. Với PostgreSQL, điều này không phải là giả thuyết: một cú nhảy bất ngờ từ 16 lên 17 sẽ khiến container bị crash-loop do không tương thích thư mục data, vì nâng cấp major version của Postgres yêu cầu phải dump và restore, chứ không chỉ là restart.

Hãy pin ít nhất là major version (postgres:16-alpine theo sau các bản patch 16.x), và pin các ứng dụng theo một release chính xác như miniflux/miniflux:2.2.9 — hãy kiểm tra trang release của project và dùng bản mới nhất tại thời điểm bạn viết file. Khi đó, việc nâng cấp sẽ trở thành một dòng chỉnh sửa có chủ đích, có thể thấy rõ trong git diff.

Publish tới 127.0.0.1, vì Docker sẽ đi vòng qua ufw

"127.0.0.1:8080:8080" — host address, host port, container port. Hầu hết các hướng dẫn viết "8080:8080", là cách viết tắt của 0.0.0.0:8080:8080: lắng nghe trên mọi interface, bao gồm cả interface công khai.

Đây là cái bẫy, và nó sẽ khiến hầu hết mọi người dính lỗi một lần. Docker publish một port bằng cách viết một rule DNAT để ghi đè destination của packet tới IP nội bộ của container trước khi thực hiện filtering, vì vậy packet đi theo đường FORWARD và không bao giờ chạm tới INPUT, nơi các rule ufw của bạn tồn tại. sudo ufw deny 8080 báo thành công, ufw status báo port bị từ chối, nhưng service vẫn trả lời được toàn bộ internet. Firewall của bạn không hỏng; nó đang bị bypass theo thiết kế. Tại sao Docker bypass ufw, và cách lọc traffic container thực sự sẽ giải thích cơ chế này và cách fix bằng DOCKER-USER cho các port cần public.

Thói quen giúp vấn đề này biến mất: hãy bind các port đã publish vào 127.0.0.1 trừ khi bạn có lý do cụ thể, và đặt một reverse proxy phía trước cho bất kỳ thứ gì cần tiếp xúc với thế giới. Đó chính xác là những gì hướng dẫn reverse proxy Traefik xây dựng ở bước tiếp theo sau trang này — một container sở hữu port 80 và 443 và route tới mọi thứ khác bằng hostname, có TLS. (Nếu bạn đang dùng setup Traefik v2 cũ? hướng dẫn migration từ Traefik v2 sang v3 sẽ đề cập đến các thay đổi về tên và rule.)

Xác minh việc bind sau khi chạy stack: sudo ss -tlnp | grep 8080 nên hiển thị 127.0.0.1:8080, chứ không phải 0.0.0.0:8080 hay *:8080.

Named volumes so với bind mounts

db-data:/var/lib/postgresql/data là một named volume: Docker tạo và quản lý một thư mục dưới /var/lib/docker/volumes/ và mount nó vào container. Lựa chọn thay thế là bind mount, ./data:/var/lib/postgresql/data, ánh xạ một đường dẫn bạn đã chọn trên host.

Sự phân chia thực tế: named volumes chỉ dành cho data mà container chạm vào — trên hết là database, vì Docker khởi tạo volume với ownership mà image yêu cầu và file permission sẽ hoạt động chuẩn. Bind mounts dành cho các file mà bạn thao tác từ host — các file config bạn sửa bằng text editor, một media library bạn rsync vào, bất cứ thứ gì bạn muốn đường dẫn của nó phải rõ ràng. Lỗi bind-mount điển hình là ownership: container chạy với UID 999, thư mục host của bạn thuộc sở hữu của UID 1000, và app sẽ chết khi khởi động với lỗi permission denied trong log. Named volumes giúp loại bỏ hầu hết các lỗi này, đổi lại là dữ liệu nằm tại một đường dẫn do Docker quản lý — chi tiết ở dưới.

environment và .env — giữ secrets tránh xa git

${POSTGRES_PASSWORD} không được đọc từ shell của bạn; Compose sẽ lấy giá trị từ một file tên là .env nằm cạnh compose.yml. Hãy tạo nó:

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

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

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

docker compose config in ra file đã được interpolate đầy đủ — cách nhanh nhất để kiểm tra những gì container thực sự sẽ nhận được; hãy nhớ output của nó bao gồm cả secrets của bạn.

depends_on không đợi gì cả — trừ khi bạn thêm healthcheck

Một lệnh depends_on: [db] trần chỉ kiểm soát thứ tự khởi động: Compose chạy Postgres trước và app ngay sau đó một lát, trong khi Postgres vẫn còn vài giây nữa mới chấp nhận kết nối. App sẽ gọi database, thất bại, và crash hoặc retry tùy vào việc nó được viết tốt đến mức nào.

Phiên bản đáng tin cậy là những gì file trên sử dụng: service db định nghĩa một healthcheck (Postgres có sẵn pg_isready cho việc này), và app khai báo depends_on với condition: service_healthy. Compose khởi động database, kiểm tra (poll) check mỗi 10 giây, và chỉ khởi động Miniflux khi check pass. Nếu database không bao giờ ở trạng thái healthy — do sai mật khẩu, volume lỗi — app sẽ không bao giờ khởi động và Compose sẽ báo cho bạn biết dependency nào bị lỗi:

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

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

restart: unless-stopped

restart: unless-stopped cho cả hai service có nghĩa là các container sẽ tự khởi động lại sau khi crash hoặc sau khi VPS reboot, nhưng sẽ giữ trạng thái stop nếu bạn chủ động chạy docker compose stop. Lựa chọn thay thế always sẽ hồi sinh container ngay cả sau khi đã stop thủ công — điều này hiếm khi là ý muốn của bạn. Nếu không có restart policy, một đợt reboot để update kernel lúc 4 giờ sáng sẽ âm thầm làm sập các service của bạn cho đến khi bạn nhận ra.

Các lệnh hàng ngày

Mọi việc hàng ngày chỉ gồm 5 lệnh, chạy từ thư mục project.

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 rất an toàn để chạy lặp lại — nó so sánh file với thực tế và chỉ tác động đến các service có config hoặc image thay đổi. Cặp lệnh upgrade sẽ lấy bất kỳ thứ gì mà các tagged version đang trỏ tới: các bản patch dưới postgres:16-alpine, không có gì thay đổi cho đến khi bạn edit nó — đó chính là mục đích. Các image cũ sẽ tích tụ sau khi upgrade; hãy giải phóng đĩa bằng docker image prune -f.

Và đây là lệnh phá hủy, hãy lưu ý: docker compose down là an toàn — container và network là tạm thời, và dữ liệu của bạn nằm trong volume. docker compose down -v sẽ xóa cả các named volumes. Đó là database của bạn, mất sạch, ngay lập tức, không có xác nhận và không thể undo. Flag -v tồn tại để dọn dẹp các thử nghiệm; với một stack chứa dữ liệu thật, hãy đối xử với nó như cách bạn đối xử với rm -rf. Không có thùng rác cho /var/lib/docker/volumes/ đâu.

Để vào một shell tạm thời bên trong một container đang chạy: docker compose exec db psql -U miniflux đưa bạn vào database, và docker compose exec miniflux sh đưa bạn vào shell của app.

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

Named volumes sẽ có prefix của project, nên db-data trong một thư mục tên là miniflux sẽ trở thành miniflux_db-data:

docker volume ls
docker volume inspect miniflux_db-data

Output của inspect bao gồm dòng quan trọng nhất:

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

Thư mục đó chính là database — thuộc sở hữu của root, nằm trên filesystem của host, và nó tồn tại qua down, các bản upgrade, và việc rebuild container. Đó cũng chính là những gì bản backup của bạn phải capture.

Backup một named volume

Pattern tiêu chuẩn là dùng một container tạm thời, mount volume ở chế độ read-only cạnh một thư mục trên host, rồi tar nó lại:

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 đặt, không có gì đang chạy, và việc restore sẽ là bản sao hoàn hảo — dùng tar xzf vào một volume trống mới với các mount ngược lại.

Một lưu ý cho database: tar một thư mục data Postgres đang chạy có thể capture trạng thái đang ghi dữ liệu, khiến nó không thể khởi động sạch sẽ. Hãy dùng docker compose stop trong vài giây khi tar, hoặc — tốt hơn — hãy thực hiện một logical dump, thứ vốn dĩ đã nhất quán:

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

Lệnh -T sẽ vô hiệu hóa pseudo-terminal mà Compose cấp mặc định — việc pipe output dump qua một TTY có thể làm hỏng nó. Hãy đặt một lệnh này vào cron và copy kết quả ra khỏi VPS; một bản backup nằm trên cùng một đĩa với dữ liệu cần bảo vệ chỉ là một bản copy, không phải là backup. Hướng dẫn Nextcloud xây dựng một quy trình lập lịch đầy đủ dựa trên chính hai pattern này.

Các lỗi thường gặp, kèm theo các thông báo bạn sẽ thấy

permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock — bạn chưa thuộc group docker, hoặc đã thuộc nhưng session hiện tại có trước đó. id hiển thị các group hiện tại của bạn; newgrp docker sẽ sửa lỗi cho shell hiện tại, còn logout rồi login lại sẽ sửa tất cả.

Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running? — vấn đề khác: bản thân daemon đã sập. sudo systemctl status dockersudo journalctl -u docker -n 50 sẽ cho biết lý do. Trên VPS, nguyên nhân phổ biến là đầy đĩa — hãy kiểm tra df -h /var/lib/docker trước.

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

yaml: line 14: did not find expected key — lỗi thụt đầu dòng tại hoặc ngay trên dòng được nêu tên. File compose là YAML: thụt đầu dòng 2 dấu cách, chỉ dùng dấu cách, và bất kỳ ký tự tab nào cũng sẽ gây lỗi fatal. docker compose config giúp validate file mà không khởi động gì cả, và chạy nó sau mỗi lần edit là một thói quen tốt.

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

Từ đây, hướng dẫn Traefik sẽ biến stack đơn lẻ này thành nhiều ứng dụng đằng sau một entry point HTTPS duy nhất, và thứ gì đáng để tự host vào năm 2026 là danh sách cần chạy qua nó.

Một game server như server Minecraft trên VPS là một dự án Compose đầu tiên thân thiện để bạn thực hành.

FAQ

Tại sao tôi gặp lỗi "permission denied while trying to connect to the Docker daemon socket"?

User của bạn chưa nằm trong group docker, hoặc mới được thêm sau khi session hiện tại bắt đầu — quyền thành viên chỉ có hiệu lực khi login. Hãy chạy sudo usermod -aG docker $USER, sau đó newgrp docker hoặc logout rồi login lại, và xác nhận bằng id. Group này cấp quyền tương đương root trên host, nên chỉ thêm những user mà bạn sẽ cấp quyền sudo.

Lệnh docker compose down có xóa dữ liệu của tôi không?

Lệnh docker compose down thuần túy thì không — nó chỉ xóa các container và network của project; các named volume vẫn còn và lệnh up -d tiếp theo sẽ gắn lại chúng. docker compose down -v là dạng phá hủy: nó xóa cả các named volumes, nghĩa là database của bạn sẽ mất sạch, không có xác nhận và không thể undo. Đừng bao giờ chạy -v trên một stack có dữ liệu thật trừ khi bạn có bản backup đã được xác minh.

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

docker-compose (có dấu gạch ngang) là Compose v1, một binary Python standalone đã hết vòng đời vào năm 2023 và không nên cài đặt trên các server mới. docker compose (có dấu cách) là Compose v2, một plugin Go cho Docker CLI, được cài đặt dưới dạng docker-compose-plugin từ repository apt của Docker. Các lệnh và YAML gần như tương thích hoàn toàn, nên khi hướng dẫn cũ nói docker-compose up, hãy gõ docker compose up.

Tại sao tôi có thể truy cập container Docker từ internet dù ufw đã chặn port?

Bởi vì Docker publish port bằng các rule DNAT trong chain PREROUTING của iptables, và các packet đã được ghi đè sẽ đi theo đường FORWARD qua các chain của chính Docker — chúng không bao giờ chạm tới chain INPUT nơi các rule của ufw áp dụng. Do đó, ufw deny 8080 không có tác dụng gì đối với một port container đã được publish. Hãy sửa từ gốc: hãy publish tới 127.0.0.1: và dùng một reverse proxy để expose các service.

Tôi nên dùng named volume hay bind mount?

Dùng named volumes cho data mà chỉ container chạm vào — đặc biệt là database, vì Docker thiết lập ownership mà image yêu cầu và permission sẽ hoạt động chuẩn. Dùng bind mounts cho các file mà bạn cũng thao tác từ host: các config bạn sửa, media bạn upload, bất cứ thứ gì bạn muốn đường dẫn phải rõ ràng. Nếu một container lỗi khi khởi động với lỗi permission denied trên một bind mount, việc mismatch UID giữa host và container là thứ đầu tiên cần kiểm tra.