Cloudflare R2 を S3 互換オブジェクトストレージとして使う手順

Cloudflare Workers で API を立て、D1 でデータを残せるようになると、次に詰まるのは 「画像と動画の置き場」 です。プロフィール画像、サムネイル、PDF、サイトの og:image ── 数 MB のファイルが数千件積み上がる頃には、たいていの個人開発が AWS S3 か Google Cloud Storage を見に行って、ダウンロード課金(エグレス)の見積もり で手が止まります。

Cloudflare R2 は、その止まりを根本から外しに来たオブジェクトストレージです。S3 互換 API(既存の S3 SDK がそのまま動く)でありながら、エグレスが完全に無料。世界中のどこからダウンロードされても、転送量で課金されません ── これは S3 / GCS の料金構造とは原理的に違います。本記事では、R2 に 個人開発の最小バケットを 1 つ作って、Worker から読み書きするまで を、wranglerr2 bucket create → bindings → put/get で通します。

姉妹記事の WorkersD1 を通した方なら、本記事は同じ流儀のままほぼ 5 分で動きます。

R2 とは(S3 / GCS との違い)

オブジェクトストレージの選択肢は、個人開発者の現実的な候補が 3 つです。

Cloudflare R2AWS S3Google Cloud Storage
互換 APIS3 互換(SDK 流用可)S3(本家)GCS 独自・S3 互換モードあり
無料枠(2026 年現在)10 GB/月、書き 100 万、読み 1,000 万5 GB(12 ヶ月限定)/2,000 PUT/20,000 GET5 GB/月(無料枠は地域制限あり)
エグレス(ダウンロード課金)完全に無料100 GB/月まで無料、以降 $0.09/GB~1 GB/月まで無料、以降 $0.12/GB~
Workers との同居同居(env.MY_BUCKET で 1 行)別アカウント・別 IAM別アカウント・別 IAM
推奨ユースケース公開 / 配信が前提の画像・動画エコシステム重視、社内 / 業務系GCP 全般を使う場面

画像をどこかに置いて、Web から多くの人に配りたい」── このとき、S3 や GCS だと 配るほどに転送費が積み重なる。R2 はその構造を切り離すための設計です。個人開発の Web サービスで「画像が読まれるほど赤字」が消えるだけでも、本番運用に入ってからのストレスが大きく下がります。

R2 が トランザクション(細かい更新の集積)に向くわけではないので、データの種類で D1 と R2 を使い分けます ── 関係性のあるレコードは D1、ファイルそのものは R2。

手順 1:wrangler r2 bucket create でバケットを作る

Workers プロジェクト(my-api)の中で実行します。

cd my-api
npx wrangler r2 bucket create my-files

数秒で完了します。

Creating bucket 'my-files'...
✅ Created bucket 'my-files' with default storage class set to Standard.

S3 と違って リージョン選択は不要。R2 は Cloudflare のエッジ全体で「自動的にユーザーの近くに配信」される設計です。

手順 2:wrangler.toml に bindings を書く

Worker から R2 を見るための bindings を wrangler.toml に追加します。

name = "my-api"
main = "src/index.ts"
compatibility_date = "2026-01-01"

[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "my-files"

これで env.MY_BUCKET が Worker のコード側から見えます。D1 が env.DB、R2 が env.MY_BUCKET ── 同じ Worker から、bindings 名を変えて両方を 1 つの fetch ハンドラから扱えます。

手順 3:Worker から put / get する

src/index.ts を書き換えます。

interface Env {
  MY_BUCKET: R2Bucket;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    // アップロード:POST /api/upload/<filename>
    if (url.pathname.startsWith('/api/upload/') && request.method === 'POST') {
      const key = url.pathname.slice('/api/upload/'.length);
      await env.MY_BUCKET.put(key, request.body, {
        httpMetadata: { contentType: request.headers.get('content-type') ?? 'application/octet-stream' },
      });
      return Response.json({ ok: true, key });
    }

    // ダウンロード:GET /files/<filename>
    if (url.pathname.startsWith('/files/') && request.method === 'GET') {
      const key = url.pathname.slice('/files/'.length);
      const object = await env.MY_BUCKET.get(key);
      if (!object) return new Response('Not Found', { status: 404 });
      const headers = new Headers();
      object.writeHttpMetadata(headers);
      headers.set('etag', object.httpEtag);
      return new Response(object.body, { headers });
    }

    return new Response('Not Found', { status: 404 });
  },
};

put(key, body, options) が書き込み、get(key) が読み出し。writeHttpMetadata を呼ぶと、保存時の Content-Type などのメタデータをそのままレスポンスヘッダに復元してくれるので、画像なら画像の MIME で返ります。

ローカルで確認します。

npx wrangler dev

# アップロード
curl -X POST http://localhost:8787/api/upload/cat.jpg \
  -H 'Content-Type: image/jpeg' \
  --data-binary @./cat.jpg

# ダウンロード
curl http://localhost:8787/files/cat.jpg -o downloaded.jpg

サイズが一致すれば OK。npx wrangler deploy で本番へ。

手順 4:Public URL の付け方(カスタムドメイン)

R2 のオブジェクトに、直接外部からアクセスさせたい場合は、Worker を経由せずに R2 が自分で配信する設定をします。

