SSD Nodes Learn Hosting plans →
ガイド Matt Connor著者 Matt Connor ・更新日 2026-08-26

NextcloudをVPSで運用する方法 DockerとTLS

Docker Compose、Postgres、Redis、Let's Encrypt対応nginxでNextcloudをVPSに構築します。データディレクトリ、DB、設定を復元できるバックアップと、安全なアップグレード手順も解説します。

実際に構築するもの

このガイドでは、Docker Compose を使用して VPS 上で Nextcloud を稼働させ、前段に Let's Encrypt の TLS を配置し、実際に復元できるバックアップを設定します。構成は、プロキシを含む 4 つのコンテナです。公式の nextcloud イメージは loopback で待ち受けます。Postgres はファイルメタデータをすべて保持します。Redis はファイルロックを保持します。2 つ目の Nextcloud イメージは cron ループだけを実行します。ホスト上の nginx は、これらすべての前段で TLS を終端します。インストール自体は 20 分で完了しますが、重要なのはそこではありません。最初の 1 時間に決める 2 つの選択が、1 年後にもファイルを保持できるかどうかを左右します。SQLite ではなく実際のデータベースを使用すること、そしてデータディレクトリ、データベース、config.php を一貫性のある 1 つのセットとして取得するバックアップを用意することです。

このガイドでは、Ubuntu 24.04 LTS または Debian 13、Docker 独自のリポジトリからインストールした Compose v2 プラグイン付きの Docker Engine、そして DNS の A レコード(IPv6 を使用する場合は AAAA も)が、すでに VPS の cloud.example.com を指していることを前提とします。これらすべてには、自分で管理できるサーバーが必要です。他者の SaaS 上で TLS 終端やデータベースダンプを実行する方法はありません。

メモリを実際に消費するもの

Nextcloud のメモリ使用量を大きく左右するのは 3 つです。いずれも、厳密な意味での「Nextcloud」そのものではありません。

PHP ワーカー。 -apache イメージは、PHP インタープリターを保持するワーカープロセスで、同時実行される各リクエストを処理します。各ワーカーは、PHP がリクエストを強制終了するまで PHP_MEMORY_LIMIT まで増加する可能性があります。最悪時の常駐メモリは、おおむね 同時実行リクエスト数 × メモリ制限 です。デスクトップの同期クライアントは、ユーザーごとに複数の接続を並列で開きます。上限を決めるのはユーザー数ではなく、同時実行数です。

データベース。 Postgres は接続ごとにバックエンドを fork し、共有バッファーを常駐させます。ワーキングセットはバイト数ではなく、ファイル数 に応じて増加します。oc_filecache には、ユーザーごとにファイル 1 つあたり 1 行が格納されます。小さなファイルが 100,000 個あるデータベースは、大きなファイルが 100 個あるデータベースより負荷が高くなります。

プレビュー生成。 サムネイルの生成では、元画像をフル解像度でメモリに展開します。動画のプレビューでは ffmpeg を外部コマンドとして実行します。occ preview:generate-all を実行すると、このメモリ使用量の急増が連続して繰り返されるため、小規模な VPS を OOM killer の対象にする最も一般的な原因になります。

Redis のメモリ使用量は比較的少なくて済みます。後から追加する Collabora、全文検索、ウイルス対策スキャナーなどは、それぞれ固有のメモリ使用量を持つ別の常駐サービスです。有効化する前に、サイジング計画へ含めてください。

RAM に余裕がない場合の調整項目は次のとおりです。PHP_MEMORY_LIMIT を下げ、preview_max_x / preview_max_y / preview_max_filesize_image に上限を設定し、実際に閲覧する形式だけに enabledPreviewProviders を絞り込みます。また、データディレクトリがファイル容量の数倍まで気付かないうちに増加しないよう、trashbin_retention_obligationversions_retention_obligation を設定します。swap ファイルも追加してください。swap は低速であり、アップグレード中に OOM kill が発生するよりはましです。

SQLite が破綻する理由

