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

在 VPS 上运行 Nextcloud:Docker、TLS 与备份

用 Docker Compose、Postgres、Redis 和 TLS 反向代理在自己的 VPS 上运行 Nextcloud,并配上让数据可恢复的备份与升级步骤。

您实际要搭建的是什么

本指南在一台 VPS 上用 Docker Compose 运行 Nextcloud,在它前面架设 Let's Encrypt TLS,并配置一套真正能还原的备份。四个容器加一个代理:官方 nextcloud 镜像监听回环地址,Postgres 保存每一份文件的元数据,Redis 保存文件锁,第二份 Nextcloud 镜像只运行定时任务循环,宿主机上的 nginx 在所有这些之前终止 TLS。安装本身只需二十分钟,而且它并不是关键。头一个小时里做出的两个决定,决定了一年后您是否还拥有自己的文件:用真正的数据库而不是 SQLite,以及一套能把数据目录、数据库和 config.php 作为一个一致集合一并捕获的备份。

本文假设您使用 Ubuntu 24.04 LTS 或 Debian 13,已从 Docker 官方仓库安装带 Compose v2 插件的 Docker Engine,并且已有一条把 cloud.example.com 指向该 VPS 的 DNS A 记录(若有 IPv6,再加一条 AAAA)。这一切都需要一台您自己掌控的服务器,在别人的 SaaS 上是没法完成 TLS 终止和数据库转储的。

容量规划:内存到底消耗在哪里

Nextcloud 的内存占用主要由三样东西决定,而其中没有一样是所谓的“Nextcloud”本身。

PHP 工作进程。 -apache 镜像用一个持有 PHP 解释器的工作进程处理每一个并发请求。每个工作进程在被 PHP 终止请求之前,最多可以增长到 PHP_MEMORY_LIMIT。您最坏情况下的常驻内存大致等于并发请求数 × 内存上限,而一个桌面同步客户端会为每个用户开启多个并行连接。设定上限的是并发量,而不是用户数量。

数据库。 Postgres 为每个连接派生一个后端进程,并把共享缓冲区常驻内存。它的工作集随文件数量增长,而不是随字节数增长:oc_filecache 中每个用户的每个文件都占一行。十万个小文件比一百个大文件对数据库的压力更重。

预览生成。 生成缩略图会把源图像按全分辨率解码进内存。视频预览则调用外部的 ffmpeg。运行 occ preview:generate-all 会一次接一次地反复制造这种内存尖峰,是把一台小 VPS 逼进 OOM killer 最常见的方式。

相比之下 Redis 的开销很低。您之后加装的任何东西,比如 Collabora、全文搜索、防病毒扫描器,都是一个独立的常驻服务,各有各的占用,应当在启用之前就纳入您的容量规划。

如果您的内存吃紧,可用的调节手段有:调低 PHP_MEMORY_LIMIT,限制 preview_max_x / preview_max_y / preview_max_filesize_image,把 enabledPreviewProviders 裁剪到您实际会浏览的格式,并设置 trashbin_retention_obligationversions_retention_obligation,以免数据目录悄悄膨胀到文件本身大小的好几倍。再加一个交换文件。交换很慢,但升级到一半被 OOM 杀掉更糟。

为什么 SQLite 会出问题

Nextcloud 自带 SQLite 支持,官方镜像也乐意使用它。请不要这么做。SQLite 用一把数据库级别的锁来串行化写入:整个文件在同一时刻只允许一个写入者。而 Nextcloud 一直在写,包括文件锁、活动记录、缓存条目、任务状态,一个桌面客户端同步一棵目录树就会发出大量并行请求。在这种模式下您会遇到 SQLSTATE[HY000]: General error: 5 database is locked 和 HTTP 500 错误,而且故障恰恰在实例开始变得有用的时候出现。

之后再用 occ db:convert-type 转换是可行的,但那是在活跃数据集上进行的一次漫长、全有或全无的迁移。一开始就用 Postgres 或 MariaDB。

Compose 文件

把下面的内容放进 /srv/nextcloud/compose.yaml,密钥则放在同目录下权限为 600.env 文件里。

