一番怖い瞬間は、セーフルームを出る直前だ。

このブログサイトの技術スタックとインフラ構成について

#開発

なぜこの構成にしたのか

このプロジェクトには最初から2つの制約を課していました。

  1. 月額を最低限に抑えること。 個人の日記帳にサーバー代を払い続けたくはありません。
  2. いつでも引っ越せること。 無料枠が消えたり、規約が変わったりしても、データごと別の場所へ移せる状態を保つ。

この2つが、スタック選定のあらゆる場面で判断基準になりました。


スタック全体像

レイヤー 採用したもの ひとことメモ
フレームワーク Next.js 16(App Router / Turbopack)+ React 19 + TypeScript strict web・console 両方
モノレポ pnpm workspaces + Turborepo 共有パッケージはTSソースのまま参照
データベース PostgreSQL 17(Supabase)+ Drizzle ORM スキーマをコードで管理、SQLマイグレーションはgit管理
多言語化 next-intl v4 パスにロケールを前置(/ja/...)、Accept-Language判定
本文パイプライン unified / remark / rehype + 自作ショートコードレジストリ rehype-sanitize の許可リストでサニタイズ
認証 Supabase Auth(GitHub OAuth) 管理者1名をメールアドレスで許可リスト管理
オブジェクトストレージ Supabase Storage(画像・PDF・バックアップ)+ Cloudflare R2(音声・動画) R2は下り課金ゼロ
機械翻訳 Google Cloud Translation API v3(REST直叩き) gRPC SDKは使わず google-auth-library で署名
画像処理 sharp リサイズ・EXIF除去・サムネ生成
メディア再生 ネイティブ <audio>/<video> の上に自作Reactプレイヤー カルーセルはヘッドレスな Embla
スタイリング 素のCSS + CSS Modules UIフレームワークなし、モバイルファースト
CI/運用 GitHub Actions 週次の自動 pg_dump バックアップ
テスト Vitest(ユニット)+ 実インフラ相手の統合スクリプト
ホスティング Vercel(1リポジトリから2プロジェクト) Hobbyプラン

以下、判断のポイントになった箇所を掘り下げます。


なぜモノレポ(pnpm + Turborepo)なのか

公開サイトと管理コンソールは、別々のアプリでありながら、同じデータ構造と同じ本文レンダリングを共有します。この「共有」をどう扱うかが最初の分岐点でした。

答えは pnpm workspaces + Turborepo によるモノレポです。共有したいものはすべて packages/ に置きます。

neo-diary/
├── apps/
│   ├── web/        # 公開サイト(ポート3000)
│   └── console/    # 管理コンソール(ポート3001)
└── packages/
    ├── db/         # Drizzleスキーマ、マイグレーション、型付きクライアント、ロケール定数
    └── content/    # Markdownパイプライン、ショートコードレジストリ、共有型

肝は、共有パッケージをビルド済みの成果物ではなく、TypeScriptのソースのまま参照していることです(Next.jsの transpilePackages を使用)。これにより、packages/content を直したら両方のアプリに即反映され、ビルドの中間ステップを挟まずに済みます。

「両方のアプリが必要とするもの(スキーマ型・Markdown描画・ロケール定数)は必ず packages/ に置き、apps/ の中で二重に持たない」── このルールを最初に決めたことで、公開サイトのプレビューとコンソールのプレビューが構造的に一致するという前述の性質が自然に手に入りました。


なぜ Supabase なのか

「月ほぼ0円」と「ポータブル」の両方を満たす必要がありました。

  • 中身はただのPostgreSQL。 ここが決め手です。Supabaseは独自DBではなく素のPostgreSQL 17。だから、いざとなれば pg_dump / pg_restore で別のPostgresへそっくり移せます。ベンダーロックインの度合いが低い。
  • 無料枠でDB・ストレージ・認証がまとめて揃う。 個人ブログの規模なら実質ゼロ円。

スキーマはコードで管理(Drizzle ORM)

テーブル定義はTypeScriptで書き、そこからSQLマイグレーションを生成してgitにコミットします。スキーマそのものが、いつでもゼロから再現できるという状態を保つためです。これは後述する「災害復旧」の3本柱の1本でもあります。

スキーマ設計で気に入っているのは、assets テーブルの kind をあえてただのtext型にし、種類ごとの細かい情報を metadata(JSONB)に逃がしたことです。おかげで、後から音声・動画という新しいアセット種を足したときもマイグレーションは不要でした。「後で拡張する余地」をスキーマの型設計そのものに埋め込んだ形です。


なぜストレージを2つに分けたのか(Supabase Storage + Cloudflare R2)

  • 画像・PDF・バックアップ → Supabase Storage。 DBと同じ場所にまとまっていて扱いやすい。
  • 音声・動画 → Cloudflare R2。 理由はただ一つ、下り(egress)課金がゼロだから。動画は容量も転送量も大きくなりがちで、下り課金があるストレージに置くと、アクセスが増えたときに費用が読めなくなります。R2は10GBまで無料で、外に出す転送量に課金されません。

さらに音声・動画はブラウザから直接R2へアップロードします。署名付きのPUT URL(presigned URL、短命・単一オブジェクト・対象記事にスコープ限定)を発行し、大きなファイルがアプリのサーバーを一切通らないようにしました。副作用として、サーバーレスのリクエストボディ上限も回避できます。


なぜ Google翻訳を「REST直叩き」なのか

機械翻訳には Google Cloud Translation API v3 を使っています。ただし公式のgRPC SDKは入れず、RESTエンドポイントを fetch で直接叩き、google-auth-library でサービスアカウント署名だけ行う構成にしました。

理由は依存を軽くするためです。必要なのは「テキストを投げて訳を受け取る」だけ。そのために重いSDK一式を抱え込むより、認証だけ借りて薄く叩くほうがビルドも軽く、挙動も追いやすい。無料枠(月50万文字未満)に収まる個人利用なら、これで十分です。翻訳前には文字数をカウントして表示し、無料枠を意識できるようにしています。

editor console

なぜ UIフレームワークを使わないのか(素のCSS + CSS Modules)

TailwindもMUIも使っていません。スタイリングは素のCSSをCSS Modulesで書いています。

個人のブログという小さな範囲であれば、UIフレームワークの学習コストと吐き出すCSSの重さより、自分で書いたほうが軽くて、見通しがよくて、楽しいという判断です。カルーセルのようにロジックが重い部品だけ、ヘッドレスなライブラリ(Embla Carousel)を借りています ── 見た目は借りず、挙動だけ借りる方針です。

ちなみにトップページ背景の「夜の警察署」も、画像ではなくすべてCSSで描いています。他人のドット絵を1ピクセルも借りずに、それらしい雰囲気だけを立ち上げる ── これも「素のCSSで書く楽しさ」の産物です。


インフラ全体図

┌────────────── pnpm + Turborepo モノレポ ───────────────────┐
│  apps/web(公開サイト)      apps/console(管理コンソール)   │
│         │                          │                      │
│         └──────────┬───────────────┘                      │
│      packages/content(Markdown+ショートコード描画)        │
│      packages/db(Drizzleスキーマ・型・ロケール)             │
└──────────────────────┬────────────────────────────────────┘
                       │
      ┌────────────────┼───────────────────┐
      ▼                ▼                   ▼
  Supabase         Cloudflare R2       Google Cloud
  ・Postgres 17    ・音声/動画          ・Translation API v3
  ・Storage        ・presign直接アップ  ・サービスアカウント認証
  ・Auth(GitHub)   ・下り課金ゼロ
      ▲
      │ 週次 pg_dump
  GitHub Actions(cron)