はじめに
こんにちは、PB開発チームの井上健人です。
カメラを持って旅行に行くのが大好きで、行った場所で撮った写真を位置情報とともに振り返れるWeb サービスを個人用に開発しています。
世界三大夜景に数えられる、函館山からの夜景
プライベートの趣味と勉強を兼ねていて、構成はざっくり次のとおりです。
- アプリケーション本体(Next.js): 個人契約している ConoHa VPS の上で動かしている
- データベース・認証・画像ストレージ: Firebase(Firestore / Authentication / Cloud Storage for Firebase)
この構成自体は触りやすくて気に入っているのですが、開発と利用が進んでくると、コストとセキュリティの面で「このまま運用し続けるのはちょっと厳しいかも」と感じる場面が少しずつ増えてきました。
そこで、最近 SNS で「コスパ最強」と話題になっている Cloudflare の $5 プラン に、個人開発物のリリース先を丸ごと移管してみることにしました。
この記事は、なぜ移管しようと思ったのかという背景の整理と、実際のCloudflareのセットアップ手順(wrangler のセットアップや R2 / D1 / Secrets まわり)を行い、実際にリリースして5ドルでどれくらい使えるのかを見る記事となっています。
今の構成への不満
① 常時機能しているVPSのコストが地味に重い
ConoHa VPS は安価で扱いやすいのですが、個人開発で1台ずっと立てておくと、月額2,000円前後はかかります。ユーザーが自分だけ、という規模で使うのは、改めて考えるとちょっともったいない構成です。
サーバーレス系に寄せれば、リクエストが来ない時間帯はそもそも課金されません。個人開発のような「アクセスがまばらに発生するワークロード」とサーバーレスは相性がいいはず、というのが一つ目の動機でした。
また、VPSはVPSで使っている場所があるので、そちらのリソースに全力で振りたいというのも動機の一つです。
② Firebase Storage の「帯域幅」が写真サービスとは少し相性が悪い
もう一つの悩みは Firebase Storage(Cloud Storage for Firebase)の無料枠です。Blaze プランで使える上限を表にすると以下のようになっています。(Cloud Storage は 2025 年 10 月から Spark プラン非対応となり、Blaze 必須になってしまいました...涙)
| 項目 | 無料枠(Blazeプラン) |
|---|---|
| 保存容量 | 5 GB |
| ダウンロード帯域 | 100 GB / 月 |
| アップロード操作 | 5000 回 / 月 |
| ダウンロード操作 | 50,000 回 / 月 |
引用元: Firebase 料金プラン
保存容量は写真サービスとしてはそれなりに使えるレベルですが、地味にきついのが ダウンロード帯域の 100 GB/月 という制限です。1枚 2〜3 MB の写真でも、誰かがスクロールして数百枚を表示すれば、すぐ上限に届いてしまいます。CDN を挟めば改善するかもしれませんが、なるべく追加の管理コストは増やしたくありませんでした。
サーバー側は使った分だけ課金、画像配信側は帯域を気にせず配信できる、そんな個人開発者の要望を満たしてくれる神サービスが Cloudflare でした。
Cloudflareのサービスを見る
① Cloudflare Workers の $5 プラン
Cloudflare のサーバーレス基盤である Cloudflare Workers の料金体系は、無料プラン(Free)と $5 / 月〜の有料プラン(Paid)の二段構成になっています。表にすると次のとおりです。
| 項目 | Free | Paid($5 / 月〜) |
|---|---|---|
| リクエスト数 | 10万 / 日 | 1,000万 / 月込み (超過 $0.30 / 100万リクエスト) |
| CPU 時間(込み) | 10 ms / 呼び出し | 月あたり 3,000万 CPU ミリ秒込み (超過 $0.02 / 100万 CPU ms) |
| 1呼び出しあたり CPU 上限 | 10 ms | 30 秒 |
| 実行時間(待機含む) | 課金・上限なし | 課金・上限なし |
引用元: Cloudflare Workers Pricing
wow もうこれでいいじゃん、素晴らしすぎる
- $5 / 月で 1,000万リクエストまで含まれる: Firebase で運用していた頃は、5万リクエストを超えるか超えないかでした。無料プランでもカバーできます。
- CPU 時間と実行時間が分離されている: 外部 API を呼んで「待っているだけ」の時間は CPU 時間にカウントされません。Next.js のような外部 API を叩く頻度が高いアプリケーションには優しい料金体系であり、個人で使うには必要範囲だけで課金されるので魅力的でした。
- 同じ Cloudflare のエコシステム上に、画像配信に有利な R2 などが揃っている: ストレージサービスの R2、SQLite ベースの D1 をパブリックに出さず、Cloudflare 内で接続できるのはセキュリティ的に非常に魅力的でした。
② R2 の料金
先ほど触れたとおり、Cloudflare には Workers と並んで R2 というオブジェクトストレージがあり、S3 互換 API + エグレス無料という尖った料金設計をしています。
Cloudflare Workers の $5 プランに入ると、DB サービスの D1 も Paid ティアの寛大な上限(月 250 億行の読み取り・5,000 万行の書き込みなど)が同じ枠で使えるようになります。一方で、ストレージサービスの R2 は別枠での契約・課金となるので注意が必要です。
| 項目 | 無料枠(毎月) | 超過後(Standard) |
|---|---|---|
| 保存容量 | 10 GB-month | $0.015 / GB-month |
| アップロード操作 (Class A) | 100万 回 / 月 | $4.50 / 100万回 |
| ダウンロード操作 (Class B) | 1,000万 回 / 月 | $0.36 / 100万回 |
| ダウンロード帯域 (エグレス) | 無制限・$0 | 無制限・$0 |
Class A / B は雑にいうと「書き込み系」と「読み取り系」の分類で、PutObject のようなアップロードは Class A、GetObject のような画像取得は Class B にあたります。DeleteObject などの削除系はどちらにも該当せず、課金対象外です。
写真サービスは「表示回数 > アップロード回数」が圧倒的なので、読み取り側の無料枠を10倍厚く 取ってある R2 の配分は、旅行アプリにとって理想的なものでした。
Firebase Storage と無料枠を並べてみる
同じ「画像を置く先」として、Firebase Storage(Blaze プランの無料枠部分)と R2(Standard の無料枠部分)を同じ軸で並べると、以下のようになります。
| 項目 | Firebase Storage (Blaze) | R2 (Standard) |
|---|---|---|
| 保存容量 | 5 GB | 10 GB |
| ダウンロード帯域 | 100 GB / 月 | 無制限・無料 |
| アップロード操作 | 0.5万 回 / 月 | 100万 回 / 月 |
| ダウンロード操作 | 5万 回 / 月 | 1,000万 回 / 月 |
全項目で R2 が勝っている上に、ダウンロード帯域の上限がそもそも存在しない!!!
無料枠で収まったとしても、とりあえず毎月の 5 ドルはお布施として払おうと思えるほどの破格、凄すぎる。
Cloudflareを実際にセットアップして使ってみる
ここからは実際に手を動かして、wrangler で Cloudflare 上にアプリの土台を立てていきます。
wrangler は Cloudflare 公式の CLI ツールです。
① wranglerのセットアップ
# 動作確認用のディレクトリを作る mkdir my-photo-app && cd my-photo-app # プロジェクトローカルにインストール npm init -y npm install -D wrangler # バージョン確認 npx wrangler --version
インストールできたら、Cloudflare アカウントにログインします。wrangler login を叩くとブラウザが開いて OAuth 認可画面に飛ぶので、許可するだけです。
トークンでもいけますが、OAuth が楽でセキュリティ的にも安心です。
# 初回ログイン(ブラウザが開く) npx wrangler login # ログインできたか確認 npx wrangler whoami
whoami で自分のメールアドレスとアカウント ID が表示されれば成功です。以降のコマンドはすべてこのアカウント配下で実行されます。
② Workers環境とシークレットのセットアップ
ここからは、VPS で動いている既存の Next.js プロジェクトをそのまま Workers に載せていきます。
Cloudflare 公式が推している OpenNext の Cloudflare アダプタ (@opennextjs/cloudflare) を使うと、SSR や API Route が Workers ランタイム上で動くようにビルドし直してくれます。
最終的に npm run deploy だけで Next.js アプリが Workers にアップロードされます。
既存の Next.js プロジェクトのルートで、アダプタと wrangler を追加します。
npm install -D @opennextjs/cloudflare wrangler
Cloudflare 関連の設定は wrangler.jsonc に書きます。
// wrangler.jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "my-photo-app",
"main": ".open-next/worker.js",
"compatibility_date": "2026-05-21",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": {
"binding": "ASSETS",
"directory": ".open-next/assets"
},
"observability": {
"enabled": true
}
}
各項目を簡単に補足しておきます。
main: OpenNext のビルド成果物 (.open-next/worker.js) を指す。Next.js のソースを直接指定するわけではありません。assets: 静的アセット(/_next/static配下やpublic/)の出力先。Workers Assets 経由で追加課金なしで配信されます。compatibility_flags: Next.js が Node.js API を一部使うのでnodejs_compatを、SSR 内部のfetch挙動を本番と揃えるためにglobal_fetch_strictly_publicを入れています。observability: ダッシュボードからログやメトリクスが見られるようになります。デフォルト OFF なので明示的に ON にしておくと後で楽です。
OpenNext 側にも最小の設定ファイルを置きます。中身は空でも構いません。
// open-next.config.ts import { defineCloudflareConfig } from "@opennextjs/cloudflare"; export default defineCloudflareConfig({});
ローカル開発でも Workers の env(後で足す D1 / R2 のバインディング)にアクセスできるように、next.config.ts に1行だけフックを追加します。
// next.config.ts import type { NextConfig } from "next"; import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare/dev"; initOpenNextCloudflareForDev(); const nextConfig: NextConfig = { // 既存の設定はそのままでOK }; export default nextConfig;
package.json のスクリプトに、Workers 向けのプレビューとデプロイを足します。普段の dev / build はそのまま残しておけば、Next.js の開発体験はこれまで通りで進められます。
{ "scripts": { "dev": "next dev", "build": "next build", "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview", "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy" } }
普段の開発は npm run dev のままで OK です。
そして本番へのデプロイは npm run deploy でできます。
内部で OpenNext のビルドが走った後、wrangler deploy 相当の処理で Cloudflare にアップロードされ、初回は https://my-photo-app.<your-subdomain>.workers.dev というサブドメインが払い出されます。
npm run deploy
シークレットの設定は wrangler secret put を使います。値は Cloudflare 側で暗号化されて保管されます。
# 本番にシークレットをセット(実行するとプロンプトで値を入力) npx wrangler secret put FIREBASE_API_KEY # 登録されているシークレット名の一覧(値そのものは表示されない) npx wrangler secret list
ローカル開発用には、リポジトリにコミットしない .dev.vars を作って同じ名前で値を置いておけば、npm run dev / npm run preview の両方で自動的に読み込まれます。.gitignore にも必ず追加します。
# .dev.vars FIREBASE_API_KEY="ローカル用の値"
echo ".dev.vars" >> .gitignore
逆に、機密ではない設定値(公開URLやフィーチャーフラグなど)は wrangler.jsonc の vars に直書きしてしまえます。git にそのままコミットしてよい値だけを置く運用です。
// wrangler.jsonc に追記
{
// ... 既存の設定
"vars": {
"NEXT_PUBLIC_APP_URL": "https://my-photo-app.example.com",
"FEATURE_NEW_MAP": "true"
}
}
Next.js 側のコードからは、API Route や Server Component の中で getCloudflareContext() 経由で env を取り出せます。
// app/api/photos/route.ts import { getCloudflareContext } from "@opennextjs/cloudflare"; export async function GET() { const { env } = getCloudflareContext(); const key = env.FIREBASE_API_KEY; // 上で put したシークレットがここに入る // ... return Response.json({ ok: true }); }
ステージング / 本番のように環境を分けたいときは、wrangler.jsonc に env プロパティを書き足して npm run deploy -- --env production のように切り替えます。シークレットも環境ごとに分かれるので、npx wrangler secret put FIREBASE_API_KEY --env production のように --env を付けて登録します。
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "my-photo-app",
// ... 共通の設定
"env": {
"production": {
"name": "my-photo-app-prod",
"vars": {
"NEXT_PUBLIC_APP_URL": "https://my-photo-app.example.com"
}
},
"staging": {
"name": "my-photo-app-staging",
"vars": {
"NEXT_PUBLIC_APP_URL": "https://staging.my-photo-app.example.com"
}
}
}
}
ちなみに、CLI でなくても Vercel のような GUI 操作でリリースできます。
Workers & Pages → アプリケーションを作成する → GitHub を選択。
リポジトリを選択して接続します。

