Database
Next.js Techniques

個人開発者のためのコスト削減術:Supabase 1つのデータベースで複数プロジェクトを運用する

個人開発者のためのコスト削減術:Supabase 1つのデータベースで複数プロジェクトを運用する
サイドプロジェクトごとに別々のデータベースインスタンスを立てていませんか?PostgreSQL の Schema 機能を活用して、1つの Supabase データベースで複数のプロジェクトを分離管理する方法を解説。Drizzle ORM との統合方法や、Schema 間のデータ移行スクリプトも紹介します。すべて無料枠内で運用可能です。

インディー開発者にとって、データベースプロバイダーの選択は常にトレードオフとの戦いです。どの選択肢にも一長一短があります:

  • Supabase: 無料プランでは2プロジェクトまで。有料プランにアップグレードすると、追加プロジェクトごとに月額10ドルかかります。複数のサイドプロジェクトを抱えていると、コストがどんどん膨らんでいきます。
  • Neon: 100個の無料プロジェクトを謳っていますが、実際のコストドライバーはコンピュートタイムです。アプリにトラフィックが集まり始めると、CU時間がすぐに積み上がります。
  • セルフホスト PostgreSQL: 確かに初期コストは抑えられますが、メンテナンス、パフォーマンスチューニング、セキュリティ管理はすべて自分の責任になります。パッシブインカムとは程遠い状況です。

もっと良いアプローチがあります:PostgreSQL のスキーマ機能を使って、1つの Supabase データベースで複数プロジェクトを運用する方法です。

この戦略が特に有効なのは、トラフィックが少ない複数のプロジェクトを持っていて、合計しても Supabase の無料枠(月間5GBエグレス—ほとんどのサイドプロジェクトには十分)に収まる場合です。

PostgreSQL スキーマとは?

スキーマは PostgreSQL のネイティブ機能で、Supabase 独自のものではありません。ファイルシステムのフォルダのようなものだと考えてください。

1つのデータベースインスタンス内に、複数のスキーマを作成できます。各スキーマは独自のテーブルを持つことができ、互いに干渉しません。デフォルトでは Supabase は public スキーマを使用しますが、独自のスキーマを作成することも可能です。

これにより、1つのデータベース内で複数のプロジェクトを互いに干渉させることなく分離できます。

Supabase スキーマを使う理由

多くの人は Supabase の無料枠が2つのデータベースだけだと思っていますが、実際には2つのデータベースで5GBのエグレス通信量と500MBのストレージが使えます。この容量は、ほとんどのプロダクトでは使い切れません。複数のプロダクトでデータベースを共有できれば、コストを大幅に削減できるのではないでしょうか?

Supabase の schema を使えば、データベースリソースを共有しながら、各プロダクトのデータを互いに分離できます。無料枠を最大限に活用し、セキュリティも確保しながらコストを抑えられる—インディー開発者にとって最適な選択肢です。

移行ガイド:Neon から Supabase へ、スキーマ分離で

注意: 以下のファイルパスは NEXTY.DEV ボイラープレートを基準にしています。

Next.js + Drizzle ORM プロジェクトを Neon から Supabase に移行し、専用スキーマを使用する手順を説明します。

1. カスタムスキーマを使用するよう Drizzle を設定

まず、drizzle.config.tsschemaFilter を追加します。これにより、Drizzle は指定したカスタムスキーマのみを管理し、public スキーマのテーブルを誤って削除することがなくなります。

typescript
// drizzle.config.ts
export default defineConfig({
  // ... その他の設定
  schemaFilter: ['my_project_schema'], // <--- 重要:スキーマ名を指定
});

2. スキーマ定義を更新

次に、lib/db/schema.ts を修正して、デフォルトの public スキーマではなく、カスタムスキーマ内にすべてのテーブルを定義します。

プロのヒント: 最初にスキーマオブジェクトを定義し、それを使ってすべてのテーブルを作成しましょう。

typescript
// lib/db/schema.ts
import { pgSchema } from 'drizzle-orm/pg-core';

// 1. スキーマを定義
export const myProjectSchema = pgSchema('my_project_schema'); // <--- drizzle.config.ts の名前と一致させる

// 2. pgTable を myProjectSchema.table に置き換え
// 旧: export const user = pgTable('user', { ... })
// 新:
export const user = myProjectSchema.table('user', {
  id: uuid('id').primaryKey().defaultRandom(),
  // ... その他のカラム
});

