SSD Nodes Learn
指南 Matt Connor作者: Matt Connor · 已更新 2026-07-24

Ubuntu 24.04 安裝 Docker Engine 與 Compose v2 教學

本指南教學於 Ubuntu 24.04 安裝 Docker Engine 與 Compose v2 plugin。內容涵蓋撰寫 compose.yml 實作、避開 ufw 防火牆對 port-publishing 的限制,以及如何正確備份 named volume 資料。

實作目標

Docker Compose 是本網站幾乎所有服務的基礎。NextcloudVaultwardenn8nImmichRocket.Chat — 這些指南的開頭都會要求「撰寫此 compose file」,而本頁面將解釋該檔案的實際含義。您將從 Docker 官方的 apt repository 在 Ubuntu 24.04 上安裝 Docker Engine 與 Compose v2 plugin,接著部署一個包含兩個服務的實作堆疊:輕量級 RSS 閱讀器 Miniflux 以及 PostgreSQL。這組組合涵蓋了大型應用程式使用的所有模式:固定版本映像檔 (pinned images)、具備 healthcheck 的資料庫、具名磁碟卷 (named volume)、.env 檔案中的 secrets,以及僅對 localhost 開放的 port。

安裝過程約需 5 分鐘。本指南其餘部分將討論後續可能遇到的問題:docker group 權限等同於 root、已發布的 ports 會繞過 ufw 規則,以及 docker compose down 中一個不會顯示確認提示便會刪除資料庫的 flag。

先決條件:一台全新的 Ubuntu 24.04 KVM VPS、具備 sudo 權限的使用者,以及 1 GB 或以上的 RAM。若已有安裝過的 Docker 亦可,第一節會說明需移除的項目。

請從 Docker 官方 repo 安裝,而非 Ubuntu 官方 repo

在執行第一個指令前,請先避免兩個錯誤。Ubuntu 內建的 docker.io 套件雖然可用,但版本落後於 Docker 官方發佈,且不符合其他套件預期的 plugin 結構。此外,帶有連字號的獨立 docker-compose 二進位檔為 Compose v1:它是基於 Python 開發,且已於 2023 年停止支援,這也是舊版教學失效的原因。現在的 Compose 是以空格分隔的 docker compose,屬於 CLI plugin,並從與 engine 相同的 repository 安裝。

若系統中已安裝上述任何內容,請先清除——包含 Ubuntu 內建的 plugin 套件 docker-compose-v2,以確保所有內容皆來自同一個 repository:

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

在全新的 VPS 上,Package 'docker.io' is not installed, so not removed 為正常輸出。接著新增 Docker repository 並進行安裝:

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

驗證以下三個層級:

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

前兩個指令會顯示版本字串 — Docker Compose version v2.x.x 可確認您安裝的是 plugin,而非已淘汰的 v1 二進位檔。hello-world 執行結果應以 Hello from Docker! 結尾。此套件會在開機時啟動服務;systemctl is-enabled docker 會顯示 enabled

docker group 權限等同 root — 請謹慎評估

目前所有 docker 指令都需要 sudo,因為位於 /var/run/docker.sock 的 daemon socket 權限屬於 root 與 docker 群組。若未加入該群組,會出現最常見的 Docker 錯誤:

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

標準解決方案:

sudo usermod -aG docker $USER

群組權限需在登入時生效,因此目前的 shell 仍會出現錯誤。若要立即在當前 session 生效,請執行 newgrp docker;或者登出並重新登入,接著執行 id 應會在群組清單中看到 docker

現在直接說明事實:加入 docker 群組等同於擁有主機的 root 權限。 這不是「類似 root」或「提升權限」,而是真正的 root。任何屬於該群組的使用者都能在不輸入密碼的情況下執行 docker run --rm -it -v /:/host alpine chroot /host 並取得整個檔案系統的控制權。該群組的存在是為了方便,而非為了隔離。

