個人開発で月 10 万円を目指す Cloudflare スタック構成

個人開発で MRR 10 万円を目指すとき、インフラ費用が収益の 10% を超えないことが最低条件になります。本記事では Cloudflare エコシステム前提で、売上の算数・アーキテクチャ・決済選択・Stripe Webhook 実装・落とし穴を、筆者が運用してきた構造から整理します。

個人開発で 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 / 月 を目指すとき、価格と必要な有料ユーザー数の関係はこうなります。

月額価格必要ユーザー数備考
¥500200低価格帯、チャーン影響が大きい
¥1,000100数が具体的で SEO から届く射程
¥2,00050B2B 寄り、価値訴求が効き始める
¥3,00034スモール B2B、月次 ROI の計算が合う
¥10,00010専門特化、顧客サポートの時間が増える

実際には月次チャーンが 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 SqueezyMerchant 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 選 を書く予定です。

参考

記事で登場したコードをひとつの最小リポジトリとしてまとめました。ファイルツリーから切り替えて、全体の構造をそのまま確認できます。

$cat./src/index.tsts
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;
slugcloudflare-saas-100k-stackfiles8click a file in the tree to switch