以降は CD が動き、main への push でリリース処理が走るところまで作ってくれます。
便利〜

③ D1のセットアップ
D1 は Cloudflare のサーバーレス SQLite です。コマンド一発で DB が作れます。
npx wrangler d1 create photo-app-db
実行すると、wrangler.jsonc に追記すべきスニペットが標準出力に出てきます。これを wrangler.jsonc の末尾に追記します。
database_id はプロジェクトごとに固有の値が払い出されます。git にコミットしても秘密情報ではない扱いですが、気になる場合は環境変数経由でも渡せます。
// wrangler.jsonc に追記
{
// ... 既存の設定
"d1_databases": [
{
"binding": "DB",
"database_name": "photo-app-db",
"database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
]
}
スキーマはマイグレーションファイルで管理します。wrangler d1 migrations create でファイルが生え、migrations/ ディレクトリ配下に番号付きの SQL ファイルが置かれます。
# migrations/0001_init_schema.sql が生成される npx wrangler d1 migrations create photo-app-db init_schema
中身に CREATE TABLE などを書いていきます。
-- migrations/0001_init_schema.sql CREATE TABLE photos ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, storage_key TEXT NOT NULL, lat REAL, lng REAL, taken_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX idx_photos_user ON photos(user_id);
migrations apply をローカルと本番それぞれに対して実行します。--local を付けるとローカル開発用の SQLite に、--remote を付けると本番の D1 に適用されます。
# ローカル npx wrangler d1 migrations apply photo-app-db --local # 本番 npx wrangler d1 migrations apply photo-app-db --remote
任意の SQL を直接叩きたいときは wrangler d1 execute が使えます。インタラクティブな確認や、ちょっとしたデータ投入に重宝します。
# 1行だけ実行(本番) npx wrangler d1 execute photo-app-db --remote --command "SELECT COUNT(*) FROM photos" # ファイルから実行(本番) npx wrangler d1 execute photo-app-db --remote --file=./scripts/seed.sql
④ R2のセットアップ
R2 はオブジェクトストレージで、こちらもコマンド一発でバケットが作れます。初回は Cloudflare ダッシュボードで R2 サービスを有効化する必要があるので、wrangler r2 bucket create が R2 is not enabled のようなエラーを返してきたら、ダッシュボードの R2 タブから一度 "Enable" を押してから再実行します(プランの確認や請求の承認が必要かもしれません)。
npx wrangler r2 bucket create photos-prod
wrangler.jsonc に R2 バケットのバインディングを追記します。
// wrangler.jsonc に追記
{
// ... 既存の設定
"r2_buckets": [
{
"binding": "PHOTOS",
"bucket_name": "photos-prod"
}
]
}
CLI からも直接オブジェクトを put / get できるので、まずは疎通確認として小さなファイルを上げてみるのが手っ取り早いです。
# ローカルの hello.txt を photos-prod バケットに置く echo "hello r2" > hello.txt npx wrangler r2 object put photos-prod/hello.txt --file=./hello.txt # オブジェクト一覧 npx wrangler r2 object list photos-prod # 取得して確認 npx wrangler r2 object get photos-prod/hello.txt --file=./downloaded.txt cat ./downloaded.txt
Next.js のサーバー側(API Route や Server Component)からは、D1 と同じく getCloudflareContext() で env を取り出して読み書きします。URL もアクセスキーも一切登場せず、env.PHOTOS という名前の「オブジェクト」をいじっているだけのように書けるのがポイントです。
// app/api/photos/[id]/route.ts などの API Route から import { getCloudflareContext } from "@opennextjs/cloudflare"; // 書き込み const { env } = getCloudflareContext(); await env.PHOTOS.put(`photos/${id}.jpg`, request.body, { httpMetadata: { contentType: "image/jpeg" }, }); // 読み出し const obj = await env.PHOTOS.get(`photos/${id}.jpg`); if (!obj) return new Response("Not found", { status: 404 }); return new Response(obj.body, { headers: { "Content-Type": obj.httpMetadata?.contentType ?? "image/jpeg" }, });
ブラウザから直接画像 URL を叩いて表示させたい場合は、ダッシュボードの R2 → 該当バケット → Settings から Public access を有効化するか、自分のドメインを Custom domain として紐づけます。前者は https://pub-xxxx.r2.dev/<key> 形式、後者は https://images.example.com/<key> 形式で配信できるようになります。CDN キャッシュも自動で効くので、画像配信の本番経路はカスタムドメイン側に寄せるのが定番です。
ここまでで出来上がる wrangler.jsonc
ここまでの作業で、wrangler.jsonc は最終的に次のような形になっているはずです。
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "my-photo-app",
"main": ".open-next/worker.js",
"compatibility_date": "2026-05-21",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": {
"binding": "ASSETS",
"directory": ".open-next/assets"
},
"observability": {
"enabled": true
},
"d1_databases": [
{
"binding": "DB",
"database_name": "photo-app-db",
"database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
],
"r2_buckets": [
{
"binding": "PHOTOS",
"bucket_name": "photos-prod"
}
]
}
この wrangler.jsonc をリポジトリにコミットしておけば、別マシンでも npm install && npm run deploy で全く同じ構成が再現できます。
Cloudflareリリース環境の使用状況を見てみる
実際に1日運用してみて、Workers / D1 / R2 がそれぞれどれくらいの枠を消費しているかを見ていきます。
Workers の使用状況
表示時点では無料枠を利用していますが、無料枠でも余裕です。 1 日あたり最大 10 万アクセスまで無料なので、あと 9.8 万アクセス分の余裕があります。

D1 / R2 の使用状況
こちらも無料枠に余裕で収まっています。
R2 へはこれから 50 GB ほどアップロードする予定ですが、1 GB あたり 0.015 ドルなので、月 1 ドルほどのコストになる見込みです。


結論、Cloudflare は最高
今回の移行で、コスト面・セキュリティ面の両方で個人開発のプロダクトを改善することができました。
Cloudflare の 5 ドルプラン、ぜひ使ってみてください。