デプロイアーキテクチャ
システム構成
ブランチ戦略
GitHub Flow を採用。main ブランチが唯一のトランク。
API は常設の Staging 環境(subseek-api-staging)を持ち、PR ごとに最新のコードがデプロイされる。
Web は Vercel Preview で PR ごとに自動デプロイされる。staging ブランチは持たない。
なぜ常設 Staging 環境を持つのか
- Better Auth ダッシュボード: プロジェクト接続に固定の Base URL が必要。動的 PR Preview URL では接続できない
- 環境変数の安定性:
wrangler secretで一度設定すれば PR ごとの再設定が不要 - ブランチ戦略は GitHub Flow のまま: staging ブランチは導入せず、PR 作成時に常設 Staging にデプロイする
- 1人開発: 複数 PR が同時にある場合は最後にデプロイされた PR の内容が Staging に反映される
アプリ名・URL 一覧
Production
| コンポーネント | アプリ名 | URL | 状態 |
|---|---|---|---|
| Vercel (Web) | subseek | https://subseek.cc | デプロイ済み |
| Cloudflare Workers (API) | subseek-api | https://subseek-api.workers.dev | デプロイ済み |
| Meilisearch (Hetzner US) | — | Hetzner VM(Production ポート) | デプロイ済み |
| Cloudflare R2 | subseek-backups | — | 未作成 (v0.3 #77) |
| Turso (DB) | subseek | libsql://subseek-saneatsu.aws-us-east-1.turso.io | 作成済み |
Staging
常設の Staging 環境。PR 作成時に API が自動デプロイされる。Web は Vercel Preview で PR ごとに自動デプロイ。
| コンポーネント | アプリ名 | URL | 状態 |
|---|---|---|---|
| Vercel (Web) | — | subseek-<hash>-nito.vercel.app(PR ごとに動的) | Vercel Preview デプロイ(GitHub 連携で自動) |
| Cloudflare Workers (API) | subseek-api-staging | https://subseek-api-staging.workers.dev | デプロイ済み |
| Meilisearch (Hetzner US) | — | Hetzner VM(Staging ポート)(Production と同一 VM、別ポート) | デプロイ済み |
| Turso (DB) | subseek-preview | libsql://subseek-preview-saneatsu.aws-us-east-1.turso.io | 作成済み |
デプロイ済みサービスの詳細
Meilisearch(Hetzner US セルフホスト)
Hetzner US(Ashburn)の VPS で Docker セルフホスト。詳細は 検索インフラ調査 および Meilisearch ホスティング比較 を参照。
- Docker コンテナで起動(
getmeili/meilisearch:v1.13) - Production(
127.0.0.1:7700)/ Staging(127.0.0.1:7701)を同一 VM 上で別コンテナとして運用 - VM: Hetzner CPX11(Shared, Regular Performance)、IPv4:
<HETZNER_VM_IP> - API への公開は Cloudflare Tunnel 経由(
https://meili.subseek.cc/https://meili-staging.subseek.cc)。Workers の HTTP outbound 制約を回避するため、直 IP + 平文 HTTP は使用しない - セットアップ手順: Meilisearch セットアップ
- BAN リスク対策: Meilisearch ダンプを毎日 R2 に自動保存(v0.3 #77)
注意:
infra/meilisearch/fly.tomlは旧 Fly.io セルフホスト時の設定ファイル。現在は使用しないが、参考として残している。
Hono API(Cloudflare Workers)
| 環境 | URL | ヘルスチェ ック |
|---|---|---|
| Production | https://subseek-api.workers.dev | / → {"message":"subseek API"} |
| Staging | https://subseek-api-staging.workers.dev | / → {"message":"subseek API"} |
- ランタイム: Cloudflare Workers(V8 isolate)
- 同一 Worker が
fetch(HTTP)とqueue(Cloudflare Queues Consumer)の 2 種類のハンドラを export
字幕取得バッチ(Cloudflare Queues + Durable Object)
旧 in-memory SubtitleFetchQueue は Workers の isolate eviction で進捗が消失する問題があり、
v0.1 で Cloudflare Queue + Durable Object ベースに置換した(#328)。
| コンポーネント | リソース名 |
|---|---|
| 字幕取得 Queue (production) | subseek-subtitle-fetch (DLQ: subseek-subtitle-fetch-dlq) |
| 字幕取得 Queue (staging) | subseek-subtitle-fetch-staging (DLQ: subseek-subtitle-fetch-staging-dlq) |
| チャット取得 Queue (production) | subseek-chat-fetch (DLQ: subseek-chat-fetch-dlq)。Consumer 未配線(フォローアップ) |
| チャット取得 Queue (staging) | subseek-chat-fetch-staging (DLQ: subseek-chat-fetch-staging-dlq) |
| 進捗 DO (production / staging) | SubtitleFetchProgressDO(チャンネル別に instance、SQLite migration v1) |
データフロー:
/api/searchがチャンネルを指定して呼ばれるとenqueueChannelFetchが- DO の進捗 state を
total = videos.lengthで初期化 - 動画 ID を 100 件ごとに
SUBTITLE_FETCH_QUEUE.sendBatchで投入 - live_archive のみ
CHAT_FETCH_QUEUEにも投入
- DO の進捗 state を
- 同 Worker の
queueexport が batch(最大 5 件 / 10 秒)で受け取り、 YouTube transcript fetch → Meilisearch upsert → DB / DO 更新 - SSE
/channels/:id/subtitles/progressは DO を 2 秒間隔で polling してprogress/completeevent を配信
- 互換性フラグ:
nodejs_compat,nodejs_compat_populate_process_env - 設定ファイル:
apps/api/wrangler.toml
Next.js Web(Vercel)
| 環境 | URL | 状態 |
|---|---|---|
| Production | https://subseek.cc | 200 OK |
- フレームワーク: Next.js(自動検出)
- Root Directory:
apps/web - GitHub 連携:
nito-tech/subseekリポジトリ
ドメイン
- ドメイン:
subseek.cc(取得済み) - DNS 管理: Cloudflare DNS で設定
- Fly.io はデフォルトドメイン(
*.fly.dev)を使用
なぜ API は api.subseek.cc ではなく subseek-api.workers.dev なのか
API の URL はフロントエンドが内部的にリクエストするだけで、ユーザーに直接見えることはない。そのため、カスタムドメインを設定するメリット(ブランド統一)よりも、設定の手間を省く方が合理的と判断した。
必要になった時点で api.subseek.cc を Workers カスタムドメインとして追加することは可能。NEXT_PUBLIC_API_URL 環境変数を変更するだけで対応できる。
CI/CD パイプライン
GitHub Flow を採用。PR 1 本につき 1 つの workflow(ci.yml)で CI → Staging Deploy → E2E を直列に実行する。CI が失敗した PR では Vercel build / Workers デプロイが走らないため、無駄なリソース消費を防ぐ。
Meilisearch インフラは Hetzner US での Docker セルフホストで、CI/CD の管理対象ではない(手動運用)。
paths-filter の監視対象
detect ジョブは dorny/paths-filter で PR の差分を検査し、対応する deploy ジョブ/ステップを起動する。
| 対象 | トリガーパス |
|---|---|
API (deploy-api) | apps/api/**, packages/**, pnpm-lock.yaml, .github/workflows/** |
Web (deploy-web) | apps/web/**, packages/**, pnpm-lock.yaml, .github/workflows/** |
| Meilisearch インデックス設定 | apps/api/src/lib/meilisearch/subtitles-index/**, apps/api/src/lib/meilisearch/scripts/**, .github/workflows/**(→ deploy-api 内で pnpm meilisearch:init を実行) |
| DB マイグレーション | packages/db/migrations/**(→ deploy-api 内で drizzle-kit migrate を実行) |
各トリガーパスの中身は次の通り:
| パス | 内容 |
|---|---|
apps/api/** | Hono API(Cloudflare Workers)のソースコード一式 |
apps/web/** | Next.js Web のソースコード一式 |
packages/** | モノレポ共通パッケージ(@subseek/db の Drizzle スキーマ、@subseek/shared の型・ユーティリティ等)。API と Web の両方が依存しているため両方を再デプロイ対象にしている |
pnpm-lock.yaml | pnpm のロックファイル。依存関係更新を検知して再デプロイする |
.github/workflows/** | CI/デプロイ workflow 自体。これも監視対象に含めることで、workflow を編集した PR を staging で動作確認できる |
apps/api/src/lib/meilisearch/subtitles-index/** | setupSubtitlesIndex() と設定定数(filterableAttributes 等)。検知すると deploy-api ジョブ内で pnpm meilisearch:init を実行し、Meilisearch の設定を反映する(下記「Meilisearch インデックス設定のデプロイ」参照) |
apps/api/src/lib/meilisearch/scripts/** | Meilisearch 初期化スクリプト |
packages/db/migrations/** | Drizzle ORM のマイグレーション SQL。検知すると deploy-api ジョブの先頭で drizzle-kit migrate を staging DB に対して実行する |
Meilisearch インフラ:
infra/meilisearch/**(旧fly.toml)は現在 CI のトリガー対象外。Meilisearch は Hetzner US の Docker セルフホスト運用で、バージョンアップ・VM 切替などは VM に SSH して手動で対応する(Meilisearch セットアップ)。fly.tomlは旧 Fly.io セルフホスト時の設定で参考として残している。
ワークフロー一覧
| ファイル | トリガー | 役割 |
|---|---|---|
ci.yml | PR 作成/更新 | lint/typecheck/vitest/knip → CI 全 success 後に paths-filter → staging deploy → E2E までを統合実行 |
ci-reusable.yml | workflow_call | lint/typecheck/vitest/knip の 4 並列ジョブ |
e2e-reusable.yml | workflow_call | Playwright E2E(base-url input を受け取る) |
deploy-production.yml | main への push(PR マージ) | paths-filter → API(Workers)/Web(Vercel)/Meilisearch インデックス設定の本番反映 |
運用ルール
- CI ゲート: staging deploy は CI(lint/typecheck/vitest/knip)が全 success のときのみ走る。1 つでも失敗すると detect / deploy / e2e はすべて skip される
- Draft PR: CI も staging deploy も全 skip。レビュー前の WIP を実インフラに流さないため
- Web(Vercel): PR ready_for_review 後に Vercel CLI でデプロイし、固定エイリアス
staging.subseek.ccに上書き割 り当て。main マージで Production (subseek.cc) にデプロイ - API(Cloudflare Workers): PR ready_for_review 後に
wrangler deploy --env stagingで常設 Staging にデプロイ、main マージでwrangler deployで Production デプロイ - Meilisearch インフラ: Hetzner US で Docker セルフホスト。通常デプロイ不要(バージョンアップ・VM 切替時のみ手動)
- Meilisearch インデックス設定:
subtitles-index.ts等を変更した PR でdeploy-api内の init ステップが自動実行される(下記「Meilisearch インデックス設定のデプロイ」参照)
Meilisearch インデックス設定のデプロイ
Meilisearch に関しては「インフラ」と「インデックス設定」の2 層を区別して管理する。
| 層 | 対象 | デプロイ手段 | トリガー(paths-filter) |
|---|---|---|---|
| インフラ | Docker コンテナ、VM、ポート、バージョン | 手動(Hetzner VM 上で docker run / 旧 flyctl deploy -c infra/meilisearch/fly.toml) | infra/meilisearch/** |
| インデックス設定 | searchableAttributes / filterableAttributes / sortableAttributes | pnpm --filter @subseek/api run meilisearch:init を CI で自動実行 | apps/api/src/lib/meilisearch/subtitles-index/** |
挙動
setupSubtitlesIndex()(apps/api/src/lib/meilisearch/subtitles-index/subtitles-index.ts)は冪等な関数で、内部で:
- インデックスが無ければ
createIndex({ primaryKey: "id" }) updateSettings()でsearchableAttributes/filterableAttributes/sortableAttributesを一括適用し、.waitTask()で Meilisearch のタスク完了を待つ
embedders / rankingRules / synonyms などは送信しないため、Meilisearch ダッシュボードで手動設定した値は維持される(PATCH セマンティクス)。
CI/CD での配置
deploy-api ジョブ内で、以下の順で実行する:
1. DB migrations (db 検知時のみ)
2. meilisearch:init (meilisearch-settings 検知時のみ) ← waitTask() で完了ブロック
3. wrangler deploy (api 検知時のみ)
meilisearch:init を wrangler deploy の前に置くことで、新しい API コードが立ち上がる時点で必ず対応するインデックス設定が揃っている状態を保証する(filter 属性追加時の race condition 回避)。
属性「追加」と「削除」の運用ルール
| 操作 | 順序 | 実現方法 |
|---|---|---|
追加(例: publishedAt を filterable に) | init → deploy | 1 つの PR で設定定義と API コード変更をまとめてよい。CI が正しい順で適用する |
| 削除(例: 使わなくなった属性を filterable から外す) | deploy → init | 2 つの PR に分割する: (1) API コードから参照を削除して deploy → (2) 設定定義から属性を削除して init |
削除を 1 PR で行うと、init が先に走って設定から属性が消えた直後に古い API が参照して 400 を返す race が生じる。
ダウンタイム / パフォーマンス影響
updateSettings()中も Meilisearch は検索を停止しない(ダウンタイムゼロ)- 属性変更があると Meilisearch は内部的に再インデックスを走らせる。現 subseek 規模では数秒〜数十秒で完了し、UX に知覚できる影響は出ない
- paths-filter で「設定定義ファイルが変わった PR のみ」トリガーするため、通常の API コード変更では再インデックスは発生しない
手動実行(緊急復旧・初回セットアップ)
# ローカル(.env 経由)
cd apps/api && pnpm meilisearch:init
# staging / production(環境変数を明示)
cd apps/api && \
MEILISEARCH_HOST=https://meili.subseek.cc \
MEILISEARCH_API_KEY=<master-key> \
tsx src/lib/meilisearch/scripts/init-subtitles-index.ts
必要な GitHub Secrets
| Secret 名 | 用途 |
|---|---|
STAGING_MEILISEARCH_HOST | staging Meilisearch の URL(https://meili-staging.subseek.cc、Cloudflare Tunnel 経由) |
STAGING_MEILISEARCH_API_KEY | staging Meilisearch の master / admin key |
PROD_MEILISEARCH_HOST | production Meilisearch の URL(https://meili.subseek.cc、Cloudflare Tunnel 経由) |
PROD_MEILISEARCH_API_KEY | production Meilisearch の master / admin key |
技術選定の背景
なぜ Vercel(Web)なのか
- Next.js の開発元で互換性 100%、ゼロコンフィグデプロイ
- next-intl(7言語対応)の動作が保証されている
- PR 連動の Preview デプロイが標準機能
- GitHub 連携で CI/CD 設定が不要
なぜ Cloudflare Workers(API)なのか
- Hono がネイティブサポートするランタイムで、
export default appだけでデプロイ可能 - V8 isolate でメモリ管理・スケーリングが自動(VM の OOM やコールドスタート問題がない)
- $5/月の Paid プランで 10M リクエスト含む。50 万 PV 以上で Fly.io よりコスト優位
- Docker ビルド不要で CI が高速
Fly.io を使用→ 256MB VM で OOM クラッシュが発生し Workers に移行(2026-04)
なぜ Hetzner US(Meilisearch)なのか
- Meilisearch Cloud と比べて2〜7倍安い(10万動画で月3,000円 vs 25,500円)
- 機能差・レイテンシ差なし(同じ US East、同じ Meilisearch 本体)
- Docker 1コマンドで起動でき、運用は月0.5〜2時間
- BAN リスクは R2 ダンプバックアップ + Meilisearch Cloud 即移行で対策
- 詳細は Meilisearch ホスティング比較 を参照
なぜプライマリリージョンは US East(ewr)なのか
subseek の対応言語圏(日本語・英語・中国語・韓国語・スペイン語・フランス語・ドイツ語)の YouTube 利用者数を国別に集計し、各リージョンへの RTT で加重平均を算出した結果、US East(ewr)が最適と判断した。
| プライマリ | カバー人口(RTT < 100ms) | 加重平均書き込みレイテンシ |
|---|---|---|
| 東京(nrt) | 1.72億(日本・韓国・台湾・香港) | ~155ms |
| US East(ewr) | 5.61億(米・加・墨・英・仏・独・西) | ~72ms |
iad は nrt の 3.3倍の人口 を低レイテンシでカバーできる。
設計判断
- API・DB・Meilisearch はすべて同一リージョン(iad)に配置する: API↔DB 間は 1 リクエストで複数回アクセスするため、ここのレイテンシが最もクリティカル
- Turso のプライマリロケーションは作成後に変更できない(不変): read replica をプライマリに昇格させる 機能も存在しないため、データが少ないうちに最適なリージョンで作成した
- 将来のマルチリージョン化: ユーザー増加時に東京(nrt)に Fly.io マシン + Turso read replica を追加し、日本・韓国ユーザーのレイテンシを改善する方針
- Meilisearch(Hetzner US Ashburn): Fly.io API(ewr)と同じ US East。RTT 5〜20ms
なぜ iad(Virginia)ではなく ewr(New Jersey)なのか
Fly.io の iad リージョンでは 2025年2月後半からボリューム付きマシン作成時に insufficient CPUs エラーが継続しており(Fly.io Community)、Meilisearch のデプロイが不可能だった。ewr は同じ米国東海岸に位置し、Turso の US East(Virginia)までの RTT は ~3-5ms と十分近いため、代替リージョンとして採用した。
なぜ Cloudflare Workers ではないのか
- Meilisearch のセルフホストが不可能(Docker 非対応)
- API を Workers に置くと Meilisearch(Fly.io)との間にネットワークホップが増加
- 収益に対するサーバー代比率が 1% 以下と微小なため、コスト差よりも運用のシンプルさを優先
疎通確認結果(2026-04-09)
| 経路 | 結果 | 備考 |
|---|---|---|
| Web → API(CORS) | OK | access-control-allow-origin: https://subseek.cc |
| API → Turso DB | OK | Production / Staging 両方 |
| API → Meilisearch(Hetzner、Cloudflare Tunnel 経由) | OK | https://meili.subseek.cc / https://meili-staging.subseek.cc の /health で確認 |
| Google OAuth | 要調査 | /api/auth/sign-in/social が 500 エラー |
| API → R2 | N/A | v0.3(#77)で導入予定 |
| Stripe 決済 | N/A | API 側ハンドラー未実装(#145) |