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

VPS 上的 Docker Compose 基础入门

在 Ubuntu 24.04 上安装 Docker Engine 与 Compose v2,编写一个真实的双服务 compose.yml,避开 ufw 端口发布陷阱,并备份数据卷。

您要搭建什么

Docker Compose 是本站几乎所有其他内容的底层基石。NextcloudVaultwardenn8nImmichRocket.Chat,每一篇指南开头都会说“编写这个 compose 文件”,而本文正是解释这个文件到底意味着什么的页面。您将在 Ubuntu 24.04 上从 Docker 官方的 apt 仓库安装 Docker Engine 和 Compose v2 插件,然后搭起一个真实的双服务栈:Miniflux(一个小巧的 RSS 阅读器)加上 PostgreSQL。因为这一对组合演练了更大型应用会用到的每一种模式:固定版本的镜像、带健康检查(healthcheck)的数据库、命名数据卷、放在 .env 文件中的密钥,以及只发布到 localhost 的端口。

安装只需五分钟。本指南的其余部分讲解那些日后会让您吃苦头的环节:docker 用户组其实是 root 的另一个名字、发布的端口会径直绕过您的 ufw 规则,以及 docker compose down 上那个会不加任何确认提示就删掉您数据库的选项。

前置条件:一台全新的 Ubuntu 24.04 KVM VPS、一个拥有 sudo 权限的用户,以及 1 GB 或更多的内存。已经装过 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,因为守护进程位于 /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(无根)模式才是真正的替代方案:守护进程本身以您这个非特权用户的身份运行。它是有代价的:低于 1024 的端口需要额外配置、网络要经过一个用户态的转接层并带来可测量的开销,还有些镜像在没有真正的 root 时会出问题。在一台只有单一管理员、且唯一的登录账户本就握有 sudo 的 VPS 上,这个用户组在实际使用中不会改变什么,本站每一篇指南也都以它为前提;只是千万别把它当作比 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:查看项目的发布页面,用您写这个文件时的当前版本。这样一来,升级就变成您有意做出的一行改动,在 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 路径,永远碰不到您 ufw 规则所在的 INPUTsudo ufw deny 8080 会报告成功,ufw status 会显示该端口已被拒绝,而服务仍然在向整个互联网应答。您的防火墙没有坏;它是被按设计绕过了。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

命名数据卷 vs 绑定挂载

db-data:/var/lib/postgresql/data 是一个命名数据卷:Docker 会在 /var/lib/docker/volumes/ 下创建并管理一个目录,再把它挂载进容器。另一种做法是绑定挂载,./data:/var/lib/postgresql/data,它映射的是您在主机上自选的一个路径。

在实践中站得住脚的分界:只有容器会碰的数据用命名数据卷,数据库尤其如此,因为 Docker 会用镜像期望的属主来初始化数据卷,文件权限便自然对得上。您会从主机去动的文件用绑定挂载:您用文本编辑器编辑的配置文件、您用 rsync 传入的媒体库,以及任何您希望路径一目了然的东西。绑定挂载最典型的失败是属主问题:容器以 UID 999 运行,而您的主机目录归 UID 1000 所有,于是应用一启动就死掉,日志里写着 permission denied。命名数据卷让这一类 bug 基本消失,代价是数据存放在由 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 什么都不等,除非您加上健康检查

光秃秃的 depends_on: [db] 只控制启动顺序:Compose 先启动 Postgres,片刻之后再启动应用,而此时 Postgres 距离能够接受连接还差几秒。应用去连数据库、失败,然后根据它写得好不好而崩溃或重试。

可靠的写法就是上面那个文件所用的:db 服务定义一个 healthcheck(Postgres 正是为此自带了 pg_isready),而应用用 condition: service_healthy 来声明 depends_on。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 点一次内核更新引发的重启,会悄悄把您的服务弄停,直到您自己发现。

日常操作的几个动词

日常的一切就是五条命令,在项目目录里运行。

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 是安全的,容器和网络都是一次性的,您的数据在数据卷里。docker compose down -v 会把命名数据卷也删掉。那就是您的数据库,瞬间消失,没有确认提示,也没有撤销。 -v 选项是为了拆掉实验环境而存在的;在装着真实数据的栈上,请像对待 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?:这是另一个问题,守护进程本身停了。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:另一个容器已经发布了那个主机端口。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 确认,连接被拒绝(connection refused)才是您想要的答案。

从这里往下,Traefik 指南 会把这个单一的栈变成一个 HTTPS 入口后面的许多应用,而 2026 年值得自托管的东西 就是一份拿来跑一遍的采购清单。

在 VPS 上搭一台 Minecraft 服务器 这样的游戏服务器,是一个友好的、适合上手练习的首个 Compose 项目。

FAQ

为什么会报 “permission denied while trying to connect to the 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-compose-plugin 从 Docker 的 apt 仓库安装。命令和 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 不匹配。