Overview
R2 is not a separate provider in the Tornado API. It is configured asprovider: "s3", pointed at R2’s S3-compatible endpoint, with the region set to
auto. Every field, error string and behaviour on this page is the S3 path with
R2-specific values.
The onboarding wizard hides that: the Cloudflare R2 tile asks for an
Account ID and builds
https://<account-id>.r2.cloudflarestorage.com for you. Everywhere else, and
through the API, you supply that URL yourself as endpoint.
Tornado writes each completed file into your bucket, then hands your customers a
presigned GET URL for it (the s3_url / download_url field in job status
and in the job.completed webhook, valid 24 hours).
Before you start
- A Cloudflare account with an R2 subscription. Cloudflare gates token creation on it: “You must purchase R2 before you can generate an API token.” If you have not subscribed, open Storage & databases then R2 in the dashboard and complete the checkout flow.
- Your account ID, a 32-character hexadecimal string.
- An R2 API token scoped to the bucket, with read and write on objects.
- Admin or Owner role in your Tornado organisation.
POST /settings/storageis gated on that; ordinary members can read the configuration but not change it.
1. Create the bucket
Open R2 in the Cloudflare dashboard
Create bucket
Leave Location on the default
Create bucket
r2.dev domain.2. Account ID and region
Account ID
Your account ID is a 32-character hex string such asb54f07a6c269ecca2fa60f1ae4920c99. Three reliable ways to get it, in order of
resilience to dashboard redesigns:
- From the URL. It is the first path segment of any account-scoped
dashboard URL:
https://dash.cloudflare.com/<account-id>/r2/overview. - From the account menu. On Account home, open the menu next to your account name and choose Copy account ID.
- From the Account Details panel shown on the R2 and Workers pages.
https://<account-id>.r2.cloudflarestorage.com.
Region is always auto
Cloudflare states it plainly: “When using the S3 API, the region for an R2 bucket
is auto.” An empty value and us-east-1 alias to auto, but there is no
reason to rely on that.
The R2 tile sets region: "auto" for you. Through the API, or through the
generic S3 form in Settings, you must type it yourself.
3. Create a bucket-scoped API token
Open the R2 API token manager
Choose the token type
Pick Object Read & Write
Scope it to the one bucket
Copy both values
Minimum and recommended permissions
R2 has no per-action IAM. There is no policy document to write, and no way to expresss3:PutObject and friends separately: you pick one of Cloudflare’s four
permission levels and a bucket scope.
Minimum: Object Read & Write, scoped to the one delivery bucket.
That single level is exactly what Tornado exercises, no more and no less:
4. Fill in the four fields
The fields Tornado stores for R2 are exactlybucket, region,
access_key_id, secret_access_key and endpoint. The wizard collects four of
them and derives the fifth.
Onboarding wizard
region is set to auto for you and is not shown.Then press Test connection. On success the configuration is saved in the
same click; a failed probe saves nothing.Or Settings, after onboarding
What the Account ID field accepts
The wizard normalises whatever you paste before building the endpoint. Knowing the exact rule saves a round of guessing:internal error during storage test in
troubleshooting.
5. Configuring through the API
Same body as the wizard, without the Account ID helper: you build the endpoint yourself. The route is on the dashboard API, authenticated with your session token, and requires Admin or Owner.POST /settings/storage returns
{"status": "saved", "vault": true}. The read-back returns the configuration
with secret_access_key masked.
The s3 block
https://https://…) collapsed, https:// prepended when absent,
trailing slash removed.
POST /user/s3, on
api.tornadoapi.io. It names two of these fields differently, access_key and
secret_key, and it is flat rather than nested under an s3 key. See
Custom Storage for that surface, including its
per-key gotcha.What “Test connection” proves
Pressing Test connection callsPOST /settings/storage/test, which runs a
five-step probe against your real bucket and reports the measured latency.
AccessDenied.
It proves:
- The Access Key ID and Secret Access Key are valid and active.
- The account ID resolved to a real R2 endpoint that answered.
- The bucket exists and the token’s scope includes it.
- The token can write, read, abort a multipart upload and delete.
- The whole round trip completes inside the 20 second budget (5 s connect, 10 s read, one attempt, no retries).
- That a real delivery will fit. The probe moves 23 bytes and uploads no parts.
- That your token covers the delivery prefix. It writes to
tornado-connection-test/, so a custom Access Policy scoped tovideos/*passes the test and fails the job. - That the endpoint points at the jurisdiction you intended. A standard-endpoint bucket and an EU-jurisdiction bucket of the same name are different buckets.
- Anything about lifecycle rules or object lifecycle policies on the bucket.
Checking a real delivered link
The probe covers the read, so this is no longer the gap it was. It remains the only way to check the delivery prefix and the path your customers take. Run one real job, take thes3_url from the job status or the job.completed webhook,
and fetch a single byte:
Troubleshooting
The dashboard shows the backend’s own error string. Match on it exactly.internal error during storage test
This message is misleading. It is not a Tornado outage and it is not a
credentials problem.
The S3 client is constructed before the probe’s error handling begins, and the
AWS SDK validates the endpoint hostname at construction time. When that hostname
is not a legal host, the SDK raises an error outside the family the storage test
handles (ClientError and BotoCoreError), so it falls through to the generic
catch-all and you get this string instead of anything about the URL.
In practice it means the derived endpoint is malformed, which means the
Account ID field contains something that is not an account ID. A label, a
dashboard URL, an email quote, a stray space. Re-copy the bare 32-character hex
value and try again.
A wrong but well-formed account ID behaves differently and is worth knowing
apart: *.r2.cloudflarestorage.com resolves for any subdomain, so a one-character
typo produces a credentials-shaped error rather than a DNS error. If
InvalidAccessKeyId persists with a token you just created, check the account ID
before you regenerate the token.
Jobs complete, but downloads 403
The bucket contains the files, the job saysCompleted, and every s3_url
returns 403. The probe’s read leg makes a plainly unreadable token much less
likely than it used to be, so work down this list in order.
Confirm the object is really there
Re-run Test connection
AccessDenied: s3:GetObject, the token really
cannot read and you are done diagnosing: reissue it as
Object Read & Write.If the test passes, suspect the prefix or the token identity
tornado-connection-test/ but not on the delivery path, which points at a
custom Access Policy. Or the token that signed the link has since been
revoked: a presigned URL dies with its Access Key ID, expiry or no expiry.Edge cases
Jurisdiction-restricted buckets (EU, FedRAMP)
Jurisdiction-restricted buckets (EU, FedRAMP)
- EU:
https://<account-id>.eu.r2.cloudflarestorage.com - FedRAMP:
https://<account-id>.fedramp.r2.cloudflarestorage.com
eu. The probe then looks for your bucket at
the wrong host and fails with NoSuchBucket: s3:PutObject.Configure jurisdiction buckets through
the API instead, setting endpoint
explicitly to the jurisdiction host, or through Settings with the
S3-compatible form, where you type the endpoint yourself.Signature version
Signature version
Large files and multipart uploads
Large files and multipart uploads
CreateMultipartUpload, UploadPart,
CompleteMultipartUpload, AbortMultipartUpload and ListMultipartUploads,
so nothing special is needed on your side.There is no separate permission to grant, because R2 has no per-action IAM:
Object Read & Write covers the whole lifecycle including the abort. This
is the one place where R2 is genuinely simpler than AWS, where the abort is
its own IAM action that people forget.What the abort protects you from is smaller on R2 than on S3, but not zero.
Cloudflare gives every bucket a default lifecycle rule to “expire multipart
uploads seven days after initiation”, so an upload orphaned by a failed
delivery clears itself within a week rather than sitting there forever. Until
it does, its parts occupy the bucket. AbortMultipartUpload is itself a free
operation on R2, so letting Tornado clean up immediately costs nothing.What R2 charges for a Tornado delivery
What R2 charges for a Tornado delivery
PutObject,
CreateMultipartUpload, UploadPart and CompleteMultipartUpload as Class
A operations, GetObject as Class B, and DeleteObject and
AbortMultipartUpload as free. So each small delivery is one Class A
operation, each customer download is one Class B operation, and the
connection test costs two Class A operations (the write and the multipart
create), one Class B (the read-back), and two free ones (the abort and the
delete).Rates change; read them on
Cloudflare’s R2 pricing page
rather than from this page.Rotating the R2 API token
Rotating the R2 API token
Create the replacement
Update Tornado
POST /settings/storage.
In-flight jobs finish with whichever credentials they already resolved.Wait out the presigned window, then revoke
The probe objects
The probe objects
tornado-connection-test/<random>.txt, reads it back, and
deletes it last. It also opens and immediately aborts a multipart upload on
tornado-connection-test/<random>.txt.multipart, with no part uploaded, so
that key never becomes an object.If you see the tornado-connection-test/ prefix accumulating objects, the
delete leg is failing; check for a lifecycle or retention rule on the bucket.
A test that fails at the read, create or abort step still tries to remove its
probe object on the way out, so leftovers there point at a delete problem
specifically.Where files land inside the bucket
Where files land inside the bucket
videos/ top-level folder, with any
folder_prefix nested inside it and the per-job folder below that. Both are
configurable. See Custom Storage for the full path
layout, including base_folder.Related
Custom Storage
Webhooks
job.completed payload, including the presigned s3_url your token has to be allowed to read