$ cat ./blog/cloudflare-saas-100k-stack.md
個人開発で月 10 万円を目指す Cloudflare スタック構成 個人開発で MRR 10 万円を目指すとき、インフラ費用が収益の 10% を超えないことが最低条件になります。本記事では Cloudflare エコシステム前提で、売上の算数・アーキテクチャ・決済選択・Stripe Webhook 実装・落とし穴を、筆者が運用してきた構造から整理します。
author r43
date 2026-04-22 (2026年4月22日)
reading 6 min
commit 4e52e16 個人開発で MRR(月額定期収益)10 万円。税引前ベースで食い扶持の 1/3 にはなる金額ですが、1 人で SaaS を運用しつつこのラインを目指すなら、インフラ費用が収益の 10% を超えないこと を筆者は目安にしています(業界一般で SaaS の COGS はインフラ比率 5〜15% 程度)。本記事では、Cloudflare エコシステム前提 でそのラインを守りながら ¥100,000 / 月 を目指すときの技術選択と算数を、筆者が運用してきた構成から整理します。
※ 筆者自身はまだ ¥100,000 / 月 には到達していません。本記事は「どう目指す構造を組むか」を、現在の個人開発運用経験から整理した設計ノートです。
到達点
月額 ¥500〜¥3,000 帯のサブスク SaaS 1 本を運用し、有料ユーザー数 × チャーン率から逆算
固定インフラ費は Workers Paid $5 / 月 + 決済手数料 が中心
スケール時もインフラ従量部分が MRR の 5〜10% 以下に収まる
売上の算数
¥100,000 / 月 を目指すとき、価格と必要な有料ユーザー数の関係はこうなります。
月額価格 必要ユーザー数 備考 ¥500 200 低価格帯、チャーン影響が大きい ¥1,000 100 数が具体的で SEO から届く射程 ¥2,000 50 B2B 寄り、価値訴求が効き始める ¥3,000 34 スモール B2B、月次 ROI の計算が合う ¥10,000 10 専門特化、顧客サポートの時間が増える
実際には月次チャーンが 5〜10% 発生するので、必要ユーザー数 ÷ (1 − チャーン率) が新規獲得の最低ラインです。¥1,000 × 100 名を維持したいなら、毎月 5〜10 名の純増が必要になります。
Cloudflare スタックのコスト構造
個人開発でコスト面を極限まで下げるなら、Cloudflare は 2026 年時点で最も有利なプラットフォームの 1 つです。
Workers Paid $5 / 月 をベースに、D1 / Durable Objects / Queues / Pages Functions / Analytics Engine が従量課金で利用可能(Queues と SQLite-backed DO は 2026-02 以降 Workers Free でも利用可、実用量は Paid で解放される)
R2 は egress 課金なし 。画像・動画を配信する SaaS だと AWS S3 から R2 に移すだけで月数万円単位で変わるケースがある
Pages 静的配信は帯域もリクエスト数も無料
D1 は Workers Paid で 月 25B rows read / 50M rows written まで含まれる(2026-04 時点、公式 D1 pricing 要確認。Free プランだと 5M rows read / 100K rows written / 日 の日次制限があるので、本気で運用するなら Paid 推奨)
アクティブユーザー数百名規模の個人 SaaS であれば、月額インフラ費用は $5〜$20 に収まる 設計が現実的。MRR ¥100,000 に対し比率 1〜3%。このあと述べる決済手数料の方が遥かに大きいコストになります。
アーキテクチャ例 — 最小の 1 人 SaaS
典型構成を層ごとに整理します。
フロント : Astro (SSG) + Cloudflare Pages — ランディング / 記事 / 認証後ダッシュボードの静的部分
API : Workers + Hono — 認証・決済 webhook・データ API
DB : D1 + Drizzle ORM — ユーザー・サブスクリプション・利用ログ
ストレージ : R2 — ユーザー生成コンテンツ、エクスポートファイル
リアルタイム (必要時のみ): Durable Objects + Hibernation API
認証 : Better Auth + D1 — email/password + Google OAuth
決済 : Stripe / Polar / Lemon Squeezy のいずれか(後述)
計測 : Workers Logs + Analytics Engine + GA4
0 から 1 まで全て Cloudflare の無料/従量枠に収まる のが個人開発には効いてきます。必要になった機能だけ足せます。
決済プラットフォームの選択
日本居住の個人開発者が月 10 万円レンジで使うとき、現実的な選択肢は 3 つです。
Stripe — 最も汎用的、SDK と資料が厚い。日本は国内カード 3.6% (固定加算なし、2026-04 時点、公式 Stripe 料金ページ で要確認)。海外発行カードは +2% の加算あり。インボイス・SCA・Stripe Tax が揃う
Polar / Lemon Squeezy — Merchant of Record (MoR) 型。VAT / 消費税を彼らが代行してくれるので、個人開発者の税務負担が軽い。手数料はベースで Polar 4% + $0.40 / 件、Lemon Squeezy 5% + $0.50 / 件 (2026-04 時点、サブスクや海外カードで +1.5〜2% 上乗せ)
自前の口座振込 — 手数料は削れるが、サブスク課金 / 請求書 / 解約フローを全部自作。月 10 万円レンジでは時間費用対効果が悪い
筆者の方針 : 最初は MoR で税務を外注し、月 30 万を超えたあたりで Stripe 直結に切り替える。手数料の差額で税務コストが吸収できる分岐点がそのあたり。
Stripe Webhook の最小実装
Cloudflare Workers + Hono + Stripe Webhook の最小構成です。外せないのは 署名検証 と 冪等性 。
// src/index.ts
import { Hono } from "hono" ;
import Stripe from "stripe" ;
import { drizzle } from "drizzle-orm/d1" ;
import { eq } from "drizzle-orm" ;
import { subscriptions } from "./schema" ;
type Bindings = {
DB : D1Database ;
STRIPE_SECRET_KEY : string ;
STRIPE_WEBHOOK_SECRET : string ;
};
const app = new Hono <{ Bindings : Bindings }>();
app. post ( "/webhook/stripe" , async ( c ) => {
const sig = c.req. header ( "stripe-signature" );
if ( ! sig) return c. text ( "no signature" , 400 );
const body = await c.req. text (); // 署名検証のため raw body
const stripe = new Stripe (c.env. STRIPE_SECRET_KEY );
let event : Stripe . Event ;
try {
// Workers では Node 同期 crypto が使えないので Async 版を使う
event = await stripe.webhooks. constructEventAsync (
body,
sig,
c.env. STRIPE_WEBHOOK_SECRET
);
} catch {
return c. text ( "invalid signature" , 400 );
}
const db = drizzle (c.env. DB );
switch (event.type) {
case "customer.subscription.created" :
case "customer.subscription.updated" : {
const sub = event.data.object;
// Stripe API 2025-03-31 以降、current_period_end は Subscription から Subscription Item へ移った。
// 単一プランならインデックス 0 を参照する。
const periodEnd = sub.items.data[ 0 ]?.current_period_end;
await db
. insert (subscriptions)
. values ({
id: sub.id,
customerId: sub.customer as string ,
status: sub.status,
currentPeriodEnd: periodEnd ? new Date (periodEnd * 1000 ) : new Date (),
updatedAt: new Date (),
})
. onConflictDoUpdate ({
target: subscriptions.id,
set: { status: sub.status, updatedAt: new Date () },
});
break ;
}
case "customer.subscription.deleted" : {
const sub = event.data.object;
await db
. update (subscriptions)
. set ({ status: "canceled" , updatedAt: new Date () })
. where ( eq (subscriptions.id, sub.id));
break ;
}
}
return c. text ( "ok" ); // 200 以外を返すと Stripe が再送してくる
});
export default app;
Stripe SDK は Workers ランタイムで動きます。constructEventAsync を使う のが公式推奨(非同期版は WebCrypto ベース、同期版の constructEvent は Node の同期 crypto 前提なので Workers では避ける)。v11.10+ では createSubtleCryptoProvider() の明示指定は不要です。Secret は wrangler secret put STRIPE_WEBHOOK_SECRET で登録します。
成長ドライバーと Cloudflare のメリット
月 10 万円は「獲得チャネルが 1 本でも通れば現実的」なラインです。
SEO + ブログ — Edge で速い → Core Web Vitals が満点近く → 記事の検索順位が取りやすい。このサイトも同じ狙いで動いています
プロダクト LED — 無料プランで一定回数まで使わせ、上限で有料化。D1 でクォータを持てば十分実装可能
SNS / Show HN — リリース直後は効く。初期 CAC を下げられる個人向きの動線
Cloudflare の Edge パフォーマンスは、SEO(表示速度)と CVR(離脱率)両方に効く 。2026 年現在、速度は間接的な成長ドライバーです。
落とし穴
無料枠に慢心する — 月 10 万円を本気で目指すなら、早い段階で Workers Paid $5 を契約した方が Analytics Engine / Queues / DO の実用量が解放される(Queues / SQLite DO は Free でも動くが、日次制限がきつい)
チャーン率を軽視する — 月次 10% チャーンが続くと ¥100k には届かない。解約理由は 1 人ずつヒアリングしてでも潰す
決済手数料の見落とし — Stripe 日本 3.6%(固定額なし)で ¥100k のうち ¥3,600、MoR 系なら 5〜7% + 固定 $0.40〜0.50/件。必ず運用予算に織り込む
個人事業主の税務 — 所得が基礎控除額を超えれば確定申告が必要(2025 税制改正で基礎控除は合計所得に応じて 58〜95 万円のレンジに拡大、最新値は 国税庁 No.1199 参照)。課税売上 1,000 万円超で消費税課税事業者、インボイス登録者は売上額に依らず課税事業者。MoR を挟む間は VAT を外注できるが、日本の確定申告は別問題
週末だけで運用しようとする — CS と障害対応を平日含めて回せる想定が要る。最初はアラートを Slack Webhook に飛ばすだけで十分
まとめ
個人開発 × 月 10 万円は、Cloudflare エコシステム前提なら インフラ費 < 収益 1%、決済手数料 4〜7%、残りは全て人件費 という構造に収まります。技術選択より 価格設定 × チャーン率 × 獲得チャネル の設計が、直接リターンを動かす段階です。本記事のスタックはその床を支える最小構成、という位置づけです。
次回は Next.js 15 + Cloudflare Pages デプロイのつまずきポイント 10 選 を書く予定です。
参考
$ tree ./repo/
記事で登場したコードをひとつの最小リポジトリとしてまとめました。ファイルツリーから切り替えて、全体の構造をそのまま確認できます。
┌─ cloudflare-saas-100k-stack ────────── files 08 ────────────────────── ● ● ● ─┐ $ cat ./src/index.ts ts
# cloudflare-saas-stripe-starter
個人開発で月 10 万円を目指すときのベースとなる、 **Cloudflare Workers + D1 + Stripe** の最小 SaaS スターター。
[ 記事本文 ]( https://r43lab.com/blog/cloudflare-saas-100k-stack ) と対になる実動リポジトリです。
## Quick start
```bash
bun install
# D1 作成 → ID を wrangler.jsonc に貼る
npx wrangler d1 create saas
# マイグレーション
npx drizzle-kit generate
npx wrangler d1 migrations apply saas --local
npx wrangler d1 migrations apply saas --remote
# Secrets 登録
wrangler secret put STRIPE_SECRET_KEY # sk_live_... or sk_test_...
wrangler secret put STRIPE_WEBHOOK_SECRET # whsec_...
# ローカル dev (Stripe CLI で webhook をフォワード)
bunx wrangler dev
# 別ターミナル:
# stripe listen --forward-to localhost:8787/webhook/stripe
```
## 含まれるもの
- `/webhook/stripe` — Stripe Webhook を受けて D1 の `subscriptions` を upsert する最小ハンドラ
- `/api/me/subscription` — 自分のサブスク状態を返す保護 API の雛形
- `subscriptions` テーブル — id / customerId / status / currentPeriodEnd
- `migrations/0000_subscriptions.sql` — drizzle-kit 生成済みマイグレーション
**認証機能は本スターターに含まれていません** 。Better Auth + D1 の最小テンプレと組み合わせる想定で、認証済み customer_id とサブスクを紐づけるシンプルな構造です。
## 依存バージョンについて
`package.json` は **記事執筆時点 (2026-04) のスナップショット** です。Stripe SDK と Cloudflare 周辺はよく更新されるので、clone 後は一度最新化を推奨。
```bash
bun outdated
bun update
```
## Stack
Hono v4 · Drizzle ORM · Stripe SDK v22 · Wrangler v4 · Cloudflare Workers + D1 import { defineConfig } from "drizzle-kit" ;
export default defineConfig ({
schema: "./src/schema.ts" ,
out: "./migrations" ,
dialect: "sqlite" ,
}); -- drizzle-kit generate で自動生成される想定のマイグレーション。
-- スキーマ変更時は drizzle-kit で再生成して追記する。
CREATE TABLE ` subscriptions ` (
`id` text PRIMARY KEY NOT NULL ,
`customer_id` text NOT NULL ,
`status` text NOT NULL ,
`current_period_end` integer NOT NULL ,
`updated_at` integer NOT NULL
);
CREATE INDEX ` subscriptions_customer_id_idx ` ON `subscriptions` ( `customer_id` ); {
"name" : "cloudflare-saas-stripe-starter" ,
"private" : true ,
"type" : "module" ,
"scripts" : {
"dev" : "wrangler dev" ,
"deploy" : "wrangler deploy" ,
"db:gen" : "drizzle-kit generate" ,
"db:local" : "wrangler d1 migrations apply saas --local" ,
"db:remote" : "wrangler d1 migrations apply saas --remote" ,
"tail" : "wrangler tail --format=pretty"
},
"dependencies" : {
"drizzle-orm" : "^0.45.2" ,
"hono" : "^4.12.14" ,
"stripe" : "^22.0.2"
},
"devDependencies" : {
"@cloudflare/workers-types" : "^4.20260420.1" ,
"drizzle-kit" : "^0.31.10" ,
"typescript" : "^6.0.3" ,
"wrangler" : "^4.84.0"
}
} import { Hono } from "hono" ;
import { HTTPException } from "hono/http-exception" ;
import Stripe from "stripe" ;
import { drizzle } from "drizzle-orm/d1" ;
import { eq } from "drizzle-orm" ;
import { subscriptions } from "./schema" ;
type Bindings = {
DB : D1Database ;
STRIPE_SECRET_KEY : string ;
STRIPE_WEBHOOK_SECRET : string ;
};
const app = new Hono <{ Bindings : Bindings }>();
app. get ( "/" , ( c ) => c. text ( "cloudflare-saas-stripe-starter ok \n " ));
// ── Stripe Webhook ────────────────────────────────────────
// raw body を text で取ること・構築は constructEventAsync を使うことの 2 つが肝。
app. post ( "/webhook/stripe" , async ( c ) => {
const sig = c.req. header ( "stripe-signature" );
if ( ! sig) return c. text ( "no signature" , 400 );
const body = await c.req. text ();
const stripe = new Stripe (c.env. STRIPE_SECRET_KEY );
let event : Stripe . Event ;
try {
event = await stripe.webhooks. constructEventAsync (
body,
sig,
c.env. STRIPE_WEBHOOK_SECRET
);
} catch {
return c. text ( "invalid signature" , 400 );
}
const db = drizzle (c.env. DB );
switch (event.type) {
case "customer.subscription.created" :
case "customer.subscription.updated" : {
const sub = event.data.object;
// Stripe API 2025-03-31 以降、current_period_end は Subscription 本体から
// Subscription Item (sub.items.data[i]) に移った。単一プランなら index 0。
const periodEnd = sub.items.data[ 0 ]?.current_period_end;
const periodEndDate = periodEnd ? new Date (periodEnd * 1000 ) : new Date ();
await db
. insert (subscriptions)
. values ({
id: sub.id,
customerId: sub.customer as string ,
status: sub.status,
currentPeriodEnd: periodEndDate,
updatedAt: new Date (),
})
. onConflictDoUpdate ({
target: subscriptions.id,
set: {
status: sub.status,
currentPeriodEnd: periodEndDate,
updatedAt: new Date (),
},
});
break ;
}
case "customer.subscription.deleted" : {
const sub = event.data.object;
await db
. update (subscriptions)
. set ({ status: "canceled" , updatedAt: new Date () })
. where ( eq (subscriptions.id, sub.id));
break ;
}
// 他のイベントは必要になったら追加する:
// invoice.payment_failed → Slack / メールで通知
// invoice.payment_succeeded → 領収書メール
}
return c. text ( "ok" );
});
// ── サブスク状態を返す保護 API の雛形 ─────────────────────
// 実運用では認証ミドルウェアで customerId を c.set して使う。
app. get ( "/api/subscriptions/:customerId" , async ( c ) => {
const customerId = c.req. param ( "customerId" );
const db = drizzle (c.env. DB );
const rows = await db
. select ()
. from (subscriptions)
. where ( eq (subscriptions.customerId, customerId))
. all ();
if (rows. length === 0 ) throw new HTTPException ( 404 , { message: "no subscription" });
return c. json (rows[ 0 ]);
});
app. onError (( err , c ) => {
if (err instanceof HTTPException ) return err. getResponse ();
console. error (err);
return c. json ({ code: "internal" , message: "unexpected error" }, 500 );
});
export default app; import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core" ;
// Stripe Subscription を最小表現で受ける。
// Better Auth の user テーブルと customer_id で繋ぐ想定。
export const subscriptions = sqliteTable ( "subscriptions" , {
// Stripe の Subscription ID (sub_xxx) をそのまま主キーに
id: text ( "id" ). primaryKey (),
// Stripe の Customer ID (cus_xxx)。自サービスの user.id とは別管理。
customerId: text ( "customer_id" ). notNull (),
// "active" | "trialing" | "past_due" | "canceled" | "unpaid" | "incomplete" | "incomplete_expired" | "paused"
status: text ( "status" ). notNull (),
// 現サブスクリプション期間の終了(= 次回の請求 / 停止予定)
currentPeriodEnd: integer ( "current_period_end" , { mode: "timestamp" }). notNull (),
// 最終更新。webhook でこのフィールドを毎回更新する。
updatedAt: integer ( "updated_at" , { mode: "timestamp" }). notNull (),
});
export type Subscription = typeof subscriptions.$inferSelect; {
"compilerOptions" : {
"target" : "ES2022" ,
"module" : "ESNext" ,
"moduleResolution" : "Bundler" ,
"lib" : [ "ES2022" , "WebWorker" ],
"types" : [ "@cloudflare/workers-types" ],
"strict" : true ,
"noEmit" : true ,
"skipLibCheck" : true ,
"resolveJsonModule" : true ,
"isolatedModules" : true ,
"verbatimModuleSyntax" : true
},
"include" : [ "src/**/*" ]
} {
"$schema" : "node_modules/wrangler/config-schema.json" ,
"name" : "cloudflare-saas-stripe-starter" ,
"main" : "src/index.ts" ,
"compatibility_date" : "2026-04-01" ,
"d1_databases" : [
{
"binding" : "DB" ,
"database_name" : "saas" ,
"database_id" : "<paste id from `wrangler d1 create saas`>" ,
"migrations_dir" : "migrations"
}
],
// STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET は必ず Secret で登録する。
// wrangler secret put STRIPE_SECRET_KEY
// wrangler secret put STRIPE_WEBHOOK_SECRET
// vars に書くとコード公開と同義になるので絶対 NG。
"observability" : {
"enabled" : true
}
} slug cloudflare-saas-100k-stack│ files 8 click a file in the tree to switch