SSD Nodes Learn Hosting plans →
指南 Matt Connor作者: Matt Connor

Ubuntu 24.04 Docker Compose 安装与配置指南

在 Ubuntu 24.04 上通过官方源安装 Docker Engine 与 Compose 插件。本指南演示如何部署双服务栈,解决 UFW 端口暴露风险,并提供生产环境下的数据备份方案。

你将构建的内容

Docker Compose 是本网站几乎所有其他内容的基础。Nextcloud、Vaultwarden、n8n、Immich、Rocket.Chat,这些指南都会以“编写此 compose 文件”开头。本页将解释该文件的实际含义。你将在 Ubuntu 24.04 上通过 Docker 官方 apt 软件源安装 Docker Engine 和 Compose 插件,然后运行一个真实的双服务栈:Miniflux(一款小型 RSS 阅读器)和 PostgreSQL。这个组合涵盖了大型应用使用的所有常见模式:固定镜像版本、带健康检查的数据库、命名卷、存储在 .env 文件中的机密信息,以及仅发布到 localhost 的端口。

安装过程需要 5 分钟。本指南的其余部分将介绍之后容易出问题的内容:docker 组实际上等同于 root、已发布的端口会直接绕过 ufw 规则,以及 docker compose down 上一个会在不显示确认提示的情况下删除数据库的选项。

前提条件:全新的 Ubuntu 24.04 KVM VPS、具有 sudo 权限的用户,以及至少 1 GB RAM。已有 Docker 安装也没问题,第一节会介绍需要移除的内容。

从 Docker 仓库安装,而不是 Ubuntu 仓库

在运行第一个命令前,先排除两种错误做法。Ubuntu 自带的 docker.io 软件包可以使用,但版本落后于 Docker 的发布版本,而且缺少其他组件默认依赖的插件布局。独立的 docker-compose 二进制文件(名称中带连字符的那个)属于 Compose v1:它使用 Python,Docker 已不再维护,也是旧教程失效的原因。现在的 Compose 是带空格的 docker compose,属于 CLI 插件,与引擎从同一个仓库安装。

如果系统中已经安装了其中任何组件,请先清理,包括 docker-compose-v2 和 docker-buildx。后两者是 Ubuntu 自己打包的插件,因此应确保所有组件都来自同一个仓库:

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

在全新的 VPS 上,Package 'docker.io' is not installed, so not removed 是正常输出。然后添加 Docker 仓库并安装:

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 v5.x.x 则确认安装的是插件,而不是已经停止维护的 v1 二进制文件。插件在 December 2025 直接从 v2 跳到 v5,跳过了 3 和 4,以避免与旧版 Compose 文件格式版本混淆。因此,教程中在此处显示 v2.x.x 时,指的是同一个工具。hello-world 命令应以 Hello from Docker! 结束。软件包会启用服务,使其在系统启动时运行;systemctl is-enabled docker 会输出 enabled。

docker 组就是 root 权限,请充分了解后再决定

目前每条 docker 命令都需要 sudo,因为 daemon 的套接字 /var/run/docker.sock 归 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 中仍会出现该错误。运行 newgrp docker 可仅为本次会话启用该组,或者注销后重新登录;之后,id 应在您的组列表中列出 docker。

现在明确说明实际情况:docker 组成员拥有主机上的 root 权限。 不是“类似 root”,也不是“提升权限”,而是 root。该组中的任何人都可以运行 docker run --rm -it -v /:/host alpine chroot /host,从而控制整个文件系统,且不需要密码。该组是为了使用方便,不是为了隔离权限。

Docker 的 rootless 模式才是真正的替代方案,其中 daemon 本身以您的非特权用户身份运行。但这会带来限制:1024 以下的端口需要额外配置;网络通过用户空间 shim 运行,会产生可测量的开销;某些镜像在没有真正的 root 权限时运行异常。对于单管理员 VPS,如果唯一的登录用户本来就拥有 sudo 权限,加入该组在实际效果上不会改变什么。本教程默认采用这种方式,但不要把 docker 组当作低于 sudo 的权限来随意授予。

Compose 文件的结构

为每个堆栈单独创建目录。目录名会成为项目名,并作为容器、网络和卷名称的前缀:

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

创建 compose.yml(现代名称;docker-compose.yml 仍然有效)。不要使用旧的 version: 键,它已经过时,Compose 检测到后会发出警告。

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:

