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.

  • Cloud Platforms
  • $cloudflare-r2
  • 219 lines
  • 2 files
  • ~2,250 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- cloudflare-r2

Installs to ~/.agents/skills/cloudflare-r2/ for every project on this machine.

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.

~/.agents/skills/cloudflare-r2/SKILL.md

--- name: cloudflare-r2 description: Stores and serves files with Cloudflare R2 object storage - Worker bindings (put, get, head, list, delete, multipart), the S3-compatible API with AWS… license: MIT ---

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

CallerPath
Code running in a Worker or Pages FunctionR2 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 objectsPresigned 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

  1. Read AGENTS.md, Wrangler config (r2_buckets), and how uploads currently work.
  2. 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); never await 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 on truncated and cursor.

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.com endpoint, not on custom domains.
  • Keep expiries short (minutes) and sign the ContentType so clients cannot upload something else under the same URL.
  • Presigned PUT cannot enforce a maximum size. If size matters, check HeadObject after upload and delete oversized objects, or upload through a Worker that checks Content-Length.
  • If uploads fail after an AWS SDK upgrade with checksum or header errors, set requestChecksumCalculation: "WHEN_REQUIRED" and responseChecksumValidation: "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

OptionUse
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 URLDevelopment only; rate limited and not cached
Worker in front of a private bucketAuth, 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) or If-Match for 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 list in 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: HIT on 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.yamlgenerated from this page

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.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem