Skip to main content

Overview

R2 is not a separate provider in the Tornado API. It is configured as provider: "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).
The connection test now checks the read half too. It used to write a probe object and delete it, and nothing else, so a token that could not read passed with a green tick and then 403’d every download. The probe now reads the object back and exercises the multipart create-and-abort lifecycle as well, so a token scoped too narrowly fails the test by name. See what it proves.Why reading matters: a presigned URL carries no permissions of its own. It is signed with the R2 API token you configured, and R2 evaluates the download as that identity.Choose Object Read & Write when you create the token. Cloudflare defines it as “Allows the ability to read, write, and list objects in specific buckets”, which is the one level that covers the write, the read, the multipart abort and the cleanup.
The hint under the credential fields in the wizard reads “Create an R2 API token with Object Write on this bucket.” That is not one of Cloudflare’s four permission levels. Pick Object Read & Write.

Before you start

You need:
  • 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/storage is gated on that; ordinary members can read the configuration but not change it.

1. Create the bucket

1

Open R2 in the Cloudflare dashboard

Go to the R2 object storage page and its Overview. Cloudflare has moved this entry point more than once (it currently sits under Storage & databases), so navigate by product name rather than by a fixed menu path.
2

Create bucket

Select Create bucket and enter a name. Cloudflare’s rules: lowercase letters, numbers and hyphens only, 3 to 63 characters, no leading or trailing hyphen. Dedicate the bucket to Tornado deliveries; a bucket-scoped token is pointless if the bucket also holds unrelated data.
3

Leave Location on the default

Under Location, leaving None selected lets R2 place the bucket in the closest available region to the request. A Location Hint is best effort, not a guarantee.Do not touch Specify jurisdiction unless you have a data residency requirement. It changes the hostname you must use, and the wizard cannot express it. See Jurisdiction-restricted buckets.
4

Create bucket

Confirm with Create bucket. The bucket stays private; Tornado delivers through presigned URLs and never needs a public bucket or an r2.dev domain.

2. Account ID and region

Account ID

Your account ID is a 32-character hex string such as b54f07a6c269ecca2fa60f1ae4920c99. Three reliable ways to get it, in order of resilience to dashboard redesigns:
  1. From the URL. It is the first path segment of any account-scoped dashboard URL: https://dash.cloudflare.com/<account-id>/r2/overview.
  2. From the account menu. On Account home, open the menu next to your account name and choose Copy account ID.
  3. From the Account Details panel shown on the R2 and Workers pages.
The same value is the subdomain of your S3 endpoint: 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.
Leave Account ID empty in the wizard and the derived endpoint is empty too. Tornado then falls back to AWS, signing a request to https://<bucket>.s3.auto.amazonaws.com, and the test fails with Could not connect to the endpoint URL: …. An amazonaws.com host in an R2 error message always means the account ID never reached the configuration.

3. Create a bucket-scoped API token

1

Open the R2 API token manager

On the R2 object storage page, under the Account Details section, select Manage next to API Tokens.
2

Choose the token type

Create Account API token is tied to the account and stays valid until revoked. Create User API token is tied to your personal Cloudflare user and dies with your access to the account.For a server integration that must keep delivering after you leave, use an Account API token. Only Super Administrators can create or view them.
3

Pick Object Read & Write

Under Permissions, Cloudflare offers four levels:Only the two Object levels can be scoped to specific buckets, which is the second reason to pick Object Read & Write.
4

Scope it to the one bucket

With an Object-level permission selected, restrict the token to the bucket you created in step 1. A token scoped to the wrong bucket fails the test with an access error, not with a message naming the bucket, so get this right while you can still see both names on screen.
5

Copy both values

After creation Cloudflare shows an Access Key ID and a Secret Access Key.
The Secret Access Key is shown once. Cloudflare cannot show it again; if you lose it, create a new token and delete the old one.
If you build the token through the Cloudflare API instead of the dashboard, the bucket-scoped permission group you want is Workers R2 Storage Bucket Item Write. The read-only counterpart, Workers R2 Storage Bucket Item Read, fails the write probe immediately.
R2 has no per-action IAM. There is no policy document to write, and no way to express s3: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: Recommended: the same level. R2 offers nothing finer that is worth having, so the minimum is the recommendation. Two things about how you issue it are worth one line each:
Do not narrow Object Read & Write afterwards through a custom Access Policy. Anything that drops read, delete or the multipart abort now fails the connection test rather than passing quietly, which is an improvement, but a policy that carves out only the delivery prefix still passes the test and then breaks deliveries: the probe writes to tornado-connection-test/ at the bucket root, not under videos/.

