💡 Tips

Cloudflare R2で画像アップロードを最安構成で実装する手順【個人開発・コスト比較付き】

結論:個人開発の画像ストレージはCloudflare R2一択

個人開発SaaSに画像アップロード機能を追加するとき、AWS S3はエグレス料金(データ転送費用)がネックで意外とコストがかさむ。一方、Cloudflare R2はエグレス料金が無料で、ストレージ料金もS3より安い。

本記事では、S3互換APIを持つR2をTypeScript(Node.js / Cloudflare Workers)から操作し、Presigned URLで安全に画像アップロードを実装する手順をコード付きで解説します。


AWSとCloudflare R2のコスト比較

まず数字で確認しましょう。2024年時点の料金体系をまとめました(為替は1USD=150円換算)。

項目AWS S3 (東京)Cloudflare R2
ストレージ単価$0.025/GB/月$0.015/GB/月
書き込みリクエスト$0.0053/1,000件$4.50/1,000,000件
読み出しリクエスト$0.00042/1,000件$0.36/1,000,000件
エグレス(外部転送)$0.114/GB無料
無料枠(ストレージ)5GB(12ヶ月のみ)10GB(永続)
無料枠(書き込み)100万件/月
無料枠(読み出し)1,000万件/月

具体的なシミュレーション

月間画像50GB保存・合計200GBのダウンロード・書き込み100万件が発生するとします。

  • S3: ストレージ$1.25 + エグレス$22.80 + リクエスト$5.30 ≒ 月$29.35(約4,400円)
  • R2: ストレージ$0.60 + エグレス$0 + リクエスト無料枠内 ≒ 月$0.60(約90円)

エグレスが無料なだけで月4,000円以上の差が生まれます。個人開発では致命的な差です。


前提と構成

本記事では以下の構成を想定します。

  • バックエンド: Node.js(またはCloudflare Workers)+ TypeScript
  • フロントエンド: Next.js / React(任意)
  • 実装方式: Presigned URL(クライアントが直接R2にアップロード)

Presigned URLを使うと、サーバーを画像データが通過しないため、サーバーの転送コストとCPU負荷を両方ゼロにできます。

クライアント → サーバー(Presigned URL発行)

クライアント → R2(直接PUT)

セットアップ手順

1. R2バケットの作成

Cloudflareダッシュボードで R2 → バケットを作成 をクリックし、バケット名(例: my-app-images)を入力して作成します。

カスタムドメインの設定(推奨):バケット → 設定 → カスタムドメインから自分のドメインをR2に向けると、CDNとして機能します。

2. APIトークンの発行

R2 → APIトークン管理 から以下の権限で発行します。

  • 権限: Object Read & Write
  • バケット指定: 作成したバケット名

Account IDAccess Key IDSecret Access Key をメモしてください。

3. 依存パッケージのインストール

R2はS3互換なので、AWS公式SDKがそのまま使えます。

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

4. R2クライアントの初期化

// lib/r2.ts
import { S3Client } from "@aws-sdk/client-s3";

const ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID!;

export const r2 = new S3Client({
  region: "auto",
  endpoint: `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
});

export const BUCKET_NAME = process.env.R2_BUCKET_NAME ?? "my-app-images";

環境変数(.env.local):

CLOUDFLARE_ACCOUNT_ID=xxxxxxxxxxxx
R2_ACCESS_KEY_ID=yyyyyyyyyyyy
R2_SECRET_ACCESS_KEY=zzzzzzzzzzzz
R2_BUCKET_NAME=my-app-images

Presigned URLの発行エンドポイント実装

サーバーサイド(Next.js Route Handler例)

// app/api/upload-url/route.ts
import { NextRequest, NextResponse } from "next/server";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { r2, BUCKET_NAME } from "@/lib/r2";
import { randomUUID } from "crypto";

export async function POST(req: NextRequest) {
  const { fileName, contentType } = await req.json();

  // 許可する拡張子を絞る(セキュリティ上重要)
  const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
  if (!ALLOWED_TYPES.includes(contentType)) {
    return NextResponse.json({ error: "Unsupported file type" }, { status: 400 });
  }

  // ファイル名をランダム化してパス traversal を防ぐ
  const ext = fileName.split(".").pop();
  const key = `uploads/${randomUUID()}.${ext}`;

  const command = new PutObjectCommand({
    Bucket: BUCKET_NAME,
    Key: key,
    ContentType: contentType,
    // 最大10MBに制限
    ContentLength: undefined, // Presigned URLではサイズ制限はポリシーで別途制御
  });

  const presignedUrl = await getSignedUrl(r2, command, {
    expiresIn: 300, // 5分間有効
  });

  return NextResponse.json({
    url: presignedUrl,
    key,
    // 公開URL(カスタムドメイン設定済みの場合)
    publicUrl: `https://images.example.com/${key}`,
  });
}

