1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Supabase Free プランだけで SaaS を運用する実践知見 — DB停止防止・接続プール・Storage

1
Posted at

はじめに

個人開発のSaaSをSupabase Free プランだけで本番運用しています。DB(PostgreSQL)+ ファイルストレージ + 認証(バックアップ)がすべて無料。

ただし、Free プランにはいくつかの落とし穴があります。本番運用で実際にハマったポイントと対策をまとめます。

Supabase Free プランのスペック

リソース 無料枠
データベース 500MB
ファイルストレージ 1GB
帯域幅 5GB/月
Edge Functions 500,000回/月
Auth MAU 50,000
リージョン 2拠点まで

個人開発の初期段階ではまず使い切らない量です。

落とし穴1: 7日間未アクセスでDBが停止する

最大の罠です。 Supabase Free プランでは、7日間データベースへのアクセスがないとプロジェクトが**自動停止(pause)**されます。

再開は可能ですが、数分かかります。その間サービスは完全にダウンします。

対策: Vercel Cron でヘルスチェック

// app/api/health/route.ts
import { prisma } from "@/lib/db";

export async function GET() {
    try {
        await prisma.$queryRaw`SELECT 1`;
        return NextResponse.json({
            status: "ok",
            timestamp: new Date().toISOString(),
        });
    } catch {
        return NextResponse.json(
            { status: "error" },
            { status: 500 }
        );
    }
}
// vercel.json
{
    "crons": [
        {
            "path": "/api/health",
            "schedule": "0 0 * * *"
        }
    ]
}

毎日0時にDBにアクセスして停止を防ぎます。SELECT 1 だけなので負荷はほぼゼロです。

注意: /api/health は認証不要にする必要があります(Vercel CronはCookieを持たないため)。Middlewareで公開ルートに追加しましょう。

落とし穴2: Prisma × Supabase の接続URL

Supabase は PgBouncer(接続プーラー)を介した接続を推奨しています。Prisma では2つのURLを設定する必要があります:

# アプリケーション用(PgBouncer経由、ポート6543)
DATABASE_URL="postgresql://postgres.[project-id]:[password]@aws-0-ap-northeast-1.pooler.supabase.com:6543/postgres?pgbouncer=true"

# マイグレーション用(直接接続、ポート5432)
DIRECT_URL="postgresql://postgres.[project-id]:[password]@aws-0-ap-northeast-1.pooler.supabase.com:5432/postgres"
// prisma/schema.prisma
datasource db {
    provider  = "postgresql"
    url       = env("DATABASE_URL")
    directUrl = env("DIRECT_URL")
}

Session Mode vs Transaction Mode

Supabase のプーラーには2つのモードがあります:

モード ポート 用途
Session Mode 5432 Prisma マイグレーション、PREPARE文
Transaction Mode 6543 アプリケーション接続(推奨)

PrismaでTransaction Mode(6543)を使う場合?pgbouncer=true パラメータが必須です。これがないとPrismaがPREPARE文を使おうとして失敗します。

落とし穴3: Supabase Storage のバケット設定

ファイルストレージは supabase.storage.from("bucket-name") で使いますが、バケットの設定に注意点があります:

バケット作成

Supabase ダッシュボード → Storage → New Bucket で作成:

  • Public: OFF(プライベート)
  • File size limit: 10MB
  • Allowed MIME types: application/pdf, image/jpeg, image/png, image/webp

ストレージ抽象化

ローカル開発ではファイルシステム、本番ではSupabase Storageを使う切り替えパターン:

// lib/storage/index.ts
import { createClient } from "@supabase/supabase-js";

const BUCKET = "invoices";

function isSupabaseConfigured(): boolean {
    return !!(process.env.SUPABASE_URL && process.env.SUPABASE_SERVICE_ROLE_KEY);
}

let supabaseClient: ReturnType<typeof createClient> | null = null;
function getSupabase() {
    if (!supabaseClient) {
        supabaseClient = createClient(
            process.env.SUPABASE_URL!,
            process.env.SUPABASE_SERVICE_ROLE_KEY!
        );
    }
    return supabaseClient;
}

export async function uploadFile(
    fileKey: string,
    data: Buffer,
    contentType: string
): Promise<void> {
    if (isSupabaseConfigured()) {
        const { error } = await getSupabase()
            .storage.from(BUCKET)
            .upload(fileKey, data, { contentType, upsert: true });
        if (error) throw error;
    } else {
        // ローカル: ファイルシステムに保存
        const filePath = path.join(".uploads", fileKey);
        await fs.mkdir(path.dirname(filePath), { recursive: true });
        await fs.writeFile(filePath, data);
    }
}

export async function downloadFile(fileKey: string): Promise<Buffer> {
    if (isSupabaseConfigured()) {
        const { data, error } = await getSupabase()
            .storage.from(BUCKET)
            .download(fileKey);
        if (error) throw error;
        return Buffer.from(await data.arrayBuffer());
    } else {
        return await fs.readFile(path.join(".uploads", fileKey));
    }
}

ポイント: SUPABASE_SERVICE_ROLE_KEY を使うことで、RLS(Row Level Security)をバイパスしてサーバーサイドから直接操作できます。このキーは絶対にフロントエンドに露出させないでください。

署名付きURL

プライベートバケットのファイルをフロントエンドに配信するには、署名付きURLを使います:

export async function getSignedUrl(fileKey: string, expiresIn = 3600): Promise<string> {
    const { data, error } = await getSupabase()
        .storage.from(BUCKET)
        .createSignedUrl(fileKey, expiresIn);
    if (error) throw error;
    return data.signedUrl;
}

API Route でリダイレクトする形にすると、フロントエンドからはAPI経由でファイルにアクセスでき、署名キーが露出しません。

容量の見積もり

実際の使用量を見積もります:

DB(500MB上限)

テーブル 1レコードあたり 想定レコード数 合計
User 0.5KB 1,000人 0.5MB
Invoice 2KB 10,000件 20MB
Supplier 0.5KB 5,000件 2.5MB
CreditTransaction 0.3KB 50,000件 15MB

合計 38MB — 500MBの7.6%。かなり余裕があります。

Storage(1GB上限)

ファイル種別 平均サイズ 想定件数 合計
請求書PDF 200KB 5,000件 1GB
請求書画像 500KB 2,000件 1GB

Storageは画像が多いとすぐ上限に近づきます。古いファイルの定期削除や、Pro プラン移行のタイミングを考えておく必要があります。

Free プランの制約まとめ

制約 影響 対策
7日間未アクセスで停止 サービスダウン Cron ヘルスチェック
500MB DB 長期的に不足可能 インデックスの最適化
1GB Storage 画像が多いと不足 古いファイル削除
2プロジェクトまで 複数サービス展開時に制約 1プロジェクトに集約
Supabase ロゴ表示義務なし なし
SLA なし 障害時の保証なし 重要データのバックアップ

まとめ

  • Supabase Free はSaaS初期運用に十分なスペック
  • 7日間停止防止のCronは必須(これを知らないとある日突然サービスが止まる)
  • Prisma × PgBouncer の接続URL設定は初見だとハマる
  • Storage の容量管理は計画的に(画像系サービスは注意)
  • 利用者が増えたら Pro プラン($25/月)に移行すればよい

Supabase Free で本番運用しているサービス: トルカ 振込アシスト — 請求書からAIで全銀フォーマットの振込データを自動生成

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?