はじめに
個人開発の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で全銀フォーマットの振込データを自動生成