services:
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    volumes:
      - db:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: nextcloud
      POSTGRES_USER: nextcloud
      POSTGRES_PASSWORD: ${DB_PASSWORD}

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --requirepass ${REDIS_PASSWORD}

  app:
    image: nextcloud:31-apache
    restart: unless-stopped
    depends_on: [db, redis]
    ports:
      - "127.0.0.1:8080:80"
    volumes:
      - html:/var/www/html
      - /srv/nextcloud/data:/var/www/html/data
    environment:
      POSTGRES_HOST: db
      POSTGRES_DB: nextcloud
      POSTGRES_USER: nextcloud
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      REDIS_HOST: redis
      REDIS_HOST_PASSWORD: ${REDIS_PASSWORD}
      NEXTCLOUD_ADMIN_USER: admin
      NEXTCLOUD_ADMIN_PASSWORD: ${ADMIN_PASSWORD}
      NEXTCLOUD_TRUSTED_DOMAINS: cloud.example.com
      TRUSTED_PROXIES: 172.16.0.0/12
      OVERWRITEPROTOCOL: https
      OVERWRITECLIURL: https://cloud.example.com
      APACHE_DISABLE_REWRITE_IP: "1"
      PHP_MEMORY_LIMIT: 512M
      PHP_UPLOAD_LIMIT: 10G

  cron:
    image: nextcloud:31-apache
    restart: unless-stopped
    entrypoint: /cron.sh
    depends_on: [db, redis]
    volumes:
      - html:/var/www/html
      - /srv/nextcloud/data:/var/www/html/data

volumes:
  db:
  html:

固定主版本标签,并在照抄 31 之前先到 Docker Hub 上查一下当前的版本号。latest 会在将来某次 docker compose pull 时把您跨越一个主版本边界,而 Nextcloud 并不支持这种跳跃。

数据目录特意用绑定挂载而不是命名卷:一个能让备份工具直接对准的路径,比整洁更有价值。用镜像里的 www-data UID 创建它,并赋予 Nextcloud 要求的权限:

sudo mkdir -p /srv/nextcloud/data
sudo chown -R 33:33 /srv/nextcloud/data
sudo chmod 0770 /srv/nextcloud/data

注意端口发布方式:127.0.0.1:8080:80。Docker 通过写入 DNAT 规则来发布端口,这些规则在 ufw 的 INPUT 链看到数据包之前就被评估,因此不带地址的 8080:80 会把一个未加密的 Nextcloud 直接暴露在公网上,无论 ufw 怎么设置。绑定到回环地址能让它远离公网接口。这样防火墙只需放行代理,而且如果您不愿把 SSH 一直向整个互联网敞开,通过自建 WireGuard VPN 访问 VPS可以让您把 22 端口从公开规则里彻底移除:

sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

docker compose up -d 把它启动起来,然后观察 docker compose logs -f app。首次启动会把整棵应用目录树复制进卷里并运行安装程序,在这一步完成之前容器不会有任何响应。

TLS 与反向代理

从发行版仓库安装 nginx 和 certbot,创建一个带正确 server_name 的纯 80 端口 server 块,然后让 certbot 去改写它。HTTP-01 挑战的机制、续期定时器和各种失败模式,在在 Ubuntu 24.04 上用 certbot 和 nginx 签发 Let's Encrypt 证书中有完整介绍:

sudo apt install nginx certbot python3-certbot-nginx
sudo certbot --nginx -d cloud.example.com

Certbot 会加入 ssl_certificate 相关行以及 :80:443 的跳转,并安装一个用于续期这张 90 天证书的 systemd 定时器。用 systemctl list-timers | grep certbot 确认它确实存在,一个从未启用的续期定时器就是一条 90 天的引信。

代理块本身:

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name cloud.example.com;

    # certbot manages ssl_certificate / ssl_certificate_key here

    add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;

    client_max_body_size 10G;
    client_body_timeout 300s;

    location = /.well-known/carddav { return 301 /remote.php/dav; }
    location = /.well-known/caldav  { return 301 /remote.php/dav; }

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host  $host;
        proxy_request_buffering off;
        proxy_buffering off;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }
}

在 nginx 1.25 及更新版本上,加一行 http2 on;。Ubuntu 24.04 自带的是较旧的构建,对应的写法是 listen 443 ssl http2;nginx -t 会告诉您您的构建接受哪一种。

