Cloud storage (Cloudflare R2)
The cloud deployment keeps all media and artifacts in one Cloudflare R2 bucket in the EU
jurisdiction. Self-hosting keeps using the bundled Garage container; the application code is
identical for both and is configured only through the MinIO__* keys (the prefix is historical).
This page is the operator reference for the R2 side. The code is ReelBolt.Shared/Storage
(S3ClientFactory, PresignedUrlService); the runner side of presigned URLs is in
runner-protocol.md; the VM layout is in
infra/README.md.
Settings for R2
These are the exact values the Inference API and the WorkflowEngine need. In the cloud compose
files they come from /opt/reelbolt/.env, so the variable names below are the .env names.
.env variable | Application setting | Value for R2 |
|---|---|---|
MINIO_ENDPOINT | MinIO__Endpoint | https://<account_id>.eu.r2.cloudflarestorage.com. The eu host is mandatory for a bucket created in the EU jurisdiction; the default host cannot see it. The r2_s3_endpoint OpenTofu output prints this value. |
MINIO_PUBLIC_ENDPOINT | MinIO__PublicEndpoint | The same URL. Presigned URLs are signed against it, and SigV4 signs the Host header, so it must be the host a runner or browser really connects to. Leaving it empty falls back to MinIO__Endpoint, which is the same host on R2, so empty also works there; set it explicitly anyway so a later move to a custom domain is a one-line change. |
MINIO_REGION | MinIO__Region | auto. The cloud compose files default to it. R2 accepts auto (and us-east-1) as the signing region. |
MINIO_FORCE_PATH_STYLE | MinIO__ForcePathStyle | true, which is also the code default. The cloud role files pass it through with a default of true. Path style (https://<acct>.eu.r2.cloudflarestorage.com/<bucket>/<key>) works on R2 and keeps the bucket out of the host name. |
MINIO_BUCKET | MinIO__BucketName | The bucket name from the bucket_names output, for example reelbolt-staging-objects. |
MINIO_ACCESS_KEY / MINIO_SECRET_KEY | MinIO__AccessKey / MinIO__SecretKey | An R2 API token's key pair (see "API tokens"). |
The AWS SDK settings that matter are already in S3ClientFactory and need no per-environment
configuration. See "Checksums and presigning" for why.
API tokens
Create tokens by hand in the Cloudflare dashboard (R2, Manage API tokens). The OpenTofu module creates buckets, lifecycle and CORS only; it never creates a key pair, so no storage secret is in state. Use one token per purpose, each scoped to a single bucket:
| Token | Permission | Used by | Where it lives |
|---|---|---|---|
| Application | Object Read and Write, only the app bucket | Inference API and WorkflowEngine (MINIO_ACCESS_KEY/MINIO_SECRET_KEY) | .env on the control and engine VMs |
| Smoke test | Object Read and Write, only the staging environment's bucket | The staging smoke checklist below, run from a laptop | Your password manager only; revoke or rotate after each use |
| OpenTofu state | Object Read and Write, only the reelbolt-tfstate bucket | tofu init/plan/apply (profile reelbolt-r2) | ~/.aws/credentials on the operator's machine |
| Cloudflare API token | Account: R2 Edit; Zone: DNS Edit on one zone | tofu apply (CLOUDFLARE_API_TOKEN) | Exported in the operator's shell |
The application token is deliberately not the OpenTofu token: the running services can read and
write objects but cannot change bucket configuration, delete the bucket or read other buckets.
A runner never holds an R2 token at all; it only receives presigned URLs. Rotation of each token
is in secrets.md.
Bucket lifecycle and CORS
infra/tofu/modules/r2 applies both to the app bucket (app_bucket, default objects).
| Rule | Effect |
|---|---|
expire-runner-staging | Objects under runner-staging/ are deleted one day after upload (staging_expiry_days). Promotion moves a verified output to its final key within minutes, so anything that old is an abandoned upload. |
abort-incomplete-multipart | Incomplete multipart uploads anywhere in the bucket are aborted after one day (abort_multipart_days). R2's own default is seven days, and the uploaded parts are billed until then. |
CORS browser-presigned-get-put | GET, HEAD and PUT from the origins in cors_allowed_origins (the env's app hostname). Exposed headers are ETag, Content-Length, Content-Range and Accept-Ranges; allowed request headers include Range and Content-Type. |
Limits worth knowing:
- Lifecycle rules run asynchronously inside R2. Expiry is "about a day", not to the minute, and nothing in the application relies on a staging object disappearing on a deadline.
- R2 CORS is per bucket, not per prefix. It cannot be restricted to
runner-staging/. What keeps a presigned URL on a staging key or inside the caller's own project is the Inference API that signs it (PresignedUrlServicerefuses any key outsideprojects/{projectId}/for GET and always writes PUT URLs torunner-staging/{jobId}/{n}), not the CORS rule. - Desktop and cloud runners are not browsers, so CORS does not apply to them.
- The per-plan retention of delivered objects (30 days by default) is not a bucket rule. The application deletes them (F10), because the period differs per plan.
- A PUT URL is signed with the
Content-Typeit was minted with. The uploader must send exactly that header or R2 answers 403SignatureDoesNotMatch. - R2 and Garage cannot sign a maximum object length into a presigned PUT. The platform checks the
size with a HEAD request in
PromoteAsyncand deletes an oversize staging object. PromoteAsyncuses a singleCopyObject, which R2 limits to 5 GiB. The largest cap in the runner protocol is 4 GiB (compile output), so the cap, not a multipart copy, is what keeps promotion working.
Checksums and presigning
AWS SDKs from early 2025 (.NET AWSSDK.S3 4.x, JavaScript v3.729.0 and later) send a CRC32
checksum on uploads by default (WHEN_SUPPORTED). R2 did not implement that and answered
Header 'x-amz-checksum-algorithm' with value 'CRC32' not implemented
(Cloudflare Community report).
Cloudflare's own SDK examples set requestChecksumCalculation: "WHEN_REQUIRED" and
responseChecksumValidation: "WHEN_REQUIRED" for exactly this reason
(R2 aws-sdk-js-v3 guide).
The same bug hits presigning: a presigned PUT has no body when it is signed, so the default mode
signs the CRC32 of an empty payload into the URL and the real upload then mismatches
(example report).
S3ClientFactory.Create already sets both flags to WHEN_REQUIRED on every client,
including the keyed "presign" client that mints URLs, for Garage's sake. That is the setting R2
needs too, so the cloud needs no extra flag. Presigned GET and PUT then carry only the
standard X-Amz-* query parameters (X-Amz-Algorithm, X-Amz-Credential, X-Amz-Date,
X-Amz-Expires, X-Amz-SignedHeaders, X-Amz-Signature) and no checksum parameter.
Two cautions for future changes:
- Do not remove the two
WHEN_REQUIREDlines, and do not build anAmazonS3Clientanywhere else without going throughS3ClientFactory. A client created with defaults works against Garage in tests and fails against R2. - The SDK's version pin (
AWSSDK.S34.0.103.4) is the version these flags were verified on in code. R2's checksum support has been moving; Cloudflare may eventually accept CRC32, butWHEN_REQUIREDremains correct either way. The staging smoke checklist below is the real proof and must be run once against a real R2 bucket before the first cloud deploy and after anyAWSSDK.S3upgrade. It has not been run against a live bucket yet (no Cloudflare account exists at the time of writing).
Staging smoke checklist
Run this against the staging bucket after the first tofu apply, with the smoke-test token. It
exercises every object-store path the product uses. Export ENDPOINT, BUCKET, AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY and AWS_DEFAULT_REGION=auto. Use the AWS CLI v2 with
aws configure set default.s3.addressing_style path and, because the CLI also defaults to CRC32,
aws configure set request_checksum_calculation when_required and
aws configure set response_checksum_validation when_required.
- Upload.
aws --endpoint-url "$ENDPOINT" s3 cp ./sample.mp4 s3://$BUCKET/projects/smoke/sample.mp4succeeds, andaws ... s3api head-objectreports the rightContentLength. - Presigned GET.
aws ... s3 presign s3://$BUCKET/projects/smoke/sample.mp4 --expires-in 900, thencurl -sS -o /dev/null -w '%{http_code}\n' "<url>"prints200. The URL names the$ENDPOINThost. - Presigned PUT to staging. Get a URL from the Inference API (or
aws s3 presignwith therunner-staging/smoke/0key), thencurl -sS -X PUT -H 'Content-Type: video/mp4' --data-binary @sample.mp4 "<url>"returns 200. Repeat without theContent-Typeheader and confirm the failure is 403 (the signed header is enforced), then check the URL contains nox-amz-checksum-*parameter. - Promote. Through the platform (a runner job, or an integration call to
PresignedUrlService.PromoteAsync), moverunner-staging/smoke/0toprojects/smoke/promoted.mp4. The staging key is gone, the final key has the same size, and an oversize cap deletes the staging object and raisesPresignedObjectTooLargeException. - Ranged streaming.
curl -sS -H 'Range: bytes=0-1023' -D - -o /dev/null "<presigned GET>"returns206withContent-Range: bytes 0-1023/<size>. Then open the object through the dashboard player orGET /api/v1/projects/{id}/files/{fileId}/mediawith aRangeheader (RangedObjectStreamer), seek mid-file and confirm playback continues without a full download. A browser seek from the app origin must also pass CORS: in devtools there is no CORS error on the ranged request. - Multipart abort. Start
aws s3api create-multipart-uploadonprojects/smoke/mp, upload one part, and leave it. After the rule runs (about a day)aws s3api list-multipart-uploadsno longer lists it. Optional on the first pass, but the only way to see the lifecycle rule work. - Staging expiry. The
runner-staging/smoke/0object from step 3, if still present, is gone after about a day. - Clean up. Delete
projects/smoke/and revoke or rotate the smoke-test token.
Record the date, the SDK version and the result in the staging status notes. If step 1 or 3
fails with x-amz-checksum-algorithm ... not implemented, a client was built without
S3ClientFactory; fix that, do not add a per-client workaround.