Cloudflare R2 skill for Codex
Zero-egress storage with uploads locked down. Codex picks the right R2 access path: Worker bindings inside Cloudflare, the S3 API with region auto and bucket-scoped tokens elsewhere, and short-lived presigned URLs for browsers. Object keys are server-prefixed and sanitised, CORS names exact origins, and public assets go through a cached custom domain instead of the rate-limited r2.dev URL.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- cloudflare-r2 Installs to ~/.agents/skills/cloudflare-r2/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install cloudflare-r2 The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project cloudflare-r2 Installs to .agents/skills/cloudflare-r2/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/cloudflare-r2.zipunzip -o cloudflare-r2.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) cloudflare-r2 PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $cloudflare-r2, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Stores and serves files with Cloudflare R2 object storage - Worker bindings (put, get, head, list, delete, multipart), the S3-compatible API with AWS SDK v3 and region auto, presigned upload and download URLs, CORS rules, public buckets via custom domains versus r2.dev, lifecycle rules, Infrequent Access, scoped API tokens and migrating from S3. Use when adding uploads or downloads backed by R2, generating presigned URLs, serving public assets from a bucket, configuring R2 CORS, or moving objects from Amazon S3 to R2.
Use it when
- Storing or serving files from Cloudflare R2
- Generating presigned upload or download URLs
- Configuring R2 CORS or a public bucket domain
- Moving objects from Amazon S3 to R2
Use something else when
- The storage is Amazon S3 itself: use aws-s3
- You are hardening a general upload endpoint against malicious files: use file-upload-security
How to check the result
A presigned PUT returns 200 and the object has the signed content type, a disallowed origin fails CORS preflight, and public assets show a cache HIT on repeat requests.
The mistake it prevents
Shipping the r2.dev URL to production, which is rate limited and bypasses Cloudflare caching and security features.
Done well means: buckets are private unless the user wants public assets, public assets are
served through a custom domain with caching (never the rate-limited r2.dev URL in
production), browser uploads go straight to R2 through short-lived presigned URLs with a
tight CORS policy, credentials are bucket-scoped tokens, and object keys never come from raw
user input.
Choose the access path
| Caller | Path |
|---|---|
| Code running in a Worker or Pages Function | R2 binding (env.BUCKET): no credentials, no egress, lowest latency |
| Server outside Cloudflare (Node, Python, Go, CI) | S3 API with an R2 API token |
| Browser upload or download of private objects | Presigned URL from the S3 API, or a Worker that authorises and streams |
| Public static assets (images, downloads) | Custom domain connected to the bucket, cached at the edge |
| Existing S3 tooling (rclone, aws CLI) | S3 API endpoint with region auto |
Inspect first
- Read
AGENTS.md, Wrangler config (r2_buckets), and how uploads currently work. - Inventory (needs network and auth):
npx wrangler r2 bucket list
npx wrangler r2 bucket info shop-assets
npx wrangler r2 bucket cors list shop-assets
npx wrangler r2 bucket lifecycle list shop-assets
npx wrangler r2 bucket domain list shop-assets
Binding usage in a Worker
"r2_buckets": [{ "binding": "FILES", "bucket_name": "shop-files", "preview_bucket_name": "shop-files-dev" }]
export default {
async fetch(req, env): Promise<Response> {
const url = new URL(req.url);
const user = await authenticate(req, env); // your auth
if (!user) return new Response("unauthorized", { status: 401 });
const name = url.pathname.split("/").pop() ?? "";
if (!/^[\w.-]{1,128}$/.test(name)) return new Response("bad name", { status: 400 });
const key = `users/${user.id}/${name}`; // server-chosen prefix
if (req.method === "PUT") {
const obj = await env.FILES.put(key, req.body, {
httpMetadata: { contentType: req.headers.get("content-type") ?? "application/octet-stream" },
customMetadata: { uploadedBy: user.id },
});
return Response.json({ key: obj.key, etag: obj.httpEtag, size: obj.size }, { status: 201 });
}
if (req.method === "GET") {
const obj = await env.FILES.get(key, { onlyIf: req.headers, range: req.headers });
if (!obj) return new Response("not found", { status: 404 });
const headers = new Headers();
obj.writeHttpMetadata(headers);
headers.set("etag", obj.httpEtag);
if (!("body" in obj)) return new Response(null, { status: 304, headers }); // precondition hit
return new Response(obj.body, { headers });
}
return new Response("method not allowed", { status: 405 });
},
} satisfies ExportedHandler<Env>;
- Stream bodies (
req.body,obj.body); neverawait req.arrayBuffer()on large files. - Worker request bodies are capped by the zone plan (100 MB on Free and Pro). Larger uploads
must use multipart (
createMultipartUpload,uploadPart,complete) or presigned URLs. list({ prefix, delimiter: "/", cursor, limit })returns up to 1,000 keys per page; loop ontruncatedandcursor.
S3 API from outside Cloudflare
Create an R2 API token scoped to specific buckets with Object Read or Object Read & Write (dashboard, R2 > Manage API tokens). Store the access key ID and secret in the platform’s secret store, never in the repo.
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
export const r2 = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
export async function uploadUrl(userId: string, fileName: string, contentType: string) {
const key = `users/${userId}/${crypto.randomUUID()}-${fileName.replace(/[^\w.-]/g, "_")}`;
const url = await getSignedUrl(
r2,
new PutObjectCommand({ Bucket: "shop-files", Key: key, ContentType: contentType }),
{ expiresIn: 300 },
);
return { key, url }; // client must PUT with the same Content-Type
}
export const downloadUrl = (key: string) =>
getSignedUrl(r2, new GetObjectCommand({ Bucket: "shop-files", Key: key }), { expiresIn: 600 });
aws s3 ls s3://shop-files --endpoint-url "https://$R2_ACCOUNT_ID.r2.cloudflarestorage.com" --region auto
- Presigned URLs work only on the
*.r2.cloudflarestorage.comendpoint, not on custom domains. - Keep expiries short (minutes) and sign the
ContentTypeso clients cannot upload something else under the same URL. - Presigned PUT cannot enforce a maximum size. If size matters, check
HeadObjectafter upload and delete oversized objects, or upload through a Worker that checksContent-Length. - If uploads fail after an AWS SDK upgrade with checksum or header errors, set
requestChecksumCalculation: "WHEN_REQUIRED"andresponseChecksumValidation: "WHEN_REQUIRED"on the client and retest; newer SDK defaults send integrity headers some S3-compatible services reject.
CORS for browser uploads
{
"rules": [
{
"allowed": {
"origins": ["https://shop.example.com"],
"methods": ["PUT", "GET"],
"headers": ["content-type"]
},
"exposeHeaders": ["ETag"],
"maxAgeSeconds": 3600
}
]
}
npx wrangler r2 bucket cors set shop-files --file cors.json
The JSON shape for Wrangler differs from the S3 PutBucketCors XML/JSON; if the command
rejects it, run npx wrangler r2 bucket cors set --help and match the version’s schema.
Never use "*" origins on a bucket that accepts uploads.
Public assets
| Option | Use |
|---|---|
Custom domain (npx wrangler r2 bucket domain add shop-assets --domain assets.example.com --zone-id <id>) | Production: Cloudflare cache, WAF, cache rules, analytics |
r2.dev public URL | Development only; rate limited and not cached |
| Worker in front of a private bucket | Auth, signed cookies, image transforms, per-user access |
Making a bucket public exposes every object in it. Confirm with the user and keep private
uploads in a separate bucket. Set long Cache-Control on immutable, content-hashed keys
(httpMetadata.cacheControl), and purge the cache when overwriting an existing key.
Lifecycle and storage class
npx wrangler r2 bucket lifecycle add shop-files --help # check flags for your Wrangler version
- Expire temporary prefixes (
tmp/, unfinished uploads) and abort incomplete multipart uploads after a day or two. - Infrequent Access storage is cheaper to store but charges for retrieval and has a minimum storage duration; use it for backups and archives, not hot assets.
Migrating from S3
- Sippy: incremental migration, R2 fetches from S3 on first read and copies the object.
- Super Slurper: bulk copy job from the dashboard.
- rclone with both remotes for scripted copies (
rclone copy s3:bucket r2:bucket --checksum).
Verify object counts and spot-check ETags for single-part objects (multipart ETags differ between providers). Deleting the S3 source is a separate step that needs user confirmation.
Gotchas
- Object keys are case-sensitive and
/is just a character; there are no real folders. - Overwrites are last-writer-wins. Use
onlyIf: { etagMatches }(binding) orIf-Matchfor optimistic concurrency. - R2 has no per-object ACLs; access is bucket-level plus whatever your Worker enforces.
- Egress is free, but Class A (writes, lists) and Class B (reads) operations are billed:
avoid
listin hot request paths. - Keys built from user-supplied paths enable overwriting other users’ files. Always prefix with a server-derived owner ID and sanitise the name.
Verification
curl -sS -X PUT -H 'Content-Type: image/png' --data-binary @test.png "$PRESIGNED_PUT_URL" -o /dev/null -w '%{http_code}\n'
curl -sI "https://assets.example.com/logo.abc123.png" | grep -iE 'cf-cache-status|cache-control'
npx wrangler r2 object get shop-files/users/u1/test.png --file /tmp/check.png --remote
- Presigned PUT returns 200, then the object exists with the right content type.
- A request from a non-allowed origin fails the CORS preflight.
- Public assets show
cf-cache-status: HITon the second request.
Report template
**Buckets:** shop-files (private, uploads), shop-assets (public via assets.example.com)
**Access:** Worker binding FILES; S3 token scoped to shop-files Object Read & Write (in secret store)
**Uploads:** presigned PUT, 5 min expiry, ContentType signed, keys users/<id>/<uuid>-<name>
**CORS:** https://shop.example.com, PUT/GET, content-type
**Lifecycle:** tmp/ expires 1 day; abort multipart after 2 days What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Cloudflare R2"
short_description: "Zero-egress storage with uploads locked down"
default_prompt: "$cloudflare-r2 Add direct browser uploads to R2 with presigned URLs from our Node backend" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.