上面的每一行都代表一个决策。逐项理解。

固定镜像版本,:latest 加 pull 会导致无人值守升级

使用 postgres:16-alpine,不要使用 postgres:latest。标签并不会固定版本:每次 pull 时,:latest 都会重新解析为维护者最近推送的版本。再加上你即将学习的常规升级操作 docker compose pull && docker compose up -d,这意味着 :latest 会在上游发布主版本时立即升级,而不是在你选择的时间升级。PostgreSQL 中这不是假设情况:从 16 意外升级到 17 会让容器因数据目录不兼容而持续崩溃重启,因为 Postgres 主版本升级需要执行转储和恢复,而不是重启。

至少固定主版本(postgres:16-alpine 会跟随 16.x 的补丁版本),应用则固定到类似 miniflux/miniflux:2.2.9 的确切版本。查看项目的 releases 页面,并使用你编写文件时的当前版本。之后的升级只需有意修改一行,并且会清晰记录在 git diff 中。

发布到 127.0.0.1,因为 Docker 会绕过 ufw

"127.0.0.1:8080:8080"、主机地址、主机端口、容器端口。大多数教程会写 "8080:8080",这是 0.0.0.0:8080:8080 的简写:监听所有网络接口,包括公网接口。

这里存在一个陷阱,几乎所有人都会遇到一次。Docker 会通过写入 DNAT 规则发布端口,在过滤之前将数据包的目标地址改写为容器的内部 IP,因此数据包会走 FORWARD 路径,完全不会经过 INPUT,而你的 ufw 规则就在这里生效。sudo ufw deny 8080 报告成功,ufw status 显示端口被拒绝,但服务仍然响应整个互联网的请求。防火墙没有损坏;这是 Docker 有意绕过它的结果。为什么 Docker 会绕过 ufw,以及如何真正过滤容器流量介绍了其工作机制,以及用于限制必须保持公网访问端口的 DOCKER-USER 修复方法。

让这个问题彻底消失的做法是:除非有明确原因,否则将已发布端口绑定到 127.0.0.1;需要面向公网的服务则放在反向代理后面。这正是本页之后的下一步:Traefik 反向代理指南会构建一个容器,由它占用 80 和 443 端口,并根据主机名将请求路由到其他服务,同时处理 TLS。(如果你使用的是较旧的 Traefik v2 配置,Traefik v2 到 v3 迁移指南介绍了名称和规则的变更。)

启动堆栈后验证绑定地址:sudo ss -tlnp | grep 8080 应显示 127.0.0.1:8080,而不是 0.0.0.0:8080 或 *:8080。

命名卷与绑定挂载

db-data:/var/lib/postgresql/data 是命名卷:Docker 会在 /var/lib/docker/volumes/ 下创建并管理目录,然后将其挂载到容器中。另一种方式是绑定挂载 ./data:/var/lib/postgresql/data,它会将你在主机上选择的路径映射到容器中。

实践中可靠的划分方式是:仅供容器访问的数据使用命名卷,尤其是数据库。Docker 会按照镜像预期的所有权初始化卷,文件权限通常可以直接正常工作。需要从主机访问的文件使用绑定挂载,例如用文本编辑器修改的配置文件、通过 rsync 同步的媒体库,以及你希望路径清晰可见的任何内容。绑定挂载最常见的问题是所有权:镜像会使用固定 UID 运行进程(Miniflux 镜像使用 UID 65534,postgres:16-alpine 会降权到 UID 70),而你的主机目录属于 UID 1000,应用启动时会失败,并在日志中出现 permission denied。命名卷可以基本避免这类问题,但代价是数据位于 Docker 管理的路径中,后文会介绍该路径。

environment 和 .env,避免将密钥提交到 git

Compose 会从与 compose.yml 位于同一目录、名为 .env 的文件中填充 ${POSTGRES_PASSWORD},但前提是 shell 中没有预先定义该变量:在 shell 环境中导出的值优先于 .env 文件。之前实验遗留的 export POSTGRES_PASSWORD=... 会因此静默覆盖文件中的值,而 docker compose config(如下)可以显示最终采用的值。创建该文件:

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

