RenderScreenshot can copy every screenshot it renders into your own Cloudflare R2 bucket. You set it up once in the dashboard, and from then on each fresh render lands in R2 under a path you choose, with no code in your app. This page covers the R2-specific settings, path templates that your app can predict, public access with a custom domain, and a tested script that checks what arrived. It is part of our integrations hub. The full parameter reference is in storage configuration.
Paid plans only. Custom storage works on the Starter, Growth and Scale plans. See pricing.
Last tested: 2026-10-15. The check script ran with Node 22.18 and
@aws-sdk/client-s33.1152.0 against a local S3-compatible server (moto 5.2.4) and a local HTTP server standing in for the public domain. It listed objects newest first, flagged objects that returned 403 or could not be reached, and exited 1 for an empty prefix. We did not run it against a live R2 bucket for this page, so there are no dashboard screenshots yet.
How it works
We built RenderScreenshot's own storage on R2: every render is written to our R2 bucket first, and the X-Cache-URL link points there. When you configure custom storage, a background job copies that object into your bucket after the API has already answered. So:
- The API response is never slower because of your bucket, and an upload problem never fails the API call.
- Only fresh renders are copied. A request answered from cache (
cache.available) does not upload again. - The copy keeps the same bytes and content type as the image the API returned.
What you can build
- A permanent OG image store. The cache link lives 30 days at most. R2 keeps your social cards for as long as you want, behind your own domain.
- A visual archive. Daily captures of your pages under
archive/{domain}/{year}/{month}/{day}/, with an R2 lifecycle rule that deletes them after a year. - A data set for review. A batch job renders thousands of pages; the files collect in R2 where a Worker or a script processes them.
Step 1: create the bucket and token in Cloudflare
- In the Cloudflare dashboard, open R2 Object Storage → Create bucket. Name it, for example
my-screenshots. - Open Manage R2 API Tokens → Create API token. Choose Object Read & Write, and limit it to this bucket.
- Copy the Access Key ID and Secret Access Key. Cloudflare shows the secret once.
- Copy your Account ID (32 hex characters) from the R2 overview page.
Step 2: add R2 in the RenderScreenshot dashboard
Open the storage dashboard and fill in:
| Field | R2 value |
|---|---|
| Bucket Name | my-screenshots |
| Endpoint URL | https://<account_id>.r2.cloudflarestorage.com |
| Region | Leave blank (the API uses auto) |
| Access Key ID / Secret Access Key | From the R2 token |
| Path Template (Advanced) | See step 3 |
| Default ACL (Advanced) | Private |
| Public URL Base (Advanced) | Your custom domain, for example https://shots.example.com |
Save. The dashboard runs a connection test (a HeadBucket call) and turns storage on only when it passes. If it fails, the storage troubleshooting table maps each message to a fix. For R2, the usual causes are a wrong account ID in the endpoint or a token limited to another bucket.
Step 3: choose a path template
The path template decides each object's key. It must contain {hash} and {ext}, and it can use {year}, {month}, {day}, {timestamp}, {domain} and {uuid}. Date parts use UTC.
| Goal | Template | Example key |
|---|---|---|
| Your app links to the file at once | og/{hash}.{ext} |
og/3f9c2a1b.png |
| Group by site | sites/{domain}/{hash}.{ext} |
sites/example.com/3f9c2a1b.png |
| Daily archive, easy lifecycle rules | archive/{year}/{month}/{day}/{hash}.{ext} |
archive/2026/10/15/3f9c2a1b.png |
| Never overwrite | shots/{uuid}.{ext} |
shots/550e8400-....png |
Pick a predictable template if your app needs the R2 URL right after the request. {hash}.{ext} is the file name at the end of the X-Cache-Key response header, so with og/{hash}.{ext} your app can compute the key from the response alone: og/ plus the last segment of X-Cache-Key. Templates with {uuid} or {timestamp} cannot be predicted; list the bucket to find those files.
The template is set per account in the dashboard and applies to every upload. To skip the copy for one request, send "storage": { "enabled": false }.
Step 4: make the files public (optional)
R2 has no per-object ACLs for public access, so do not use public-read. Keep the ACL at Private and choose one of these:
- Custom domain (production). In the bucket's Settings → Public access, connect a domain such as
shots.example.com. Files are served through Cloudflare's cache, and you can add cache rules. - r2.dev subdomain (testing). Cloudflare rate-limits it and does not recommend it for production traffic.
Leave public access off if the files are for internal use; your app can then read them with the S3 API or a Worker binding.
Egress: R2 compared with S3
Screenshots are read far more often than they are written: every share of a page makes several crawlers fetch its OG image. That makes egress the cost that grows.
| Cloudflare R2 | Amazon S3 | |
|---|---|---|
| Data transfer out to the internet | No egress fee (Cloudflare R2 pricing page) | Billed per GB after the free allowance (AWS S3 pricing page) |
| Reads | Billed as Class B operations | Billed per GET request |
| CDN in front | Custom domain uses Cloudflare's cache | Usually CloudFront, billed separately |
| Per-object ACLs | Not supported | Supported, but disabled by default on new buckets |
Check both pricing pages for current rates before you decide. If your stack already runs on AWS and the images stay inside AWS, S3 can still be the simpler choice; see save screenshots to S3.
Check what arrived
Uploads are asynchronous and have no API response, so check the bucket itself. This script lists the 10 newest objects under a prefix and tests each one at your public URL.
// r2-latest.mjs - list the newest screenshots in an R2 bucket and check that each // one loads from your public URL. Node 22+, npm i @aws-sdk/client-s3 // // R2_ACCOUNT_ID=... AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... R2_BUCKET=my-screenshots \ // R2_PREFIX=screenshots/2026/10/ PUBLIC_BASE=https://shots.example.com node r2-latest.mjs import { S3Client, ListObjectsV2Command } from '@aws-sdk/client-s3'; const { R2_ACCOUNT_ID, R2_BUCKET, R2_PREFIX = '', PUBLIC_BASE } = process.env; const endpoint = process.env.R2_ENDPOINT ?? `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`; const r2 = new S3Client({ region: 'auto', endpoint, forcePathStyle: true }); // Read every page of the listing, then sort by upload time. const objects = []; let ContinuationToken; do { const page = await r2.send(new ListObjectsV2Command({ Bucket: R2_BUCKET, Prefix: R2_PREFIX, ContinuationToken })); objects.push(...(page.Contents ?? [])); ContinuationToken = page.IsTruncated ? page.NextContinuationToken : undefined; } while (ContinuationToken); const newest = objects.sort((a, b) => b.LastModified - a.LastModified).slice(0, 10); if (!newest.length) { console.error(`No objects under "${R2_PREFIX}". Uploads start after a fresh render on a paid plan; cache hits are not copied.`); process.exit(1); } let broken = 0; for (const obj of newest) { const line = `${obj.LastModified.toISOString()} ${String(obj.Size).padStart(8)} B ${obj.Key}`; if (!PUBLIC_BASE) { console.log(line); continue; } const publicUrl = `${PUBLIC_BASE.replace(/\/$/, '')}/${obj.Key.split('/').map(encodeURIComponent).join('/')}`; const res = await fetch(publicUrl, { method: 'HEAD' }).catch((err) => ({ ok: false, status: err.cause?.code ?? err.message, headers: new Headers() })); if (!res.ok) broken++; console.log(`${line}\n ${res.ok ? 'public ' : `HTTP ${res.status}`} ${publicUrl} ${res.headers.get('content-type') ?? ''}`); } if (broken) { console.error(`${broken} object(s) not reachable at ${PUBLIC_BASE}: check the bucket's public access or custom domain.`); process.exitCode = 1; }
Run it with the R2 token's keys. Omit PUBLIC_BASE to only list the files.
Errors and retries
- Connection test fails: storage stays off and no uploads happen. Fix the setting and save again.
- Upload fails after a render: the job retries transient S3 errors a few times. An upload that still fails is not queued again, so render that page again.
- Plan changes to Free: uploads stop. The files already in R2 stay.
Troubleshooting
- Nothing arrives, the test passed. Check that the token has Object Read & Write on this bucket, and that you are not reading cache hits.
- Files arrive, the public URL returns 403 or 404. Public access is off, or the custom domain is connected to another bucket.
- Keys contain
unknowninstead of a domain. The request rendered HTML or Markdown, not a URL, so{domain}has no host. Those keys usehtml_contentormarkdown_content.
If you render at the edge, the Cloudflare Workers integration shows how to call the API from a Worker that can also read this bucket through an R2 binding.