client_max_body_size 和较长的读取超时正是让大文件上传不会中途夭折的关键。proxy_request_buffering off 会把上传流式转发出去,而不是先把整个文件缓冲到代理的磁盘上。

宿主机上的 nginx 对单个应用来说是最简单的可行方案。如果 Nextcloud 将要和其他容器共用这台 VPS,用 Traefik 作为 Docker Compose 反向代理服务多个应用会把路由和证书签发移进容器标签里,而同样的 client_max_body_size 和超时问题在那里会以中间件和传输设置的形式再次出现。

trusted_proxies 与 overwriteprotocol

绝大多数自建 Nextcloud 实例都栽在这里,而且症状看起来和病因毫无关联。

只有当请求来自 trusted_proxies 中列出的地址时,X-Forwarded-Proto: https 才会被采信。当它不被采信时,Nextcloud 认为请求是普通 HTTP,于是发出 http:// 的 URL;代理把这些跳转到 HTTPS;浏览器跟着跳;Nextcloud 又发出 http://。这就是那个跳转循环。OVERWRITEPROTOCOL: https 会强制固定协议,不管别的。

TRUSTED_PROXIES 的陷阱在于:Nextcloud 看到的地址不是 127.0.0.1。nginx 运行在宿主机上并连接到一个已发布的端口,因此容器看到的是 Docker 网桥网关,也就是 172.x 里的某个地址。找出真正的子网:

docker network inspect nextcloud_default \
  -f '{{range .IPAM.Config}}{{.Subnet}}{{end}}'

把那个 CIDR(或者覆盖它的 172.16.0.0/12)填进 TRUSTED_PROXIES。设得太宽,任何客户端都能伪造 X-Forwarded-For;设错了,每次登录看起来都来自网关地址,暴力破解防护会一下子把您的整个实例封掉,而管理概览会显示“反向代理头配置不正确,或者您正在从一个受信任的代理访问 Nextcloud。”

OVERWRITECLIURL 对 cron 容器很重要,因为它没有传入请求可以据此推断主机名。缺了它,后台任务会生成指向 localhost 的链接,邮件通知里发出的也是无法使用的 URL。

后台任务:用 cron,而不是 AJAX

Nextcloud 默认的任务运行方式是 AJAX:任务作为某人加载页面的副作用来执行。凌晨 04:00 没人在浏览,于是回收站过期、版本清理、预览和联合重试都会停滞,第一个症状就是数据目录永不停止地增长。上面的 cron 服务会对同一批卷运行官方的 /cron.sh 循环。告诉 Nextcloud 去指望它:

docker compose exec -u www-data app php occ background:cron

每一条 occ 命令都遵循这个形式:docker compose exec -u www-data app php occ <command>。值得为它设个别名。

备份:三样一起,否则一样都不算

只备份文件系统,还原出来的是一个坏掉的实例。数据目录保存字节;Postgres 保存文件缓存、共享、用户和应用状态;config.php 保存数据库凭据、实例 ID 和密码盐。只还原文件而不还原数据库,Nextcloud 看不到它们。只还原数据库而不还原 config.php,它打不开数据库。用一份旧数据库去配一个较新的数据目录,您会得到指向已移动文件的共享。

把这三样都备份下来,而且要从一个已静止的实例上备份:

#!/usr/bin/env bash
set -euo pipefail
cd /srv/nextcloud
DEST="/var/backups/nextcloud/$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "$DEST"

occ() { docker compose exec -T -u www-data app php occ "$@"; }

occ maintenance:mode --on
trap 'occ maintenance:mode --off' EXIT

docker compose exec -T db \
  pg_dump -U nextcloud --clean --if-exists nextcloud | gzip > "$DEST/db.sql.gz"

docker compose exec -T app \
  tar -C /var/www/html -cf - config custom_apps themes > "$DEST/app.tar"

rsync -a --delete /srv/nextcloud/data/ /var/backups/nextcloud/data/