Docker 的 rootless mode 才是真正的替代方案 — daemon 本身會以非特權使用者身份執行。這會帶來以下代價:低於 1024 的 port 需要額外設定、網路需透過 userspace shim 運作並產生可察覺的額外開銷,且部分 image 在沒有真實 root 權限時會運作異常。在只有單一管理員且已具備 sudo 權限的 VPS 環境中,使用該群組在實務上並無差異,且本指南的所有說明皆以此為前提 — 但請務必記住,其權限等級並不比 sudo 低。

Anatomy of a compose file

Give each stack its own directory — the directory name becomes the project name, which prefixes containers, networks, and volumes:

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

Create compose.yml (the modern name; docker-compose.yml still works). Skip the old version: key — it is obsolete and Compose warns if it sees one.

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:

Every line above is a decision. Take them one at a time.

Pin image versions — :latest plus a pull is an unattended upgrade

postgres:16-alpine, not postgres:latest. A tag is not frozen: :latest re-resolves to whatever the maintainer most recently pushed, every time you pull. Combine that with the routine upgrade habit you are about to learn — docker compose pull && docker compose up -d — and :latest means major-version jumps land whenever upstream ships them, not when you choose. With PostgreSQL that is not hypothetical: a surprise jump from 16 to 17 leaves the container crash-looping on an incompatible data directory, because Postgres major upgrades require a dump and restore, not a restart.

Pin at least the major version (postgres:16-alpine follows 16.x patch releases), and pin applications to an exact release like miniflux/miniflux:2.2.9 — check the project's releases page and use whatever is current when you write the file. An upgrade then becomes a one-line edit you made on purpose, visible in git diff.

Publish to 127.0.0.1, because Docker walks around ufw

"127.0.0.1:8080:8080" — host address, host port, container port. Most tutorials write "8080:8080", which is shorthand for 0.0.0.0:8080:8080: listening on every interface, including the public one.

Here is the trap, and it bites almost everyone once. Docker publishes a port by writing a DNAT rule that rewrites the packet's destination to the container's internal IP before filtering, so the packet takes the FORWARD path and never touches INPUT, where your ufw rules live. sudo ufw deny 8080 reports success, ufw status shows the port denied, and the service still answers the whole internet. Your firewall is not broken; it is being bypassed by design. Why Docker bypasses ufw, and how to filter container traffic for real walks through the mechanism and the DOCKER-USER fix for ports that must stay public.

The habit that makes the whole problem vanish: bind published ports to 127.0.0.1 unless you have a specific reason not to, and put a reverse proxy in front for anything that should face the world. That is exactly what the Traefik reverse proxy guide builds as the next step after this page — one container that owns ports 80 and 443 and routes to everything else by hostname, with TLS. (Coming from an older Traefik v2 setup? The Traefik v2 to v3 migration guide covers the renames and rule changes.)

Verify the bind after starting the stack: sudo ss -tlnp | grep 8080 should show 127.0.0.1:8080, not 0.0.0.0:8080 or *:8080.

Named volumes vs bind mounts

db-data:/var/lib/postgresql/data is a named volume: Docker creates and manages a directory under /var/lib/docker/volumes/ and mounts it into the container. The alternative is a bind mount, ./data:/var/lib/postgresql/data, which maps a path you chose on the host.

The split that holds up in practice: named volumes for data only containers touch — databases above all, since Docker initialises the volume with the ownership the image expects and file permissions just work. Bind mounts for files you touch from the host — config files you edit with a text editor, a media library you rsync into, anything whose path you want to be obvious. The classic bind-mount failure is ownership: the container runs as UID 999, your host directory is owned by UID 1000, and the app dies on startup with permission denied in its logs. Named volumes make that class of bug mostly disappear, at the cost of the data living at a Docker-managed path — covered below.

environment and .env — keep secrets out of git

${POSTGRES_PASSWORD} is not read from your shell; Compose interpolates it from a file named .env sitting next to compose.yml. Create it:

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