4. Fill in the four fields

The fields Tornado stores for R2 are exactly bucket, region, access_key_id, secret_access_key and endpoint. The wizard collects four of them and derives the fifth.
1

Onboarding wizard

On the Destination step, choose the Cloudflare R2 tile. The Credentials step shows four inputs: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.
2

Or Settings, after onboarding

Dashboard Settings, section Default storage destination. There is no R2 tile there. Choose provider Amazon S3 / S3-Compatible and fill the generic S3 form yourself:
Settings has a Save destination button and no “Test connection” button. Saving there does not probe your bucket. Verify with a real job, or call POST /settings/storage/test yourself.

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: The last three produce a broken endpoint, and the resulting error message is unhelpful. See internal error during storage test in troubleshooting.
Credentials submitted through the dashboard are written to Azure Key Vault, not to the application database. Only the provider name and a vault reference are stored alongside your organisation. Reading the configuration back returns the secret masked. If Key Vault is unreachable, the save is refused with a 503 rather than falling back to plaintext storage.

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.
The test route persists nothing. POST /settings/storage returns {"status": "saved", "vault": true}. The read-back returns the configuration with secret_access_key masked.

The s3 block

The endpoint is normalised before use: whitespace trimmed, an accidental doubled scheme (https://https://…) collapsed, https:// prepended when absent, trailing slash removed.
This page covers the dashboard configuration, which is per organisation. The public job API has a separate, older per-API-key route, 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 calls POST /settings/storage/test, which runs a five-step probe against your real bucket and reports the measured latency. No part is ever uploaded, so the whole probe still moves 23 bytes. Each step names itself in the error string, so a failure says which capability is missing rather than just 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).
It does not prove:
  • 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 to videos/* 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.
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 the s3_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 says Completed, 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.
1

Confirm the object is really there

Open the bucket in the R2 dashboard and find the key from the job payload. If the file is present, the upload path is fine and this is purely a read problem.
2

Re-run Test connection

It now reads. If it comes back AccessDenied: s3:GetObject, the token really cannot read and you are done diagnosing: reissue it as Object Read & Write.
3

If the test passes, suspect the prefix or the token identity

A green test with 403 links means the read is allowed on 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.
Replacing the token repairs existing links only if you keep the same credentials. A presigned URL is signed with a specific Access Key ID; revoke that token and every link already in your customers’ hands stops working, even inside its 24 hour window. Create the new token, save it in Tornado, confirm deliveries, and only then revoke the old one.

Edge cases

A bucket created with Specify jurisdiction is not reachable at the standard endpoint. Cloudflare uses a different host:
  • EU: https://<account-id>.eu.r2.cloudflarestorage.com
  • FedRAMP: https://<account-id>.fedramp.r2.cloudflarestorage.com
The wizard cannot express this. Paste an EU endpoint into Account ID and the normaliser extracts the account ID and rebuilds the standard endpoint, silently dropping the 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.
Requests are signed with AWS Signature Version 4, forced rather than defaulted. Cloudflare documents the R2 S3-compatible API as authenticating with SigV4, and boto3’s presigned URLs would otherwise fall back to the legacy SigV2 scheme, which does not work against R2.There is nothing to configure. It matters only if you are comparing Tornado’s behaviour against another S3 client that still defaults to SigV2 and appears to work elsewhere.
Uploads above 50 MB are sent as multipart, which is most videos, and with Tornado’s streaming upload path enabled a copy-only job of any size uses the multipart APIs. R2 implements 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.
Cloudflare’s current pricing page states: “There are no charges for egress bandwidth for any storage class.” Customer downloads of your delivered files do not incur a bandwidth bill.What is billed is storage and operations. Cloudflare classifies 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.
R2 tokens cannot be rotated in place. The sequence that avoids a gap:
1

Create the replacement

Create a second token with the same Object Read & Write permission and the same bucket scope.
2

Update Tornado

Paste the new pair into Settings and save, or POST /settings/storage. In-flight jobs finish with whichever credentials they already resolved.
3

Wait out the presigned window, then revoke

Confirm new jobs deliver and new links download. Links signed with the old token die the moment it is revoked, so if customers hold live links, wait out the 24 hour expiry before revoking.
The test writes 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.
By default objects go under a 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.

Custom Storage

All providers, the per-API-key configuration route, folder layout, and inline per-request credentials

Webhooks

The job.completed payload, including the presigned s3_url your token has to be allowed to read