// 3. Enum もスキーマにスコープする必要があります
// 旧: export const roleEnum = pgEnum('role', ['admin', 'user'])
// 新:
export const roleEnum = myProjectSchema.enum('role', ['admin', 'user']);

3. マイグレーション履歴をリセット

このような大規模な移行では、古いマイグレーションの上に増分マイグレーションを生成しようとしないでください。様々な問題に遭遇することになります。

代わりに、マイグレーション履歴をリセットして、Drizzle にすべてを最初から生成させましょう:

  1. 古いマイグレーションを削除: lib/db/migrations/ 内のすべてを削除します。
  2. 新しいマイグレーションを生成:
    bash
    pnpm db:generate
    # または
    pnpm drizzle-kit generate
    
  3. マイグレーションを適用して、Supabase に新しいスキーマとテーブルを作成:
    bash
    pnpm db:migrate
    # または
    pnpm drizzle-kit migrate
    

Drizzle が my_project_schema とすべてのテーブルを自動的に作成します。Supabase エディタで生の SQL を書く必要はありません。

4. スクリプトでデータを移行

次に、古いデータベース(Neon)から新しいデータベース(Supabase + カスタムスキーマ)にデータをコピーする必要があります。

.env に古い接続文字列と新しい接続文字列を追加し、以下のスクリプトを出発点として使用してください。AI アシスタントに依頼して、特定のスキーマに合わせてカスタマイズできます。

typescript
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from '../lib/db/schema';
import * as dotenv from 'dotenv';
import { getTableConfig } from 'drizzle-orm/pg-core';

dotenv.config();

const { OLD_DB_URL, NEW_DB_URL } = process.env;

// 移行順序を定義(外部キーの依存関係を考慮:基本テーブルを先に)
const tables = [
  { name: 'user', table: schema.user }, // 基本テーブル
  { name: 'pricing_plans', table: schema.pricingPlans },
  { name: 'orders', table: schema.orders }, // user と pricing_plans に依存
  // ... その他のテーブル
];

async function main() {
  const sourceClient = postgres(OLD_DB_URL!, { max: 1 });
  const destClient = postgres(NEW_DB_URL!, { max: 1 });
  const destDb = drizzle(destClient, { schema });

  for (const t of tables) {
    console.log(`テーブルを移行中: ${t.name}...`);

    // 1. カラムマッピングを構築(db_column -> schema_property)
    const columnMapping: Record<string, string> = {};
    const tableColumns = t.table;
    for (const key in tableColumns) {
      // @ts-ignore
      const col = tableColumns[key];
      // スキーマプロパティキーは通常 camelCase(例:cardTitle)
      // col.name は実際の DB カラム(例:card_title)
      if (col && typeof col === 'object' && 'name' in col) {
        columnMapping[col.name] = key;
      }
    }

    // 2. 古いデータベースからデータを取得(public スキーマを想定)
    const rows = await sourceClient`SELECT * FROM public.${sourceClient(t.name)}`;
    
    if (rows.length === 0) continue;

    // 3. データを変換(snake_case -> camelCase)
    const transformRow = (row: any) => {
      const newRow: any = {};
      for (const dbColName in row) {
        const schemaKey = columnMapping[dbColName];
        // スキーマキーが定義されていればそれを使用、なければ元のカラム名を維持
        newRow[schemaKey || dbColName] = row[dbColName];
      }
      return newRow;
    };

    // 4. 新しいデータベースにバッチ挿入
    const batchSize = 100;
    for (let i = 0; i < rows.length; i += batchSize) {
      const batch = rows.slice(i, i + batchSize).map(transformRow);
      await destDb.insert(t.table).values(batch).onConflictDoNothing();
    }
  }

  await sourceClient.end();
  await destClient.end();
  console.log('移行完了!');
}

main();

5. 本番環境の環境変数を更新

最後に、本番環境の .env を新しいデータベースを参照するように更新します:

env
DATABASE_URL=your-new-supabase-connection-string

アプリケーションを再デプロイすれば完了です。

まとめ

このアプローチを使えば、1つの Supabase データベースで10〜20個のトラフィックの少ないサイドプロジェクトを快適に運用できます。各プロジェクトは独自の分離されたスキーマを持ち、データはクリーンかつ安全に保たれます—しかも無料枠の範囲内で。

NEXTY.DEV ボイラープレートを使用している場合は、この移行を自動化する組み込みの Agent SKILL があります。それ以外の方は、このガイドを AI アシスタントと共有すれば、ほとんどの作業を代行してくれるでしょう。