使用 openssl rand -hex 24 生成真实值。这里有意使用十六进制,而不是 base64:该密码会进入 DATABASE_URL 连接字符串,而 base64 生成的 /、+ 和 = 字符会破坏 URL 解析。此问题最终表现为身份验证错误,而不是语法错误,排查可能耗费一整晚。.gitignore 这一行应在第一次提交之前加入:compose 文件可以安全发布和纳入版本控制,但 .env 文件绝不能这样处理;一旦密钥进入 git 历史记录,就应将其视为需要轮换的密钥。如果缺少变量就启动堆栈,Compose 会发出明确警告,并继续使用空字符串。对于 Postgres 密码而言,这会导致部署失败:

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

docker compose config 会输出完成变量插值的文件,这是检查容器实际接收内容的最快方式;请注意,其输出包含你的密钥。

depends_on 不会等待任何条件,除非添加 healthcheck

单独使用 depends_on: [db] 只控制启动顺序:Compose 先启动 Postgres,稍后启动应用,但此时 Postgres 可能还需要几秒才能接受连接。应用访问数据库时会失败,随后崩溃或重试,具体取决于应用的实现。

可靠的做法就是上面文件采用的方式:db 服务定义 healthcheck(Postgres 提供 pg_isready,正是用于此目的),应用则声明 depends_on,并使用 condition: service_healthy。Compose 启动数据库后每 10 秒轮询一次检查,只有检查通过后才启动 Miniflux。如果数据库始终未达到健康状态,例如密码错误或卷损坏,应用就不会启动,Compose 会告知你哪个依赖项失败:

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

该消息会将你引向 docker compose logs db,真正的错误位于其中。

restart: unless-stopped

在两个服务上设置 restart: unless-stopped,表示容器会在崩溃和 VPS 重启后恢复运行;但如果你明确执行 docker compose stop,容器会保持停止状态。另一种策略 always 会在手动停止后仍重新启动容器,这通常不是你的预期。不设置重启策略时,凌晨 4 点因内核更新而重启,可能会悄然导致服务停止,直到你发现问题。

日常操作

日常操作只需使用 5 条命令,并在项目目录中执行。

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 可以重复安全执行。它会将文件与实际状态进行比较,只处理配置或镜像已更改的服务。升级命令对会获取当前固定标签所指向的内容:postgres:16-alpine 下的补丁版本会更新;精确固定的版本不会更新,除非您手动修改它,这正是精确固定的作用。升级后旧镜像会逐渐累积;使用 docker image prune -f 回收磁盘空间。

下面介绍具有破坏性的命令,请特别注意:docker compose down 可以安全执行,因为容器和网络都是可重新创建的,而数据存储在 volume 中。docker compose down -v 还会删除指定的 volume。这意味着数据库会立即丢失,不会显示确认提示,也无法撤销。 -v 标志用于拆除实验环境;对于存放真实数据的 stack,应像对待 rm -rf 一样对待它。/var/lib/docker/volumes/ 下没有回收站。

要在运行中的容器内执行一次性 shell:docker compose exec db psql -U miniflux 会进入数据库,docker compose exec miniflux sh 会进入应用容器的 shell。

数据实际存储在哪里

命名卷会使用项目名前缀,因此目录 miniflux 中的 db-data 会变为 miniflux_db-data:

docker volume ls
docker volume inspect miniflux_db-data

inspect 的输出包含关键行:

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

该目录就是数据库所在位置。它位于主机文件系统中,由 root 拥有,并且在 down、升级和重建容器后仍会保留。备份必须完整包含该目录。

备份命名卷

标准做法是使用一次性容器,将卷以只读方式挂载,并同时挂载一个主机目录,然后通过 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;更好的做法是执行逻辑转储,因为逻辑转储在设计上就是一致的:

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

-T 会禁用 Compose 默认分配的伪终端。通过 TTY 传输转储输出可能会损坏数据。可以将其中一个命令加入 cron,并将结果复制到 VPS 外部;与受保护数据位于同一磁盘上的备份只是副本,不能算作备份。Nextcloud 指南正是围绕这两种模式构建完整的计划任务流程。

故障模式及其对应提示信息

permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock表示您尚未加入 docker 组,或者当前会话是在加入该组之前启动的。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 docker和 sudo journalctl -u docker -n 50会说明原因。在 VPS 上,最常见的原因是磁盘已满,请先运行 df -h /var/lib/docker。

Bind for 127.0.0.1:8080 failed: port is already allocated表示已有其他容器发布了该主机端口。docker ps会显示具体容器;几周前用于实验的 docker run 遗留容器通常就是原因。如果 docker ps 没有输出,则说明端口被非 Docker 进程占用:sudo ss -tlnp | grep 8080会显示该进程。

