Crowi

デプロイ構成

crowi/crowi-web イメージを api に向ける方法 — 同一オリジン + リバースプロキシ(推奨)、クロスオリジンの制約、Vercel / PaaS、ローカル開発

Crowi のデプロイは api(Hono)と web(Next.js)の 2 つのサービスで構成されます。web 側で大事なのは、ブラウザがどうやって api に到達するかです。

推奨する構成は、ブラウザには単一オリジンを見せ、前段にリバースプロキシを置いて パスごとに api / web へ振り分ける形です。以下ではその理由と、代替案のコストを説明します。

モデル: 1 つの公開オリジン、2 つの認証モード

「api が公開到達可能であること」と「ブラウザが api をクロスオリジンで叩くこと」は 別の問題です。前段のリバースプロキシは、この 2 つを 1 つのオリジンで両立させます。

認証モード利用者URLクロスオリジン?
Bearer トークンCLI / MCP / OAuth クライアント、そしてブラウザ自身(ページ API・JSON 更新。アクセストークンは localStorage に保持し Authorization で送る)https://wiki.example.com/api/... に Authorization: Bearer …可(トークンはオリジンを越える)
WebSocket トークンブラウザ。realtime の 3 経路(/collab・/presence・/notifications)向け。ブラウザは WS handshake に Authorization ヘッダを付けられないため(リアルタイム共同編集の運用 参照)、認証済み Bearer 呼び出しで発行される短命・単一用途のトークンを、WebSocket 接続時に ?token= クエリパラメータとして渡すwss://wiki.example.com/collab?token=…可(トークンはヘッダではなく URL に乗る)
Cookie / 相対ブラウザ。ただしヘッダなしで取得される添付配信 3 経路のみ(GET /api/attachments/{id}[/original]、GET /api/attachments/by-key/*)。<img src> や直接ナビゲーションはブラウザが JS フックなしで組み立てるため、Authorization ヘッダを付けられないページと同一オリジン不可(first-party cookie 前提)

つまり、公開する api は proxy が公開する <origin>/api の 1 つだけです。ブラウザも ほとんどのリクエストは他のクライアントと同じ方法で認証します。localStorage から読んだ Bearer トークンを使い、realtime の WebSocket 3 経路については、同じ Bearer 接続の上で発行される 短命な WebSocket トークンをクエリパラメータとして渡します(WS handshake には カスタムヘッダを付けられないため)。インライン <img> 添付とファイルダウンロードだけは、 ブラウザがヘッダを付けられないまま自分で取得するため first-party の crowi.accessToken cookie にフォールバックします。この cookie フォールバックは 上記の添付配信 3 経路だけに絞られており、それ以外のルート(添付 API の upload / meta / delete を含む)は cookie の有無に関わらず Bearer ヘッダが必須です。proxy があるおかげで、 CLI は wiki.example.com/api を Bearer で叩け、ブラウザは同じオリジンを自身の Bearer トークン(と、配信 3 経路向けの cookie)で使えます。非公開にしておくのは api の内部 アドレス(http://api:3000)だけで、外部クライアントはそこを使いません。

web イメージが api URL を解決する仕組み

配布される crowi/crowi-web イメージは絶対 API URL を一切焼き込みません。 URL は次の 3 つに分けて考えます。

  • ブラウザ → api(HTTP): 既定でブラウザは相対パス(/api/...・/files/...)を 自身のオリジンに送り、前段 proxy が api へ振ります。前段 proxy を置かずに web サーバを直接公開する場合は、web 内蔵の proxy でも /api・/files を転送できます。 ただし有効なのは dev か、宛先が焼き込まれている場合だけです(下記 (D) と Vercel の注記を参照)。
  • ブラウザ → api(WebSocket): realtime(/collab・/presence・/notifications)は web 内蔵の proxy を通せません(内蔵 proxy は HTTP 専用で upgrade を落とすため)。ブラウザは既定で window.location から WS URL を導出し、前段 proxy がそのパスを api へ振ります。
  • クロスオリジン用の上書き: NEXT_PUBLIC_API_URL(ブラウザ HTTP)と NEXT_PUBLIC_COLLAB_URL(ブラウザ WS)は実行時に読まれます。root layout が リクエストごとにコンテナ env から window.__ENV を同期 inline で注入するため、 同一イメージのまま別オリジンの api を向けられます(再ビルド不要)。

これにより、1 度ビルドしたイメージがどの api 先でも動きます(再ビルドは不要で、起動時の env だけで済みます)。同一オリジン (A) では NEXT_PUBLIC_* の上書きを未設定のまま proxy を前段に置きます。

(A) 同一オリジン + リバースプロキシ — 推奨

両サービスの前段にリバースプロキシ(Caddy / nginx / Traefik)を 1 つの公開オリジンで置きます。 ブラウザはそのオリジンだけと話し、proxy がパスで振り分けます。

                          ┌───────────────────────────────────────────┐
  browser ──HTTPS──▶ reverse proxy (one origin, e.g. wiki.example.com) │
                          │  /api/*           → api  :3000              │
                          │  /files/*         → api  :3000              │
                          │  /collab/*        → api  :3000 (WS upgrade) │
                          │  /presence/*      → api  :3000 (WS upgrade) │
                          │  /notifications/* → api  :3000 (WS upgrade) │
                          │  /                → web  :3000              │
                          └───────────────────────────────────────────┘
                                     │              │
                                ┌────▼───┐    ┌─────▼──┐
                                │  web   │    │  api   │──▶ MongoDB / Redis / …
                                └────────┘    └────────┘

web コンテナでは NEXT_PUBLIC_API_URL / NEXT_PUBLIC_COLLAB_URL を未設定のままに します。ブラウザは相対 HTTP パスを使い、WS URL は window.location から導出するので、 イメージに api 固有の情報は焼き込まれません。

Caddy(最小・自動 HTTPS + WS)

Caddy は WebSocket の Upgrade を自動で通し、TLS も自動取得します。

wiki.example.com {
	# HTTP API + 添付 + OAuth/well-known メタデータ → api
	# `/.well-known/*` は OAuth discovery(`oauth-authorization-server`)専用に
	# 絞らず広く振っている。web は standalone build 時にこのパスを絶対 URL で
	# rewrite できない(web 内蔵 proxy の宛先が build 時に固定される)ため、前段 proxy が
	# api に直送する必要がある。ACME challenge(`/.well-known/acme-challenge/*`)
	# は Caddy の TLS 自動化が reverse_proxy より前の内部ハンドラで処理するため
	# 衝突しない。
	@api path /api/* /files/* /.well-known/*
	reverse_proxy @api api:3000

	# realtime WebSocket → api(Upgrade は自動で透過)
	# 共同編集クライアントは素の `/collab` に接続するので bare パスも列挙する
	@ws path /collab /collab/* /presence /presence/* /notifications /notifications/*
	reverse_proxy @ws api:3000

	# それ以外 → web
	reverse_proxy web:3000
}

nginx(Upgrade ヘッダは手動)

location /api/         { proxy_pass http://api:3000; }
location /files/       { proxy_pass http://api:3000; }
location /.well-known/ { proxy_pass http://api:3000; }  # OAuth discovery 等

# realtime WebSocket — Upgrade ヘッダの透過が必須
# 末尾の (/|$) で素の `/collab`(セグメントなし)も拾う
location ~ ^/(collab|presence|notifications)(/|$) {
  proxy_pass http://api:3000;
  proxy_http_version 1.1;
  proxy_set_header Upgrade    $http_upgrade;
  proxy_set_header Connection "upgrade";
  proxy_read_timeout 3600s;   # 長時間 WS 接続を維持
}

location / { proxy_pass http://web:3000; }

3 つの realtime namespace(/collab・/presence・/notifications)は Upgrade / Connection の透過が必須です。透過しないと WebSocket upgrade が失敗し、 リアルタイム共同編集 / プレゼンス / 通知ベルが気づかないうちに非リアルタイム動作にフォールバック します(他の機能は動きます)。さらに共同編集クライアントは素の /collab (末尾セグメントなし)に接続するため、マッチャには /collab/* だけでなく 素の /collab も含める必要があります(上記の Caddy / nginx 例は対応済み)。 リアルタイム共同編集の運用 を参照してください。

アップロードの body size 上限

添付アップロードの上限は既定 50MB です(CROWI_UPLOAD_MAX_BYTES で下げられます。 詳しくは 環境変数 を参照してください)。ただしこれは api プロセス内で判定される 値でしかありません。前段にリバースプロキシを置く場合、プロキシ自身の body size 上限は crowi の上限より少し大きい値に設定する必要があります(同じ値では不十分な理由は下記)。

nginx の client_max_body_size の既定は 1MB です。 何も設定しないと、crowi が 50MB まで許可していても nginx が 1MB で 413 を返し、リクエストは api に届きません (api 側のログにも残りません)。上記の nginx 設定例の /api/ ロケーションに、次のように 足してください。

location /api/ {
  proxy_pass http://api:3000;
  client_max_body_size 51m;  # crowi の CROWI_UPLOAD_MAX_BYTES(既定 50MB)+ multipart のフレーミング分の余裕(下記参照)
}

プロキシの上限は crowi の上限と完全に同じ値ではなく、少し上に設定してください。 client_max_body_size はリクエストボディ全体を見て判定しますが、multipart アップロードは 中身のファイルより常にいくらか大きくなります(境界文字列・各パートのヘッダ・他のフォーム フィールド分)。crowi 自身も、このフレーミングのオーバーヘッドを自分の上限に対して最大 64KiB まで許容してから拒否します。プロキシの上限を CROWI_UPLOAD_MAX_BYTES と完全に同じ値に すると、crowi なら受け付けるはずのリクエストをプロキシが 413 で弾いてしまうことがあります。 1MB の余裕(既定 50MB に対して 51m)を見ておけば、現実的なフレーミングのオーバーヘッドより 十分に大きく、安全です。

Caddy には既定の body size 制限がありません。 上記の Caddy 設定例のままで、 crowi の上限一杯までアップロードが通ります。

プロキシの上限が crowi の上限より小さいと、CLI の pre-flight チェック(GET /attachments/upload-policy を見て判定)は通過するのに、実際のアップロードでは プロキシが 413 を返す状態になります。CLI と、エディタの paste / ドラッグ&ドロップ 経路は、この 413 が crowi 自身の応答形式でないことを検出して「前段のリバースプロキシが 拒否した」旨を表示します(添付一覧などからの直接アップロードは汎用のエラー表示に なります)。いずれにせよ根本的な対処は、運用者がプロキシの body size 上限を crowi の 上限より少し大きい値に上げることです (上記「アップロードの body size 上限」を参照してください。完全に同じ値では crowi が受け付ける リクエストが 413 になることがあります)。

添付ファイルの配信とヘッダ

添付ファイルの配信は、api がレスポンスヘッダで安全性を確保しています。前段のリバースプロキシでは、次のヘッダを書き換えたり削ったりしないでください。

ヘッダ役割
Content-Typeapi がサーバ側の許可リストに固定した値。インライン表示できる種別以外は application/octet-stream に置き換えられます
Content-Dispositionインライン表示しない添付をダウンロードさせます
X-Content-Type-Options: nosniffブラウザが中身から種別を推測して、固定した Content-Type を迂回するのを防ぎます
Content-Security-Policy: sandboxインライン表示される添付 (PDF を除く) に付きます。SVG のようにスクリプトを持てる形式でも、スクリプト実行不可・オリジンなしでしか描画されません

どの種別がインライン表示になるかは 添付ファイル を参照してください。ヘッダがブラウザまで届くことが前提の設計なので、プロキシで応答ヘッダを正規化している場合は添付配信の経路 (/api/attachments/*・/files/*) を対象外にしてください。

添付の URL は認証を必要とします。ブラウザが <img> やファイルダウンロードで取得する経路だけは、ヘッダを付けられないため first-party cookie にフォールバックします。プロキシで cookie を落とさないでください。

(B) web と api を別ホストに(クロスオリジン)— 限定対応

web と api が別オリジン(例: web = https://wiki.example.com、api = https://api.example.com)で、前段に単一 proxy を置かない場合、対応は部分的です。 Crowi では (A) を推奨します。

動くのはブラウザの HTTP API 呼び出しです。web コンテナにブラウザ向けの api URL を起動時に 設定します。実行時の設定なので再ビルドは不要です。

# WEB コンテナ(実行時 env・再ビルド不要):
NEXT_PUBLIC_API_URL=https://api.example.com     # ブラウザ → api HTTP(絶対)
NEXT_PUBLIC_COLLAB_URL=wss://api.example.com     # ブラウザ → api WebSocket
# API コンテナ:
CLIENT_URL=https://wiki.example.com              # CORS allow-origin(完全一致)

インライン画像・アバター・ファイル DL はクロスオリジンで動きません。 これらは 相対 URL(/api/attachments/by-key/…・/files/…)で出力され、ブラウザは api ではなく web オリジンから読もうとします。さらに by-key 画像配信は first-party の crowi.accessToken cookie で認証されますが、別オリジンの <img> はその cookie を 送れません。そのため別オリジン構成では、アプリと HTTP データは表示されても添付画像 / ファイルリンクが使えなくなります。完全なクロスオリジン対応(全 asset URL の絶対化 + cross-site cookie)は将来の対応に回しています。代わりに (A) か、Vercel の edge を proxy にする (C) を使ってください (どちらもブラウザを同一オリジンに保ちます)。

クロスオリジンでも動く HTTP API 呼び出しの CORS では、api はブラウザのリクエストの origin が CLIENT_URL と完全一致 (scheme + host + port、ワイルドカード不可)するものだけを許可します。web のオリジンを 設定してください。dev では localhost オリジンが自動許可されます。

(C) Vercel / マネージド PaaS

api は別所(VM / コンテナ基盤)の公開オリジンに置き、web を Vercel にデプロイします。 すっきりした方法は、Vercel の edge を proxy にしてブラウザを同一オリジンに保つことです。

  • CROWI_API_URL=https://api.example.com を Vercel の build/runtime env に設定。 Vercel は rewrite を build 時に解決し、edge が /api/*・/files/* をリクエストごとに proxy するため、ブラウザは Vercel オリジン(相対 URL)のままで、インライン画像・ ファイル DL・first-party cookie がすべて動きます。
  • WebSocket は例外: Vercel の edge は WS を proxy しません。realtime namespace は NEXT_PUBLIC_COLLAB_URL=wss://api.example.com で api を直接指してください。WS には cookie / 画像の問題はありません。api は短命のトークンで upgrade を認証するためです。
  • api には CLIENT_URL を Vercel の web オリジンに設定(CORS・完全一致)。この設定は WS の origin チェックにも使われます。

host-only cookie 不変条件。 crowi.accessToken cookie はクライアント側で Domain 無し(host-only)・SameSite=Lax で発行されるため、ページを配信するオリジン(edge が 転送する Vercel/proxy オリジンを含む)に first-party で乗ります。api 側で Domain 固定の session cookie を設定しないでください(proxy 越しに乗らなくなります)。これは self-host proxy (A) でも同じ前提です。

self-host の standalone サーバと違い、Vercel は deploy ごとに build し直し、edge が rewrite を per-request で適用するため、build 時固定の proxy 宛先は ここでは制約になりません。build 時に NEXT_PUBLIC_API_URL=https://api.example.com (クロスオリジン・焼き込み)を渡す手もありますが、(B) の画像 / cookie 制約を引き継ぎます。

(D) ローカル開発

pnpm dev は api(:4301)と web(:4302)を起動し、その前段に 同一オリジンの reverse proxy(:4304)を立てます(Caddy があれば Caddy、無ければ依存ゼロの node フォールバック。ルーティング表は本番の 前段 proxy と同一)。dev の正式な入口は :4304 です。

  • HTTP: ブラウザは :4304 に対して相対 /api/... を叩き、proxy が api パスを :4301 へ、それ以外を web へ振り分けます。
  • WebSocket: /collab・/presence・/notifications も同じ :4304 オリジンを upgrade します。resolver は既定で window.location から URL を 導出するため、追加設定は不要です。素の web ポート :4302 を直接開くと proxy を迂回し、リアルタイム系は接続できません(web 内蔵の proxy は HTTP 専用で WS upgrade を転送できません)。

dev では CROWI_API_URL も NEXT_PUBLIC_* も不要です。

ヘルスチェックと停止

api は、オーケストレータやロードバランサ向けに認証不要の確認口を 2 つ持ちます。本文は {"status":"ok"} か {"status":"unavailable"} だけで、どの依存先が落ちたかなどの内部情報は返しません。

パス意味200 を返す条件
/healthz生きているか (liveness)プロセスが HTTP に応答できる限り常に 200
/readyz受け付けてよいか (readiness)DB に繋がっていて、停止処理に入っていないときだけ 200。それ以外は 503

/readyz は Redis の接続状態を見ません (Redis が落ちても crowi は縮退して動き続けるため)。リバースプロキシ経由では /api/healthz / /api/readyz でも届きます。外に公開したくない場合は proxy で塞いでください (オーケストレータは api のポートを直接叩くので影響しません)。

Kubernetes の例:

livenessProbe:
  httpGet: { path: /healthz, port: 3000 }
readinessProbe:
  httpGet: { path: /readyz, port: 3000 }

公式 Docker イメージには /healthz を叩く HEALTHCHECK が入っており、docker ps に healthy / unhealthy が出ます。DB 障害でコンテナを入れ替えても直らないため、HEALTHCHECK は /readyz ではなく /healthz を見ます。

SIGTERM / SIGINT を受けた api は、/readyz を 503 にし、新しい接続を受けなくなり、処理中の HTTP リクエストと、保存の後に裏で走る処理 (検索インデックスの更新や通知) が終わるのを、合図から合計で最長 8 秒待ってから終了します。猶予は固定で、環境変数では変えられません。docker stop の既定 (10 秒) に収まりますが、stop_grace_period / terminationGracePeriodSeconds は 10 秒以上にしておいてください。

Kubernetes でエンドポイントからの除外が遅れる場合は、preStop で数秒待たせます。

lifecycle:
  preStop:
    exec:
      command: ["sleep", "5"]

まとめ

構成ブラウザ → api HTTPブラウザ → api WSインライン画像 / ファイルapi 先変更に再ビルド?
(A) 同一オリジン proxy(推奨)相対・proxy 経由window.location・proxy 経由動く(同一オリジン)不要
(B) クロスオリジン self-hostNEXT_PUBLIC_API_URL(実行時)NEXT_PUBLIC_COLLAB_URL(実行時)動かない(相対 + cookie)不要(実行時 env)
(C) Vercel + edge proxy相対・Vercel edge 経由(CROWI_API_URL build env)NEXT_PUBLIC_COLLAB_URL(直接)動く(同一オリジン)n/a(deploy ごと build)
(D) dev相対・:4304 dev proxy 経由window.location・:4304 dev proxy 経由動くn/a

次のステップ

On this page