Ubuntu 24.04 VPS Docker Compose 入门指南
在 Ubuntu 24.04 VPS 上从 Docker 官方源安装 Engine 和 Compose v2,运行 Miniflux 与 PostgreSQL 双服务示例,避开 ufw 端口绕过问题,并安全备份命名卷。
构建内容
Docker Compose 是本网站几乎所有其他内容的基础。Nextcloud、Vaultwarden、n8n、Immich、Rocket.Chat,这些指南都会以“编写此 compose 文件”开头。本页面将解释该文件的实际含义。您将在 Ubuntu 24.04 上通过 Docker 官方 apt 软件源安装 Docker Engine 和 Compose v2 插件,然后运行一个真正的双服务堆栈: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,自 2023 年起已停止维护,也是旧教程失效的原因。现在的 Compose 是带空格的 docker compose,即一个 CLI 插件,应从与引擎相同的软件源安装。
如果系统中已经存在上述任何组件,请先清理,包括 docker-compose-v2(Ubuntu 自带的插件软件包),确保所有组件都来自同一个软件源:
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 软件源并安装:
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 可确认安装的是插件,而不是已停止维护的 v1 二进制文件。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 999 运行,而主机目录属于 UID 1000,应用启动时会失败,并在日志中出现 permission denied。命名卷可以基本避免这类问题,但数据会存放在 Docker 管理的路径下,后文会介绍这一点。
environment 和 .env:不要将密钥提交到 git
${POSTGRES_PASSWORD} 不会从你的 shell 中读取;Compose 会从与 compose.yml 位于同一目录、名为 .env 的文件中进行插值。创建该文件:
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 networkup -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-datainspect 的输出包含关键行:
"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 daemon socket 时权限被拒绝”?
您的用户不在 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 二进制程序,已于 2023 年结束生命周期,不应安装在新服务器上。docker compose(空格形式)是 Compose v2,是 Docker CLI 的 Go 插件,通过 Docker 的 apt 软件源以 docker-compose-plugin 的形式安装。命令和 YAML 基本完全兼容,因此旧教程要求运行 docker-compose up 时,应输入 docker compose up。
为什么 ufw 阻止了端口,我仍然可以从互联网访问 Docker 容器?
因为 Docker 会在 iptables 的 PREROUTING 链中使用 DNAT 规则发布端口,而重写后的数据包会沿着 Docker 自己链中的 FORWARD 路径传递,因此不会经过应用 ufw 规则的 INPUT 链。由此,ufw deny 8080 对已发布的容器端口不起作用。应从源头修复:将端口发布到 127.0.0.1:,并改用反向代理对外提供服务。
应该使用命名卷还是绑定挂载?
对于只由容器访问的数据,尤其是数据库,应使用命名卷。Docker 会设置镜像所需的所有权,权限通常可以直接正常工作。对于还需要从主机处理的文件,应使用绑定挂载,例如您要编辑的配置文件、要上传的媒体文件,以及您希望路径明确的任何内容。如果容器在启动时因绑定挂载出现 permission denied 而失败,首先应检查主机与容器之间的 UID 是否不匹配。