Generate real values with openssl rand -hex 24. Hex, not base64, on purpose: this password lands inside the DATABASE_URL connection string, and the /, +, and = characters base64 produces break URL parsing — a failure that surfaces as an authentication error, not a syntax error, and costs an evening. The .gitignore line goes in before the first commit: the compose file is safe to publish and version, the .env file never is, and a secret that has touched git history is a secret you rotate. If you start the stack with a variable missing, Compose warns loudly and continues with an empty string — which for a Postgres password means a broken deployment:

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

docker compose config prints the fully-interpolated file — the fastest way to check what the containers will actually receive; remember its output includes your secrets.

depends_on waits for nothing — unless you add a healthcheck

A bare depends_on: [db] controls start order only: Compose launches Postgres first and the app a moment later, while Postgres is still seconds away from accepting connections. The app hits the database, fails, and crashes or retries depending on how well it was written.

The reliable version is what the file above uses: the db service defines a healthcheck (Postgres ships pg_isready for exactly this), and the app declares depends_on with condition: service_healthy. Compose starts the database, polls the check every 10 seconds, and only starts Miniflux once the check passes. If the database never becomes healthy — bad password, corrupt volume — the app never starts and Compose tells you which dependency failed:

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

That message points you at docker compose logs db, which is where the real error lives.

restart: unless-stopped

restart: unless-stopped on both services means the containers come back after a crash and after a VPS reboot, but stay down if you deliberately ran docker compose stop. The alternative always resurrects containers even after a manual stop — rarely what you meant. Without a restart policy, a kernel-update reboot at 4 a.m. quietly takes your services down until you notice.

日常指令

所有日常維運工作僅需在專案目錄下執行五個指令。

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 —— 此指令會比對檔案與實際狀態,且僅會更動設定或 image 已變動的 services。upgrade 指令會抓取目前 pinned tags 所指向的內容:若標籤在 postgres:16-alpine 以下則會抓取 patch releases;若為精確標籤(exact pin)則不會有任何動作,直到您手動修改為止 —— 這正是使用標籤的目的。升級後會累積舊的 images;請使用 docker image prune -f 釋放磁碟空間。

接下來是具破壞性的指令,請務必注意:docker compose down 是安全的 —— containers 與 network 是可拋棄的,且您的 data 儲存在 volume 中。docker compose down -v 會連同 named volumes 一併刪除。這代表您的 database 會立即消失,且不會有任何確認提示或復原機制。 -v flag 適用於拆解實驗環境;若是在存放真實資料的 stack 上,請對待它如同對待 rm -rf。在 /var/lib/docker/volumes/ 之下沒有資源回收桶。

若需進入執行中的 container 進行單次 shell 操作:docker compose exec db psql -U miniflux 會讓您進入 database,而 docker compose exec miniflux sh 則會讓您進入 app 的 shell。

資料實際儲存位置

具名磁碟卷 (Named volumes) 會加上專案前綴,因此 db-data 在名為 miniflux 的目錄中會變成 miniflux_db-data

docker volume ls
docker volume inspect miniflux_db-data

inspect 的輸出包含關鍵行:

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

該目錄即為資料庫內容 — 由 root 擁有,位於主機檔案系統,且在 down、版本升級與容器重建後仍會保留。這也是備份作業必須擷取的對象。

備份具名磁碟卷 (Named Volume)

標準做法是啟動一個暫時性的容器,將磁碟卷以 read-only 模式掛載至主機目錄,接著進行 tar 封裝:

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 .

此方法無需安裝額外軟體,且不會留下任何執行中的程序。還原過程與備份完全對應:將 tar xzf 寫入一個掛載路徑相同但方向相反的新空磁碟卷中。

針對資料庫有一個注意事項:對「執行中」的 Postgres 資料目錄進行 tar 封裝,可能會擷取到寫入中途的狀態,導致無法正常啟動。建議在執行 tar 的幾秒鐘內執行 docker compose stop,或者更好的做法是進行邏輯傾印 (Logical dump),因為其結構具備一致性:

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