yaml: line 14: did not find expected key表示指定行或其上方附近存在缩进错误。Compose 文件使用 YAML:缩进为两个空格,只能使用空格,任何位置出现制表符都会导致解析失败。docker compose config可在不启动任何服务的情况下验证文件,建议每次修改后都运行一次,成本很低。

ufw 的意外情况不会输出任何错误,这正是它危险的地方:部署成功,ufw status看起来正常,但从外部执行端口扫描时,仍然可以发现您的数据库。重新检查上面的端口部分,检查每个 ports: 条目是否缺少 127.0.0.1: 前缀,并使用 curl http://your-vps-ip:8080 从另一台机器进行确认;连接被拒绝才是您希望看到的结果。

接下来,Traefik 指南会将这个单一堆栈转换为多个应用,并通过一个 HTTPS 入口统一提供服务;2026 年值得自行托管的服务则是可通过该入口运行的服务清单。当其中几个堆栈运行起来,并且每个服务都使用了各自的登录表单后,像 Authentik 这样的自托管 SSO 服务器可以将它们重新统一到同一个代理后的账户中。

像 VPS 上的 Minecraft 游戏服务器 这样的服务,是适合练习的第一个 Compose 项目。如果您更愿意从每天都会打开的应用开始,自托管的健身追踪器 openGym 是一个固定到 git tag、而不是镜像 tag 的小型服务栈;在注册第一个 passkey 之前,应先在其前面配置 TLS。照片通常是人们最想从他人云服务中迁出的数据,而 比较 PhotoPrism 和 Immich 可以在您决定为其中任一应用创建数据卷之前,明确所需的最低 RAM 和备份流程。当两个服务已不足以满足需求时,搭建类似 Notion 的工作区 AFFiNE 会将相同模式扩展到 4 个容器,也可以检验您是否已经养成固定 tag、健康检查和命名卷的习惯。

FAQ

为什么在尝试连接 Docker 守护进程套接字时收到 “permission denied” 错误?

您的用户不在 docker 组中,或者是在当前会话开始后才加入的,组成员身份仅在登录时生效。请运行 sudo usermod -aG docker $USER,然后执行 newgrp docker 或注销并重新登录,最后通过 id 进行确认。该组拥有与 root 等效的主机访问权限,因此只能将您愿意授予 sudo 权限的用户加入其中。

执行 docker compose down 会删除我的数据吗?

普通的 docker compose down 不会删除数据,它仅移除容器和项目网络;命名卷会保留,下次执行 up -d 时会重新挂载它们。docker compose down -v 是破坏性操作:它会删除命名卷(即您的数据库),且不会进行确认或提供撤销功能。除非持有已验证的备份,否则切勿在存有真实数据的堆栈上运行 -v。

docker-compose 和 docker compose 有什么区别?

docker-compose(带连字符)是 Compose v1,这是一个独立的 Python 二进制文件,Docker 已不再维护,不应在新服务器上安装。docker compose(带空格)是当前的 Compose,即 v2 及后续版本(自 2025 年 12 月起编号为 v5),它是 Docker CLI 的 Go 插件,通过 Docker 的 apt 仓库安装为 docker-compose-plugin。命令和 YAML 配置几乎完全兼容,因此当旧教程提到 docker-compose up 时,请直接输入 docker compose up。

为什么即使 ufw 阻止了端口,我仍然可以从互联网访问我的 Docker 容器?

因为 Docker 通过 iptables 的 PREROUTING 链中的 DNAT 规则发布端口,重写后的数据包会通过 FORWARD 路径传输,它们永远不会到达 ufw 规则生效的 INPUT 链。因此,ufw deny 8080 对已发布的容器端口无效。请从源头解决:仅发布到 127.0.0.1:,并通过反向代理暴露服务。

我应该使用命名卷还是绑定挂载?

仅供容器访问的数据(尤其是数据库)应使用命名卷,因为 Docker 会自动设置镜像所需的属主权限,权限配置可以直接生效。对于您也需要从主机处理的文件(如您编辑的配置文件、上传的媒体文件,或任何您希望路径直观的文件),应使用绑定挂载。如果容器在启动时因绑定挂载出现 permission denied 错误,请优先检查主机与容器之间的 UID 不匹配问题。