Nextcloud は SQLite をサポートしており、公式イメージも問題なく SQLite を使用します。しかし、SQLite は使用しないでください。SQLite はデータベース全体のロックによって書き込みを直列化します。1 回に書き込めるのは 1 つの処理だけで、対象はファイル全体です。Nextcloud では、ファイルロック、アクティビティの行、キャッシュエントリ、ジョブの状態などが常に書き込まれます。さらに、1 台のデスクトップクライアントがディレクトリツリーを同期すると、多数のリクエストが並列に発生します。この条件では SQLSTATE[HY000]: General error: 5 database is locked と HTTP 500 が発生し、インスタンスが実用になり始めたまさにその時点で障害が表面化します。

後から occ db:convert-type で変換することは可能ですが、稼働中のデータセットに対する長時間の一括移行になります。移行を途中で分割することもできません。最初から Postgres または MariaDB を使用してください。

Compose ファイル

これを /srv/nextcloud/compose.yaml に配置し、Secret はモード 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 で現在のタグを確認してください。将来の一部の docker compose pulllatest を使うと、メジャーバージョンをまたいで更新されます。Nextcloud はこの更新方法をサポートしていません。

データディレクトリには、意図的に名前付きボリュームではなく bind mount を使用します。バックアップツールから直接指定できるパスのほうが、構成の簡潔さより重要です。イメージの 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 では、ufw の設定にかかわらず暗号化されていない Nextcloud がパブリックインターネットに公開されます。loopback にバインドすれば、パブリックインターフェースから切り離せます。これにより、ファイアウォールで許可する必要があるのはプロキシだけになります。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 を指定した単純な port-80 の server block を作成してから、certbot に書き換えさせます。HTTP-01 challenge の仕組み、更新用 timer、失敗する場合の詳細は、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-day 証明書を更新する systemd timer をインストールします。systemctl list-timers | grep certbot で存在を確認してください。有効化されていない更新 timer は、90-day で失効する仕組みと同じです。

プロキシ block 自体は次のとおりです。

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 には古い build が含まれており、同等の設定は listen 443 ssl http2; です。nginx -t で、使用中の build がどちらを受け付けるか確認できます。

client_max_body_size と長い read timeout により、大きな upload が途中で失敗するのを防ぎます。proxy_request_buffering off はファイル全体を先にプロキシのディスクへ spool せず、upload をそのまま転送します。

ホスト上の nginx は、1 つのアプリに対して最も簡単に動作する構成です。Nextcloud を他の container と同じ VPS で運用する場合は、複数のアプリ向けに Traefik を Docker Compose のリバースプロキシとして実行することで、routing と証明書発行を container label に移せます。そこでも同じ client_max_body_size と timeout の問題が middleware と transport の設定として現れます。

trusted_proxies と overwriteprotocol

ここで、多くの self-hosted Nextcloud インスタンスが誤設定されます。症状は原因と無関係に見えます。

X-Forwarded-Proto: https は、リクエストが trusted_proxies に記載されたアドレスから到着した場合にだけ適用されます。適用されない場合、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/12TRUSTED_PROXIES に設定します。範囲を広くしすぎると、任意のクライアントが X-Forwarded-For を偽装できます。誤った値を設定すると、すべてのログインがゲートウェイアドレスからのアクセスとして記録されます。その結果、ブルートフォース対策がインスタンス全体を一度にブロックし、管理画面には 「リバースプロキシのヘッダー設定が正しくないか、信頼されたプロキシから Nextcloud にアクセスしています。」 と表示されます。

OVERWRITECLIURL は cron コンテナーで重要です。このコンテナーには、ホスト名を推測できる受信リクエストがないためです。設定しないと、バックグラウンドジョブが localhost へのリンクを生成し、メール通知に使用できない URL が含まれます。

バックグラウンドジョブ: AJAX ではなく cron

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> という形式に従います。エイリアスを設定しておくと便利です。

バックアップ: 3 つすべて、または何もなし

ファイルシステムだけのバックアップでは、壊れたインスタンスしか復元できません。データディレクトリにはバイト列が保存されます。Postgres にはファイルキャッシュ、共有、ユーザー、アプリケーションの状態が保存されます。config.php にはデータベースの認証情報、インスタンス ID、パスワードソルトが保存されます。データベースなしでファイルを復元すると、Nextcloud はそれらを認識できません。config.php なしでデータベースを復元すると、データベースを開けません。古いデータベースを新しいデータディレクトリに対して復元すると、移動済みのファイルを指す共有が作成されます。

停止状態にしたインスタンスから、3 つすべてをバックアップします。