维护模式正是让数据库转储和文件副本彼此一致的东西。跳过它,您迟早会捕获到一个引用了 rsync 尚未复制到的文件的数据库。请注意,这个脚本保留了带时间戳的数据库转储,但数据目录只保留一份滚动镜像,rsync --delete 每次运行都会覆盖它,所以只有最新的那份转储才和文件副本配对。

然后把它挪出这台机器。一份和它所备份的东西住在同一台 VPS 上的备份,是副本,不是备份。对准对象存储或另一台主机的 restic 是通常的答案,它的去重处理数据目录的效果远比每晚一个 tar 包要好。从仓库初始化到每晚定时器再到还原演练的完整配置,都在用 restic 做离机 VPS 备份里。

还原并不只是简单地把上面的步骤倒过来。一个刚启动的栈会运行安装程序并写入一份全新的 config.php,也就是一个新的实例 ID 和密码盐,而把转储导入到这个新身份之上,会留下失效的会话和共享令牌。先把旧身份放回去,按这个顺序:

docker compose up -d && docker compose stop app cron    # create the volumes, then halt the app
sudo rsync -a --delete /var/backups/nextcloud/data/ /srv/nextcloud/data/
docker compose run --rm -T --entrypoint "" app \
  tar -C /var/www/html -xf - < app.tar                  # the original config.php returns
gunzip -c db.sql.gz | docker compose exec -T db psql -U nextcloud -d nextcloud
docker compose start app cron
docker compose exec -T -u www-data app php occ maintenance:mode --off
docker compose exec -T -u www-data app php occ files:scan --all

files:scan 会把文件缓存和磁盘上实际存在的内容对上。在真正需要它之前,先在一台备用 VPS 上把这套流程演练一遍。

升级:一次一个主版本

Nextcloud 只支持一次升级恰好一个主版本。从 29 直接跳到 31 不会优雅地失败,它会以 Exception: Updates between multiple major versions and downgrades are unsupported. 失败,并把您留在维护模式里。

Docker 下的升级是:先做一份备份,把 appcron 两个服务里的标签从 31 改成 32,然后 docker compose pull && docker compose up -d,再 docker compose logs -f app。镜像入口点会检测到较新的代码与现有数据不匹配,并自行运行 occ upgrade。不要打断它。等日志安静下来,运行 docker compose exec -u www-data app php occ status,检查 versionstring,并确认各应用重新启用了。

有两条能救您的规则:升一个主版本,验证,然后再升下一个。以及,绝不要在改 app 服务的标签时不把 cron 一起改到匹配,两个不同的 Nextcloud 版本对着同一个数据库就是一条走向损坏的路。

您实际会遇到的报错

“您的数据目录可被其他用户读取。请把权限改为 0770。” 绑定挂载的目录带了组或其他用户的读取位。sudo chmod 0770 /srv/nextcloud/data 以及 sudo chown -R 33:33 /srv/nextcloud/data

“您的数据目录无效。请确保根目录下有一个名为 .ocdata 的文件。” 绑定挂载指向了一个 Nextcloud 从未初始化过的地方,可能是路径里的一个拼写错误,也可能是有人把一个空的新目录换到了一个正常工作的实例底下。检查宿主机路径是否和卷那一行相符。

“通过不受信任的域名访问。” 请求中的主机名不在 trusted_domains 里。NEXTCLOUD_TRUSTED_DOMAINS 只在首次安装时生效,之后要在线设置:occ config:system:set trusted_domains 1 --value=cloud.example.com

502 Bad Gateway,在 /var/log/nginx/error.log 里带有 connect() failed (111: Connection refused) while connecting to upstream。nginx 在 127.0.0.1:8080 上什么都没连到。要么是容器还在初始化(查看 docker compose logs app),要么是它退出了(docker compose ps),要么是发布那一行和 proxy_pass 的端口对不上。用 ss -ltnp | grep 8080 确认。

跳转循环,或者管理概览里的“不安全”警告。 缺了 OVERWRITEPROTOCOL: https,或者 TRUSTED_PROXIES 里不包含 Docker 网关子网。参见上面的代理小节。

LockedException: "files/..." is locked 设了 REDIS_HOST 时,镜像会把 Redis 配成锁定后端,陈旧锁很少见。不设它,锁就存在数据库表 oc_file_locks 里,一个在写入途中被杀掉的请求会留下残余的行。在动手清理锁行之前,先确认 Redis 确实在用,occ config:system:get memcache.locking 应当返回那个 Redis 类。