使用 -T 可以停用 Compose 預設分配的 pseudo-terminal —— 透過 TTY 傳輸 dump 輸出可能會導致資料毀損。將其中一種指令加入 cron 排程,並將結果複製到 VPS 之外;將備份存放在與原始資料相同的磁碟上僅是副本,而非真正的備份。Nextcloud 指南 圍繞這兩種模式建構了完整的排程作業流程。

Failure modes, with the strings you will see

permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock — 您尚未加入 docker group,或者已加入但該權限尚未套用到目前的 session。id 會顯示目前的有效群組;newgrp docker 可修正目前的 shell,若要套用所有變更,請登出並重新登入。

Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running? — 問題不同:daemon 本身已停止執行。sudo systemctl status dockersudo journalctl -u docker -n 50 會說明原因。在 VPS 上,常見原因是磁碟空間已滿 — 請先執行 df -h /var/lib/docker

Bind for 127.0.0.1:8080 failed: port is already allocated — 另一個 container 已佔用該 host port。docker ps 會顯示該 container;通常是幾週前進行 docker run 實驗時留下的殘餘 container。若 docker ps 結果為乾淨,則是由非 Docker 程序佔用該 port:sudo ss -tlnp | grep 8080 會顯示該程序名稱。

yaml: line 14: did not find expected key — 在指定行或其上方存在縮排錯誤。Compose 檔案格式為 YAML:必須使用兩個空格縮排,僅限使用空格,任何 tab 鍵都會導致錯誤。docker compose config 可在不啟動服務的情況下驗證檔案,建議每次編輯後都執行一次。

The ufw surprise 完全不會顯示錯誤訊息,這點非常危險:部署看似成功,ufw status 顯示正常,但從外部進行 port scan 仍能連入您的資料庫。請重新閱讀上方的 ports 章節,檢查每個 ports: 項目是否遺漏了 127.0.0.1: 前綴,並使用另一台機器透過 curl http://your-vps-ip:8080 進行確認 — 預期的結果應為 connection refused。

從這裡開始,Traefik guide 會將此單一 stack 轉換為透過單一 HTTPS 入口點運行的多個應用程式;而 what's worth self-hosting in 2026 則是建議運行的清單。

a Minecraft server on a VPS 這樣的遊戲伺服器,是練習 Compose 專案的好入門選擇。

FAQ

Why do I get "permission denied while trying to connect to the Docker daemon socket"?

Your user is not in the docker group, or was added after the current session began — membership only applies at login. Run sudo usermod -aG docker $USER, then newgrp docker or log out and back in, and confirm with id. The group grants root-equivalent access to the host, so only add users you would give sudo.

Does docker compose down delete my data?

Plain docker compose down does not — it removes containers and the project network; named volumes survive and the next up -d reattaches them. docker compose down -v is the destructive form: it deletes the named volumes, meaning your database, with no confirmation and no undo. Never run -v on a stack with real data unless you hold a verified backup.

What is the difference between docker-compose and docker compose?

docker-compose (hyphen) is Compose v1, a standalone Python binary that reached end of life in 2023 and should not be installed on new servers. docker compose (space) is Compose v2, a Go plugin for the Docker CLI, installed as docker-compose-plugin from Docker's apt repository. Commands and YAML are almost fully compatible, so when an old tutorial says docker-compose up, type docker compose up.

Why can I reach my Docker container from the internet even though ufw blocks the port?

Because Docker publishes ports with DNAT rules in iptables' PREROUTING chain, and the rewritten packets travel the FORWARD path through Docker's own chains — they never hit the INPUT chain where ufw's rules apply. ufw deny 8080 therefore does nothing to a published container port. Fix it at the source: publish to 127.0.0.1: and expose services through a reverse proxy instead.

Should I use a named volume or a bind mount?

Named volumes for data only the container touches — databases especially, since Docker sets the ownership the image expects and permissions just work. Bind mounts for files you also handle from the host: configs you edit, media you upload, anything whose path you want obvious. If a container fails at startup with permission denied on a bind mount, host-vs-container UID mismatch is the first thing to check.