#!/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 がまだコピーしていないファイルを参照するデータベースを、いずれ取得することになります。スクリプトはタイムスタンプ付きのデータベースダンプを保持しますが、データディレクトリのローリングミラーは 1 つだけです。rsync --delete は実行のたびにこのミラーを上書きするため、ファイルコピーと組み合わせられるのは最新のダンプだけです。

次に、バックアップをサーバーの外部へ移します。バックアップ対象と同じ VPS に保存されたバックアップは、バックアップではなくコピーです。通常は、オブジェクトストレージまたは 2 台目のホストに対して restic を使用します。重複排除により、毎晩作成する tarball よりもデータディレクトリを効率よく扱えます。リポジトリの初期化から、夜間タイマー、復元訓練までの完全な構成は、restic を使った VPS の外部バックアップにまとめています。

復元は、単純に逆の手順を行えばよいわけではありません。起動直後のスタックはインストーラーを実行し、新しい config.php、新しいインスタンス ID、パスワードソルトを書き込みます。その新しい ID の上にダンプをインポートすると、セッションと共有トークンが壊れた状態になります。最初に古い 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 で一度この手順をリハーサルしてください。ディスク上のバイト列と Postgres 内のメタデータを分けて扱う点は、この構成の他のすべてのアプリにも共通します。そのため、ライブラリは取得してもデータベースを取得しない Immich のバックアップでは、空のタイムラインに復元されます

アップグレード: メジャーバージョンは一度に1つ

Nextcloud は、1 回につきちょうど1つのメジャーバージョンのみアップグレードできます。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 を実行します。イメージの entrypoint は、既存データに対して新しいコードを検出すると、occ upgrade を自動的に実行します。処理を中断しないでください。ログの出力が止まったら、docker compose exec -u www-data app php occ status を実行し、versionstring とアプリが有効な状態に戻っていることを確認します。

事故を防ぐ重要なルールは2つあります。メジャーバージョンを1つ上げて確認してから、次のバージョンを上げてください。また、app サービスのタグだけを変更しないでください。cron も同じバージョンに変更する必要があります。異なる2つの Nextcloud バージョンを1つのデータベースに接続すると、データ破損につながります。

実際に発生するエラー

「データディレクトリは他のユーザーから読み取り可能です。権限を 0770 に変更してください。」 bind mount されたディレクトリに、グループまたはその他のユーザー向けの読み取りビットが設定されています。sudo chmod 0770 /srv/nextcloud/datasudo chown -R 33:33 /srv/nextcloud/data を確認してください。

「データディレクトリが無効です。ルートに .ocdata という名前のファイルがあることを確認してください。」 bind mount の参照先が、Nextcloud が初期化したことのない場所、パスの入力ミス、または稼働中のインスタンス配下で差し替えられた空のディレクトリになっています。ホスト側のパスが volume の行と一致することを確認してください。

「信頼されていないドメイン経由のアクセスです。」 リクエストのホスト名が trusted_domains に登録されていません。NEXTCLOUD_TRUSTED_DOMAINS は初回インストール時にのみ適用されます。その後は稼働中に occ config:system:set trusted_domains 1 --value=cloud.example.com を設定してください。

502 Bad Gateway が発生し、/var/log/nginx/error.logconnect() 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 でボリュームを拡張するには、ボリュームの resize とファイルシステムの grow が必要です。容量が 100% に達してからではなく、余裕があるうちに計画的に実施すると、作業の負担を大幅に減らせます。ディスク使用量のアラートは、今すぐ設定してください。

2 つ目の壁は oc_filecache です。ファイル一覧の取得や同期スキャンは、行数が増えるほど遅くなります。対策にはデータベースの調整が必要です。Postgres は高速なストレージに置き、十分な shared memory を使用できるようにします。ゴミ箱とバージョンは retention 設定で削除し、永続的に蓄積させないでください。

3 つ目は、プレビュー生成が他の処理とリソースを奪い合うことです。小規模なサーバーでは、プレビュー provider の対象を絞り、作業時間中に occ preview:generate-all を実行しないでください。保存データの大半がスマートフォンのカメラロールなら、サムネイル生成は専用の photo server に任せるべきです。RAM、スマートフォンアプリ、バックアップコマンドの観点から PhotoPrism と Immich を比較では、Nextcloud 用のサーバーと併用した場合に必要なリソースを説明しています。

