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 から読み書きするまで を、wrangler の r2 bucket create → bindings → put/get で通します。
姉妹記事の Workers と D1 を通した方なら、本記事は同じ流儀のままほぼ 5 分で動きます。
R2 とは(S3 / GCS との違い)
オブジェクトストレージの選択肢は、個人開発者の現実的な候補が 3 つです。
| Cloudflare R2 | AWS S3 | Google Cloud Storage | |
|---|---|---|---|
| 互換 API | S3 互換(SDK 流用可) | S3(本家) | GCS 独自・S3 互換モードあり |
| 無料枠(2026 年現在) | 10 GB/月、書き 100 万、読み 1,000 万 | 5 GB(12 ヶ月限定)/2,000 PUT/20,000 GET | 5 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 ID と Secret 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 するだけ」
最小手順を振り返ります。
npx wrangler r2 bucket create my-fileswrangler.tomlに[[r2_buckets]] binding = "MY_BUCKET"を追加- Worker 側で
env.MY_BUCKET.put(key, body)/env.MY_BUCKET.get(key) - 公開配信は R2 → Public Access → Custom Domain で Worker を経由しない
- 既存 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 を相棒にしたプロンプト連鎖の実演を含めて統合しています。