
1. はじめに
Bubble でマルチテナントのサブドメインを利用しようとした場合、標準の機能で行う際にはサブアプリ化が必須になります。 サブアプリ化は魅力的ではあるのですが、親側が Team プラン以上、子側が Standard プラン以上が必要となります。また、必要なサブドメインごとに子アプリを作る必要があるので、非常に高価なシステムになってしまいます。
これを解決するために Bubble の前に Cloudflare Workers で作成したリバースプロキシを配置することにしました。 リバースプロキシでサブドメインでアクセスしてきたものをいつもの Bubble の URL 形式に変換して渡すことにより、クライアントからサブドメインを利用したアクセスを実現することができました。
具体例ですが、次のようになります。
foo.example.com → example.com/?__tenant="foo"
ユーザーは foo.example.com にアクセスするのですが、リバースプロキシで example.com にアクセスし、変数として __tenant="foo" を渡すように設定するというものです。
これを実現できると、Bubble 側は Standard プランだけでマルチテナントのサブドメイン運用ができるようになるため、非常に運用コストが下がることになります。
以下、これを実現するための方法と設計上の注意点をまとめます。
1.1 この記事で扱うこと(スコープ)
- 構成:
*.example.com → Cloudflare Workers → Bubble(単一ホスト) - 方式:
- クエリ方式(推奨):
?__tenant={tenant}を初回だけ付与→アプリで取得→可能なら 302 で除去 - パス書き換え:内部的に
https://app.example.com/t/{tenant}{pathname}へリライト
- クエリ方式(推奨):
- Cookie/認証:
Domain=.example.comでスコープ統一、またはサブドメイン単位で分離 - 運用:テナント登録(DNS/証明書の自動化含む)、監視、緊急バイパス
ミニ図:
foo.example.com→ Workers(tenant=foo)→ Bubble(同一オリジン)→ アプリ内でtenantを参照
1.2 読むとわかること
- なぜサブドメイン運用なのか(ブランド分離、権限分離、Cookie・SEOの自然さ)
- 最小実装の骨子(20行前後) と、壊さないための原則
- 運用のカギ(DNS/証明書の自動化、ログ/監視、ロールバック)
1.3 前提と制約
- 前提スキル:JavaScript(Workers)、HTTP基礎、Bubble のURL/DB/ログイン仕様
- 制約:
- 署名付きURL/ファイル:ホストやパスを書き換えない(または署名ポリシーを統一)
- Cookie:Samesite/Secure/Domain を一貫させる(跨ドメインにしない)
- 直オリジン遮断:Workers を経由しない直接アクセスを塞ぐ設計(Allowlist 等)
- DNS:Cloudflare の DNS を利用する(Bubble へのアクセスはプロキシは利用しない)
1.4 本記事で扱わないこと(除外)
- CDN/WAF など Cloudflare 一般機能の効果検証(Bubble 標準でも享受できるため)
- 大規模 SASE/Zero Trust 設計の詳細
- OAuth/外部IdP 連携の詳細(本記事では省略)
- 顧客持込ドメイン(バニティドメイン)
1.5 要点まとめ(結論の先出し)
Workers を挟めば、Bubble に“サブドメイン=テナント”という自然設計を付与できる。
- 導入メリット(サブドメイン限定)
- ブランド分離:
foo.example.com/bar.example.comで顧客ごとにURLを分離。 - 認証・権限の整理:Cookie スコープや RBAC をテナント境界に合わせやすい。
- 運用の見通し:ログ/監視がテナント単位で切れて、障害切り分けが容易。
- ブランド分離:
- 注意点(壊しやすい所)
- 署名付きURL/ファイル:ホスト・パスの書き換えは極力避ける(または署名の前提を統一)。
- Cookie 設計:
Domain=.example.comで統一する場合とサブドメインで分離する場合で挙動が変わる。
- 判断フレーム
- テナントごとに URL を分けたい/ブランドを出したい → 導入
- パスで十分・署名URLの制約が厳しい → 見送り or 後回し
2. アーキテクチャ
2.1 フロー(全体像)
client → *.example.com (DNS) → Cloudflare Workers (tenant 抽出) → Bubble 単一ホスト → アプリ
- Workers は 入口で host を解析して
tenantを確定。 - クエリ方式(__tenant)またはパス書き換えのいずれかで Bubble にテナント情報を渡す。
- 監視・メトリクスは テナント名をキーに集計。
2.2 マッピング方式の比較
| 方式 | 仕組み | Bubble 側の実装 | 長所 | 注意点 |
|---|---|---|---|---|
| クエリ方式(推奨) | ?__tenant=foo を付与→アプリで取得 |
Get data from page URL で参照可 |
実装が容易/URL設計がシンプル | 取得後は 302 で除去してクリーンURL推奨 |
| パス書き換え | 内部で /t/foo{pathname} にリライト |
既存のパスベース実装を流用 | Bubble 側の変更が少ない | 署名URLでホスト・パス不一致の地雷 |
パス書き換えの方は、あらかじめ SPAのように作っておく必要があるので、アプリケーションを選びます。SPA として作っているならアリかと思います。ただ、今回は、多くの環境で利用できそうなクエリ方式(__tenant)を採用しました。
2.3 Cookie / 認証の設計
- 統一スコープ派:
Domain=.example.comに設定し、サブドメインを跨いでも同一セッションを共有。- 長所:SSO 的に扱いやすい。
- 注意:テナント間の偶発的共有を避けるため Path や Claimで厳格に判定。
- 分離スコープ派:サブドメインごとに Cookie を分離。
- 長所:セキュリティ/独立性が高い。
- 注意:管理者が複数テナントを横断する場合に SSO が難しくなる。
3. 実装ミニマム
Cloudflare Workers に設定するコード例
3.1 クエリ方式(__tenant)(推奨)
export default {
async fetch(req) {
const url = new URL(req.url); // https://foo.example.com/...
const host = url.hostname; // foo.example.com
const tenant = host.split(".")[0]; // "foo"
if (!/^[a-z0-9-]{1,63}$/.test(tenant)) return new Response("invalid tenant", { status: 400 });
// 初回だけ __tenant を付与して Bubble 側で取得 → 可能なら 302 で除去
if (!url.searchParams.has("__tenant")) {
url.searchParams.set("__tenant", tenant);
return Response.redirect(url.toString(), 302);
}
const origin = "https://app.example.com"; // Bubble 側(単一ホスト)
return fetch(origin + url.pathname + url.search, {
method: req.method,
headers: req.headers,
body: req.body
});
}
}
3.2 パス書き換え方式(参考)
export default {
async fetch(req) {
const url = new URL(req.url);
const [tenant] = url.hostname.split("."); // foo
const origin = "https://app.example.com";
url.hostname = new URL(origin).hostname;
url.pathname = `/t/${tenant}${url.pathname}`; // 内部的に /t/foo/... へ
return fetch(url.toString(), {
method: req.method,
headers: req.headers,
body: req.body
});
}
}
署名付き URL(S3 署名等)は ホスト/パス一致が前提。
パス書き換え方式はこの前提を崩しやすいので、できるだけ避ける。
3.3 エッジケース対策
- 予約サブドメイン(
www,app,cdn,apiなど)はテナントとして拒否。 - 存在しないテナントは 404 ではなく、ガイド付き 404/テナント作成導線に誘導。
- リダイレクト連鎖(301/302)を検知し、3回以上で遮断 → エラーページ。
- CORS/Cookie 属性(
Secure; SameSite=Noneなど)を一貫させる。
3.4 動作確認チェックリスト(最小)
foo.example.com/bar.example.comで __tenant が期待通りに届く- ログイン〜再訪問で Cookie の挙動が意図通り(共有 or 分離)
- 署名付きURL の動作(ダウンロード/プレビュー)
- 404/メンテ/キルスイッチ(Workers 側フラグでの切替)
- 直オリジン遮断(Workers 経由以外のアクセスを拒否)
4. 運用設計
4.1 テナント登録の自動化(真実は Bubble のDB)
- 方針:テナントの存在・状態(active/disabled)のソース・オブ・トゥルースは Bubble のDB。
Workers は__tenantをホストから確定して渡すだけ(無状態)。 - 作成トリガー:
- 管理画面から作成(手動):Bubble の Backend WF で
Tenantを作成 - セルフサインアップ(ユーザー起動):申込→決済/メール認証→ Backend WF で
Tenant作成
- 管理画面から作成(手動):Bubble の Backend WF で
- 存在チェック:
- ページ初期化で
Get data from page URL(__tenant)→Do a search for Tenant:first item (slug = __tenant)
見つかれば通常処理、なければ「ガイド付き404」や申込導線へ
- ページ初期化で
- メリット:運用が Bubble 側で完結/Workers はデプロイ不要の薄い入口として安定
参考:Workers 側は ホスト由来の
__tenantを常に上書きし、改ざんを防止します(実装は §4.1 のサンプル参照)。
4.2 監視・メトリクス(最小:console.log に tenant を出す)
まずは 外部連携なしで十分です。Workers の標準ログは存在するので、そこに tenant を自前で出すだけで「どのテナントで何が起きたか」を追えるようになります。
何を出すか(最小)
tenant(ホストから抽出)- 可能なら
status(HTTP ステータス)・duration_ms(往復時間)・pathも一緒に
置く場所
- レスポンス返却の直前(
fetchの finally など)。1リクエストにつき 1 行でOK。
サンプル(そのまま貼れる)
export default {
async fetch(req) {
const url = new URL(req.url);
const tenant = url.hostname.split(".")[0].toLowerCase();
// __tenant を正値で付与(初回だけ)
if (!url.searchParams.has("__tenant") || url.searchParams.get("__tenant") !== tenant) {
url.searchParams.set("__tenant", tenant);
return Response.redirect(url.toString(), 302);
}
const origin = "https://app.example.com";
const started = Date.now();
try {
const res = await fetch(origin + url.pathname + url.search, {
method: req.method, headers: req.headers, body: req.body
});
// 返す前に1行だけログ
const duration_ms = Date.now() - started;
console.log(JSON.stringify({
ts: new Date().toISOString(),
tenant,
status: res.status,
duration_ms,
path: url.pathname
}));
return res;
} catch (e) {
const duration_ms = Date.now() - started;
console.log(JSON.stringify({
ts: new Date().toISOString(),
tenant,
status: 599, // ネットワーク等の便宜的コード
duration_ms,
path: url.pathname,
error: String(e)
}));
throw e;
}
}
}
見方(外部SaaSは不要)
- Cloudflare ダッシュボード → Workers → Logs で流れてきます。
- 開発中は CLI でも:
wrangler tail --format=json
// 例)特定テナントだけ眺める(jqがあれば)
wrangler tail --format=json | jq 'select(.logs[0].message[0] | fromjson | .tenant=="foo")'
本格的なダッシュボード化は、必要になってから Logpush(S3/R2/Datadog など)を検討すればOK。まずは console.log 1行から始めましょう。
4.3 ロールバック&緊急バイパス
- ガード:
Tenant.statusをdisabledにすると、アプリ側ロジックで即停止できるようにする - キルスイッチ:Workers に簡易フラグを用意して メンテ画面へ切替(数秒で反映)
- 直オリジン経由の暫定復旧は避ける(ガードを回避するため)。必ず Workers 経由で制御
4.4 直オリジン遮断の考え方
- 想定:
https://app.example.comを直接叩かれると、__tenant付与や監視が迂回される - 対策:Bubble 側で
X-Forwarded-Host等のヘッダチェック or 簡易BasicAuth で遮断(可能な範囲で) - Workers 側:
Host/X-Forwarded-Hostを常に設定し、一致しないアクセスは弾く
5. ハマりどころ(トラブル事例)
5.1 署名付きURL(S3 等)が 403 になる
- 原因:Workers で パス書き換えや クエリ順序変更をしてしまう
- 対策:署名URLに対しては 一切書き換えをしない。HTML と API 以外は 素通しルールを徹底
5.2 メール内リンク(確認/リセット)が親ドメインに飛ぶ
- 症状:
example.com/reset?token=...に飛び、サブドメイン文脈が失われる - 対策:
- 専用ページを用意してトークンを受ける(推奨)
- もしくは初回アクセス時に Workers で
?__tenant=fooを付与 → アプリ側で読んだら 302 で除去
5.3 リダイレクト連鎖(301/302)が止まらない
- 原因:末尾スラッシュ統一や HTTPS 強制と、アプリ側リダイレクトが衝突
- 対策:Workers で 正規化は最小限にし、Bubble 側の設定と 二重にしない。N回以上で遮断
5.4 Cookie の SameSite 問題
- 症状:一部の埋め込み or iframe でセッションが途切れる
- 対策:
SameSite=None; Secureを必要箇所に限定適用。むやみに全Cookieへは付けない
5.5 存在しないサブドメインの取り扱い
- 誤り:即 404 を返して終わり
- 改善:テナント作成導線や説明ページへ誘導し、運用コスト削減&コンバージョン向上
6. まとめ(チェックリスト)
*.example.com → Workers → Bubbleのフローで tenant 抽出が一貫している- クエリ方式(__tenant)で初回だけ付与 → アプリで取得 → 可能なら 302 で除去
- 署名付きURLやファイル配信は 一切書き換えしない(素通し)
- Cookie 設計(共有 or 分離)を決め、
SameSite/Secure/Domainを一貫させる - 直オリジン遮断(ヘッダチェック等)で Workers 経由を強制
- 監視・ログは
tenant・route・request_idで紐づけ、キルスイッチを用意