Cloudflare ダッシュ → R2 → 該当バケット → Settings → Public Access → Custom Domain で、files.example.com のようなドメインを当てます(CF のゾーン上にあれば DNS と SSL は自動)。

これで https://files.example.com/cat.jpg が直リンクで動きます。Worker を経由しない分、レイテンシも CPU 課金も発生しません。「Web に配るだけの画像」は、Worker を通さない方が安く・速い

ただし、アクセス制御をかけたい(認証必要、署名 URL) 場合は、Worker 経由(手順 3 のパターン)にして、ヘッダ検査やトークン検証を Worker 側でやるのが筋。R2 の Public Custom Domain は「全部に公開」しかできない。

手順 5(任意):S3 互換 API で AWS SDK から叩く

既存のコードベースが AWS SDK(@aws-sdk/client-s3 など)で書かれているなら、R2 はそのまま流用できます。

import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';

const s3 = new S3Client({
  endpoint: `https://<account-id>.r2.cloudflarestorage.com`,
  region: 'auto',
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
});

await s3.send(new PutObjectCommand({
  Bucket: 'my-files',
  Key: 'cat.jpg',
  Body: buffer,
  ContentType: 'image/jpeg',
}));

R2 のダッシュ → Manage R2 API Tokens から、Access Key IDSecret Access Key を発行できます。既存の S3 移行CI / バックアップスクリプトからの書き込みには、こちらの経路が便利です。

ただし Worker の中で R2 を扱うなら、env.MY_BUCKET のほうが圧倒的に楽(IAM もエンドポイントも要らない)。経路を使い分けます。

挫折ポイント

  • Public Access が default で OFF:作りたてのバケットは外から見えません。Public Access を ON にしないと、https://files.example.com/cat.jpg が 404 を返す。意図せず公開してしまうのを防ぐ良い既定値です。
  • アップロード本体のサイズ上限:1 リクエストの put で送れるのは 5 GB まで。それ以上は Multipart Upload(複数チャンクに分割)が必要です。Worker から直接巨大ファイルを扱うより、フロントから署名 URL で R2 に直接 PUT させる構成のほうが現実的です。
  • 無料枠の Class A(書き込み)100 万 / 月:大量アップロードを伴う用途(ユーザー投稿型)では、上限に注意。重複アップロードを ETag や key の存在チェックで弾くだけで、書き込み回数は大幅に減ります。
  • put 後に即時で見えない錯覚:R2 は書き込み後の 強い一貫性を持ちます。put が成功したら次の get で必ず読めます(S3 のような結果整合の罠はない)── これが意外と知られていない美点です。
  • ファイル名に日本語や特殊文字:key は URL の一部になります。encodeURIComponent で必ずエスケープする、または 保存時に英数 + UUID にリネームする。後者のほうが事故が少ない。
  • Content-Type を保存していないput 時の httpMetadata.contentType を渡し忘れると、ダウンロード時に application/octet-stream が返り、ブラウザがファイルとしてダウンロードを始めてしまう。アップロード時に必ず保存
  • 間違って本番バケットを削除wrangler r2 bucket delete は確認なしで動きます。name = "my-files-prod" のように環境名を入れる規律が、誤爆を防ぎます。

まとめ:R2 は「bindings で put / get するだけ」

最小手順を振り返ります。

  1. npx wrangler r2 bucket create my-files
  2. wrangler.toml[[r2_buckets]] binding = "MY_BUCKET" を追加
  3. Worker 側で env.MY_BUCKET.put(key, body) / env.MY_BUCKET.get(key)
  4. 公開配信は R2 → Public Access → Custom Domain で Worker を経由しない
  5. 既存 AWS SDK からなら S3 互換エンドポイント を使う

これで、画像・動画・PDF の置き場が手元に揃いました。**「配るほど赤字」**の構造が消えるのは、個人開発の本番運用ではかなり効きます。

サイトの og:image を R2 に置いて、og:image のフィードを files.example.com/og/<slug>.png のような形にすると、SNS でシェアされるほど勝手にエッジに広がっていく ── ダウンロードを増やしても課金が増えない強みが、ここで最大化します。

R2 + Workers + D1 の 3 つが揃った時点で、個人開発のバックエンドはほぼ完成です。残るのは 「サイト本体(フロント)をどう配るか」 で、これは Cloudflare Pages が静的サイトを担当します。フロント(Pages)・API(Workers)・関係 DB(D1)・ファイル(R2)── すべて 1 つの Cloudflare アカウントで同居し、1 つの wrangler で動かせる構造になります。

そして、R2 から配ったファイルが、サイト上で何回見られているかを知りたくなったら、画像 / PDF の view 計測を GA4 のカスタムイベントで取る話が次です ── これは GA4 で最初に設定する 3 つのイベントGA4 で最初に見る 3 つの数字 が下地になります。

最初の 1 ファイルを、まず env.MY_BUCKET.put するところから。


ファイルが配れる、データが残せる、API が動く。けれど 読まれない/登録に至らない ── その構造を WHY → HOW で 1 冊にまとめたのが、下記の本です。個人開発者・インディー Web 作者・動画作者向けに、LP・短文・メールの戦場別の書き方と、Claude を相棒にしたプロンプト連鎖の実演を含めて統合しています。

#個人開発#Cloudflare#リファレンス