Docker Compose cơ bản trên VPS Ubuntu 24.04
Cài Docker Engine và Compose v2 trên Ubuntu 24.04, chạy Miniflux cùng PostgreSQL bằng compose.yml 2 service, tránh lỗi UFW và backup named volume.
Bạn sẽ xây dựng gì
Docker Compose là nền tảng bên dưới 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à plugin Compose v2 từ apt repository chính thức của Docker trên Ubuntu 24.04, sau đó triển khai một stack gồm 2 service thực tế: Miniflux, một RSS reader nhỏ, cùng PostgreSQL. Cặp này minh họa đầy đủ các pattern mà những app lớn hơn sử dụng: image được pin version, database có healthcheck, named volume, secret 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: group docker thực chất có quyền như root, các port được publish có thể bỏ qua thẳng các rule của ufw, và một flag trên docker compose down có thể xóa database mà không hỏi xác nhận.
Điều kiện cần: một Ubuntu 24.04 KVM VPS mới, một user có sudo và ít nhất 1 gigabyte RAM. Nếu đã cài Docker thì vẫn được; section đầu tiên sẽ hướng dẫn những thành phần cần gỡ bỏ.
Cài từ repository của Docker, không phải repository của Ubuntu
Có 2 lựa chọn sai cần loại bỏ trước khi chạy lệnh đầu tiên. Package 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 giả định. Binary docker-compose độc lập, tức binary có dấu gạch nối, là Compose v1: chạy bằng Python, đã hết vòng đời từ 2023 và là nguyên nhân khiến các tutorial cũ bị lỗi. 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 Docker Engine.
Nếu một trong các thành phần đó đã có trên máy, hãy gỡ sạch trước, bao gồm cả docker-compose-v2, là package plugin riêng của Ubuntu, để mọi thành phần đều đến từ một repository:
sudo apt remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runcPackage '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-pluginXác minh cả 3 lớp:
docker --version
docker compose version
sudo docker run --rm hello-world2 lệnh đầu tiên in ra chuỗi phiên bản. Docker Compose version v2.x.x xác nhận bạn đang dùng plugin, không phải binary v1 đã lỗi thời. 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.sockCách sửa tiêu chuẩn:
sudo usermod -aG docker $USERQuyề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 trở thành tên project và được dùng làm tiền tố cho container, network và volume:
sudo mkdir -p /opt/miniflux && sudo chown $USER /opt/miniflux && cd /opt/minifluxTạo compose.yml (đây là tên hiện đại; docker-compose.yml vẫn hoạt động). Bỏ qua key cũ version: vì nó đã lỗi thời và Compose sẽ cảnh báo khi thấy 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 từng dòng một.
Ghim phiên bản image, :latest kèm pull là một lần nâng cấp không giám sát
postgres:16-alpine, không phải postgres:latest. Tag không cố định: :latest sẽ được phân giải lại thành phiên bản 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 upgrade định kỳ mà bạn sắp học, docker compose pull && docker compose up -d, :latest có nghĩa là các lần nhảy major version sẽ được áp dụng ngay khi upstream phát hành, thay vì khi bạn chọn. Với PostgreSQL, đây không phải giả thuyết: một lần nhảy bất ngờ từ 16 lên 17 khiến container crash-loop vì data directory không tương thích, do upgrade major của Postgres 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 bản vá 16.x), và ghim ứng dụng vào một release cụ thể như miniflux/miniflux:2.2.9. Kiểm tra trang releases của project và dùng phiên bản hiện tại tại thời điểm viết file. Khi đó, upgrade chỉ là một dòng được bạn chủ động sửa, có thể xem lại trong git diff.
Publish vào 127.0.0.1 vì Docker đi vòng qua ufw
"127.0.0.1:8080:8080", địa chỉ host, port trên host, port trong container. 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: lắng nghe trên mọi interface, bao gồm 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 port bằng cách ghi một rule DNAT để đổi đích của packet thành IP nội bộ của container trước khi filtering. Vì vậy packet đi theo đường FORWARD và không bao giờ chạm vào 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 cho thấy port bị deny, nhưng service vẫn trả lời toàn bộ Internet. Firewall của bạn không hỏng; theo thiết kế, Docker đang bypass firewall. Tại sao Docker bypass ufw và cách filter traffic của container thực sự giải thích cơ chế này và cách khắc phục 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 port đã publish vào 127.0.0.1, trừ khi bạn có lý do cụ thể để làm khác, và đặt reverse proxy phía trước mọi dịch vụ cần public. Đó chính là nội dung mà 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, định tuyến đến mọi dịch vụ khác theo hostname, kèm TLS. (Nếu bạn đang dùng Traefik v2? Hướng dẫn migration từ Traefik v2 sang v3 trình bày các thay đổi 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, dùng để map một path do bạn chọn trên host.
Cách phân chia thực tế và bền vững 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à file permission sẽ hoạt động đúng. Dùng bind mount cho các file bạn chỉnh sửa từ host, như file config chỉnh bằng text editor, media library được rsync vào, hoặc mọi dữ liệu mà bạn muốn nhìn thấy path rõ ràng. Lỗi bind mount kinh điển là ownership: container chạy với UID 999, directory trên host thuộc UID 1000, và app chết khi start với permission denied trong log. Named volume gần như loại bỏ nhóm lỗi này, đổi lại dữ liệu nằm ở một path do Docker quản lý, sẽ nói ở phần dưới.
environment và .env, không để secret trong git
${POSTGRES_PASSWORD} không được đọc từ shell của bạn. Compose interpolate giá trị này từ file tên .env nằm cạnh compose.yml. Tạo file đó:
cat > .env <<'EOF'
POSTGRES_PASSWORD=change-me-to-something-long
ADMIN_PASSWORD=change-me-too
EOF
chmod 600 .env
echo ".env" >> .gitignoreTạo giá trị thực bằng openssl rand -hex 24. Cố ý dùng hex thay vì base64: password này sẽ nằm trong connection string DATABASE_URL, 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 lỗi authentication, không phải lỗi syntax, 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 làm vậy. Secret đã từng xuất hiện trong git history là secret bạn phải rotate. Nếu start stack khi thiếu variable, Compose sẽ cảnh báo rõ ràng nhưng vẫn tiếp tục với chuỗi rỗng. Với password của Postgres, điều này sẽ 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 đã được interpolate hoàn toàn. Đây là cách nhanh nhất để kiểm tra chính xác dữ liệu mà container sẽ nhận. 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 thuầ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 chấp nhận 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ách đáng tin cậy là cách file ở trên đang dùng: service db định nghĩa một healthcheck (Postgres cung cấp pg_isready chính xác cho mục đích này), còn app khai báo depends_on 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ị hỏng, app sẽ không start và Compose cho biết dependency nào đã fail:
dependency failed to start: container miniflux-db-1 is unhealthyThô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ả 2 service có nghĩa là container sẽ chạy lại sau khi crash và sau khi VPS reboot, nhưng vẫn dừng nếu bạn chủ động chạy docker compose stop. Lựa chọn always sẽ resurrect container ngay cả sau khi bạn stop thủ công, đây hiếm khi là điều bạn muốn. Nếu không có restart policy, một lần reboot lúc 4 a.m. do 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 networkup -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-dataKế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 lỗi thường gặp và chuỗi 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 docker group, hoặc session hiện tại được mở trước khi bạn thêm group. id hiển thị các group hiệu lực của bạn; newgrp docker áp dụng thay đổi cho shell hiện tại, còn đăng xuất rồi đăng nhập lại sẽ áp dụng cho tất cả session.
Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?, đây là vấn đề khác: daemon đã dừng. sudo systemctl status docker và sudo journalctl -u docker -n 50 cho biết nguyên nhân. Trên VPS, nguyên nhân thường gặp là disk đầy; 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 host port đó. docker ps cho biết container nào đang dùng port; thường thủ phạm là container cũ từ một lần thử nghiệm docker run vài tuần trước. Nếu docker ps không trả về gì, một process không thuộc Docker đang giữ port: sudo ss -tlnp | grep 8080 cho biết process đó.
yaml: line 14: did not find expected key, lỗi indentation nằm tại hoặc ngay phía trên dòng được nêu. File Compose là YAML: dùng indentation 2 space, chỉ dùng space, và một ký tự tab ở bất kỳ đâu cũng khiến file lỗi. docker compose config kiểm tra tính hợp lệ của file mà không khởi động gì; chạy lệnh này sau mỗi lần chỉnh sửa là một thói quen đơn giản và hữu ích.
Bất ngờ với ufw là lệnh không in ra lỗi nào, và chính điều đó khiến vấn đề nguy hiểm: việc deploy thành công, ufw status trông có vẻ đúng, nhưng port scan từ bên ngoài vẫn phát hiện database của bạn. Đọ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, rồi xác nhận từ một máy khác bằng curl http://your-vps-ip:8080; kết quả bạn muốn là connection refused.
Từ đây, hướng dẫn Traefik biến stack đơn này thành nhiều app phía sau một HTTPS entry point, còn những gì đáng tự host trong 2026 là danh sách để bạn triển khai qua đó. Khi vài stack trong số này đã chạy và mỗi stack lại có form đăng nhập riêng, một SSO server tự host như Authentik sẽ gom chúng về một account duy nhất phía sau cùng reverse proxy đó.
Một game server như Minecraft server trên một VPS là dự án Compose đầu tiên dễ làm để thực hành. Nếu muốn học trên một dịch vụ bạn mở hằng ngày, openGym, trình theo dõi bài tập tự host là một stack cố định theo git tag thay vì image tag. Dịch vụ này cần TLS ở phía trước trước khi bạn đăng ký passkey đầu tiên. Ảnh thường là loại dữ liệu đầu tiên mọi người muốn đưa ra khỏi cloud của người khác, và so sánh PhotoPrism với Immich giúp xác định mức RAM tối thiểu cùng quy trình backup mà bạn sẽ phải áp dụng trước khi gắn volume vào một trong hai dịch vụ. Khi hai service không còn đủ để thực hành, dựng AFFiNE làm workspace kiểu Notion áp dụng các pattern tương tự với bốn container. Đây cũng là bài kiểm tra thực tế xem việc cố định tag, cấu hình healthcheck và dùng named volume ở trên đã thành thói quen hay chưa.
FAQ
Tại sao tôi nhận được lỗi "permission denied while trying to connect to the Docker daemon socket"?
User của bạn không 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 từ lần đăng nhập tiếp theo. 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?
Lệnh 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ẽ gắn 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, không hỏi xác nhận và không thể hoàn tác. Không bao giờ chạy -v trên stack chứa dữ liệu thật nếu bạn chưa có backup đã được kiểm tra.
docker-compose và docker compose khác nhau như thế nào?
docker-compose (dấu gạch nối) là Compose v1, một binary Python độc lập đã hết vòng đời vào năm 2023 và không nên được cài trên server mới. docker compose (dấu cách) là Compose v2, một plugin Go cho Docker CLI, được cài dưới dạng docker-compose-plugin từ Docker apt repository. Command và YAML gần như tương thích hoàn toàn. Vì vậy, khi tutorial cũ yêu cầu chạy docker-compose up, hãy nhập docker compose up.
Tại sao tôi vẫn truy cập được Docker container từ Internet dù ufw chặn cổng?
Vì Docker publish cổng bằng các rule DNAT trong chain PREROUTING của iptables. Các packet sau khi được rewrite sẽ đi qua 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 cổng của container đã được publish. Hãy xử lý ngay từ nguồn: publish vào 127.0.0.1: và expose service 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ẽ thiết lập 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ứ có path mà bạn muốn nhìn thấy rõ. Nếu container fail khi startup với permission denied trên bind mount, hãy kiểm tra mismatch UID giữa host và container trước tiên.