これ以上の追加機能については、別のマシンを用意するのが現実的です。Collabora と full-text search は、それぞれ独自のメモリ使用特性を持つ常駐サービスです。唯一のファイルコピーも保存しているサーバーに配置すると、メリットがないまま障害範囲が広がります。ブラウザー上のドキュメント編集が必要なら、OnlyOffice と Collabora を分ける vendor の RAM 最低要件と接続数制限によって、2 から 4 GB の VPS でどちらを実行できるかが決まります。ボリュームが用途に合わなくなったら、ファイルストレージを S3-compatible の primary storage に移します。ただし、これでバックアップが簡単になるわけではありません。データベースにはメタデータが残るため、bucket と同じタイミングで dump する必要があります。

インスタンスが実際のユーザーにサービスを提供するようになったら、Uptime Kuma を前段に配置し、sync client より先にダウンタイムを検知できるようにします。private cloud は 自分で運用する mail server と相性がよく、サービスの連携を手作業で設定したくない場合は、Cloudron、CasaOS、Coolify が自動化を担う各プラットフォームを比較しています。次に self-hosted search engine を導入する場合は、これまでとは異なる種類の問題に備えてください。SearXNG の 429 エラー は、SearXNG 自身の rate limiter、または upstream engine による VPS の IP アドレスのブロックが原因です。どちらなのかはログでしか判断できません。

FAQ

SQLite で Postgres の代わりに Nextcloud を実行できますか?

実行できます。公式イメージでも使用できます。ただし、1 台のデスクトップ同期クライアントが並列リクエストを発行すると、SQLSTATE[HY000]: General error: 5 database is locked と HTTP 500 エラーが発生します。SQLite はデータベース全体に対する書き込みロックを取得します。Nextcloud はファイルロック、アクティビティ行、ジョブ状態などを継続的に書き込むためです。Postgres または MariaDB で開始してください。occ db:convert-type は存在しますが、稼働中のデータに対する長時間の一括移行になります。

Nextcloud の VPS には実際にどの程度の RAM が必要ですか?

ユーザー数ではなく、同時実行数を基準に容量を決めてください。最悪時の常駐メモリは、おおむね同時実行リクエスト数に PHP_MEMORY_LIMIT を掛けた値に、Postgres の共有バッファー、接続ごとのバックエンド 1 つ、さらにプレビュー生成による一時的な増加分を加えた量です。2 GB のサーバーでも、プレビューを制限して swap を追加すれば、小規模な家庭用インスタンスを実行できます。Collabora や全文検索を追加する場合は、常駐サービス用にもう 1 組分のリソースを見積もってください。

nginx リバースプロキシの背後で大容量アップロードに失敗するのはなぜですか?

通常、プロキシ側の 2 つの設定が原因です。デフォルトの 1 MB のままの client_max_body_size はリクエストを途中で切り捨てます。また、短い proxy_read_timeout / proxy_send_timeout の値は、長時間の転送を途中で終了させます。両方に十分大きな値を設定し、proxy_request_buffering off を有効にしてスプールせずストリーム転送にし、アプリコンテナの PHP_UPLOAD_LIMIT も同じ基準まで引き上げてください。

Nextcloud がリダイレクトループになる、またはリバースプロキシについて警告するのはなぜですか?

コンテナからは nginx が 127.0.0.1 にあるようには見えません。Docker ブリッジのゲートウェイが 172.x のどこかにあるように見えます。そのアドレスが TRUSTED_PROXIES に含まれていないと、X-Forwarded-Proto: https ヘッダーは無視されます。その結果、Nextcloud は http:// URL を生成し、プロキシがそれを再びリダイレクトします。TRUSTED_PROXIES に実際のブリッジサブネットを設定し、OVERWRITEPROTOCOL: https を固定してください。

Nextcloud を 29 から 31 に直接アップグレードできますか?

できません。Nextcloud は、1 回のアップグレードで 1 つのメジャーバージョンだけをサポートします。バージョンを飛ばすと Updates between multiple major versions and downgrades are unsupported. で停止し、インスタンスはメンテナンスモードのままになります。バックアップを取得し、app サービスと cron サービスのタグを 1 つ先のメジャーバージョンに更新して、docker compose pull && docker compose up -d します。occ status で確認してから、同じ手順を繰り返してください。