クライアントサイド(React)

// components/ImageUploader.tsx
async function uploadImage(file: File): Promise<string> {
  // 1. Presigned URLを取得
  const res = await fetch("/api/upload-url", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      fileName: file.name,
      contentType: file.type,
    }),
  });
  const { url, publicUrl } = await res.json();

  // 2. 直接R2にアップロード(サーバーを通過しない)
  await fetch(url, {
    method: "PUT",
    headers: { "Content-Type": file.type },
    body: file,
  });

  return publicUrl; // DBに保存するURL
}

export function ImageUploader() {
  const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;
    const publicUrl = await uploadImage(file);
    console.log("アップロード完了:", publicUrl);
  };

  return <input type="file" accept="image/*" onChange={handleChange} />;
}

セキュリティ上の注意点

Presigned URLを使う際に必ず対処すべき点です。

リスク対策
不正なファイル形式のアップロードサーバー側でMIMEタイプをホワイトリスト検証
巨大ファイルによるコスト爆発Content-Length ヘッダーチェック or バケットのライフサイクルルール
URLの使い回しexpiresIn を短く設定(300秒程度)
認証なしアップロードPresigned URL発行エンドポイントをログイン済みユーザーのみに制限
不要ファイルの蓄積R2のライフサイクルルールで一定期間後に自動削除

バケットをパブリックアクセス禁止にし、カスタムドメインからのみ配信する構成が最も安全です。


Cloudflare Workersで使う場合

Cloudflare Workers環境ではAWS SDKは不要で、バインディングで直接R2を操作できます。

// worker.ts
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method === "PUT") {
      const key = new URL(request.url).pathname.slice(1);
      await env.MY_BUCKET.put(key, request.body, {
        httpMetadata: { contentType: request.headers.get("Content-Type") ?? "" },
      });
      return new Response(`Uploaded: ${key}`, { status: 200 });
    }
    return new Response("Method Not Allowed", { status: 405 });
  },
};

interface Env {
  MY_BUCKET: R2Bucket;
}

wrangler.toml でバインディングを設定します:

[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "my-app-images"

Workers経由だとSDKの初期化も不要で、さらにシンプルに書けます。


まとめ

  • Cloudflare R2はエグレス無料により、画像配信が多いSaaSで圧倒的に安い
  • 無料枠(10GB/月・書き込み100万件)で小規模個人開発は事実上0円運用
  • AWS S3 SDKがそのまま使えるため移行コストが低い
  • Presigned URLでサーバーレスかつ安全なアップロードが実現できる
  • Cloudflare Workersと組み合わせるとバインディングでさらにシンプルに実装可能

インフラコストをとことん下げた個人開発をするなら、R2は最初に選ぶべきストレージです。

📚 おすすめ書籍

手を動かしてわかるCloudflareの教科書

Cloudflareのエコシステムを体系的に学ぶのにおすすめ

Amazonで見る →

クラウドコスト削減や設計をさらに深掘りしたい方は、以下の書籍も参考になります。

📚 おすすめ書籍

AWSではじめるクラウド開発入門

S3/R2の比較設計を学ぶ基礎として最適

Amazonで見る →