“PHP 内存上限低于推荐值 512MB。” 提高 PHP_MEMORY_LIMIT 并重建容器。记住这对您最坏情况下的上限意味着什么。

规模变大时会先坏在哪里

第一道墙是数据目录长得超过了卷的容量。在 VPS 上给卷扩容是一次调整大小加上一次文件系统扩展,有计划地做远比在 100% 满的时候做要省心,所以现在就为磁盘用量设告警,别等以后。

第二道墙是 oc_filecache。文件列表和同步扫描会随着行数增多而变慢,解法是数据库层面的功夫:把 Postgres 放在快速存储上,让它用上足够的共享内存,并用保留策略去清理回收站和版本,而不是任由它们永远累积。

第三道墙是预览生成和其他一切争抢资源。在一台小机器上,把预览提供程序保持得窄一些,绝不在工作时间运行 occ preview:generate-all

除此之外,实话是那些附加组件想要自己的机器。Collabora 和全文搜索是各有各内存画像的独立常驻服务,把它们放在那台同时也保存着您文件唯一副本的机器上,只会白白扩大故障域而没有好处。当卷不再是合适的形态时,把文件存储迁到兼容 S3 的主存储上,但要注意这会让备份更难而不是更容易:元数据仍然在数据库里,而且它必须和存储桶同步转储。

一旦实例开始服务真实用户,就在它前面放一个Uptime Kuma,让您在同步客户端之前就知道停机。一朵私有云和您自己的邮件服务器搭配得很好,而如果您不愿手工把各种服务接线到一起,Cloudron、CasaOS 和 Coolify对比了那些替您完成这件事的平台。

FAQ

Nextcloud 能用 SQLite 而不用 Postgres 吗?

能,官方镜像也允许,但一个发出并行请求的桌面同步客户端就会撞上 SQLSTATE[HY000]: General error: 5 database is locked 和 HTTP 500。SQLite 会取一把数据库级别的写锁,而 Nextcloud 一直在写,包括文件锁、活动记录、任务状态。请从 Postgres 或 MariaDB 起步;occ db:convert-type 确实存在,但它是在活跃数据上进行的一次漫长、全有或全无的迁移。

一台 Nextcloud VPS 到底需要多少内存?

按并发量而不是用户数量来规划。最坏情况下的常驻内存大致是并发请求数乘以 PHP_MEMORY_LIMIT,加上 Postgres 的共享缓冲区和每个连接一个后端,再加上预览生成尖峰到的那部分。一台 2 GB 的机器,如果您限制预览并加上交换,能跑一个小型家庭实例;加上 Collabora 或全文搜索,您就是在为第二套常驻服务做容量规划了。

为什么大文件上传在 nginx 反向代理后面会失败?

通常是代理上的两项设置解释了这一点:client_max_body_size 保持在 1 MB 的默认值会把请求截断,而过短的 proxy_read_timeout / proxy_send_timeout 值会在中途杀掉长传输。把两者都设得慷慨些,打开 proxy_request_buffering off 以流式转发而不是缓冲,并把应用容器上的 PHP_UPLOAD_LIMIT 提高到相匹配。

为什么 Nextcloud 会陷入跳转循环,或者对反向代理发出警告?

容器在 127.0.0.1 看不到 nginx,它看到的是 Docker 网桥网关,位于 172.x 里的某处。当那个地址不在 TRUSTED_PROXIES 里时,X-Forwarded-Proto: https 头会被忽略,Nextcloud 发出 http:// 的 URL,代理再把它们弹回来。把 TRUSTED_PROXIES 设成真正的网桥子网,并固定 OVERWRITEPROTOCOL: https

我能把 Nextcloud 从 29 直接升到 31 吗?

不能。Nextcloud 每次升级只支持一个主版本,跳级会以 Updates between multiple major versions and downgrades are unsupported. 停下,并把实例留在维护模式里。先备份,把 appcron 两个服务的标签升一个主版本,docker compose pull && docker compose up -d,用 occ status 验证,然后重复。