Using R2
File URLs, public buckets, and signed uploads with Cloudflare R2
Most "files" on a listing, profile, or gallery are a URL. Host the bytes in R2 only when this app must own the upload.
Pick the first row that fits:
| What you need | What to build |
|---|---|
| Photo already on the web, or the user pastes a link | photoUrl: q.url() — no R2, no secrets |
| This app hosts the bytes; anyone with the link may view | Managed public bucket (readScope: "public") + UUID in the key |
| Large or confidential bytes; the URL itself must expire | Signed PUT/GET — needs an R2 API token |
Public-if-you-have-the-link is the usual read model (listing photos, avatars). Signed URLs are the smaller case. Details: R2 Setup → Read models.
By default defineFileStorage("cloudflare-r2") is presign-only: you get
the ctx.storage signer and nothing else. The /storage/v1/* endpoints
further down exist only in the managed subsystem
(managed: true).
Store a URL instead
A property listing, avatar, or hero image that is already a link does not need R2. Store the URL on the row. The CMS gets a URL input; the public listing page renders it. A browser deploy does not ask for R2 secrets.
import { q, defineTable } from "@quickback/compiler";
export const listings = q.table("listings", {
id: q.id(),
title: q.text().required(),
photoUrl: q.url(),
organizationId: q.scope("organization"),
...q.audit(),
...q.softDelete(),
});
export default defineTable(listings, {
read: { access: { roles: ["member+"] } },
crud: {
create: { access: { roles: ["member+"] } },
update: { access: { roles: ["admin+"] } },
delete: { access: { roles: ["admin+"] }, mode: "soft" },
},
guards: { createable: ["title", "photoUrl"], updatable: ["title", "photoUrl"] },
});q.url() rejects javascript: / data: URIs at the validation edge. Use
q.text() only when you must store a non-http value.
Upload into a public bucket (managed)
With managed: true, create a bucket with readScope: "public" and PUT bytes
through the Worker. No R2 API token. The files worker serves the object at
/public/... with no auth. Put a UUID in the path so the URL is not guessable.
# 1. Create the bucket once (write stays org-gated)
curl -X POST https://api.example.com/storage/v1/bucket \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{ "name": "listings", "readScope": "public", "writeScope": "organization", "writeRoles": ["admin", "member"] }'
# 2. Upload — bytes go through the Worker, binding only
curl -X POST https://api.example.com/storage/v1/object/listings/3f2a9c…-hero.jpg \
-H "Authorization: Bearer <token>" -H "Content-Type: image/jpeg" \
--data-binary @hero.jpg
# → { "key": "public/org_abc/listings/3f2a9c…-hero.jpg", "readScope": "public", … }
# 3. Store that URL on the listing (or POST /storage/v1/url to resolve it)Serve:
https://files.yourdomain.com/public/org_abc/listings/3f2a9c…-hero.jpgSigned upload (large or confidential files)
Write a defineAction that signs a presigned URL and stores the key in
your own table. Bytes go directly to R2 — the Worker only signs.
import { z } from "zod";
import { defineAction, media } from "../.quickback/define-action";
export default defineAction({
description: "Sign a direct-to-R2 upload URL and record the object key on the media row.",
path: "/media/sign-upload",
method: "POST",
input: z.object({ filename: z.string(), contentType: z.string() }),
access: { roles: ["member"] },
async execute({ input, ctx, db, storage }) {
const key = `org/${ctx.activeOrgId}/${crypto.randomUUID()}-${input.filename}`;
const upload = await storage.signPutUrl(key, { contentType: input.contentType, expiresIn: 600 });
await db.insert(media).values({ key /* … */ });
return { uploadUrl: upload.url, key, headers: upload.headers };
},
});# 1. Ask your action for a signed URL (your auth/roles run here)
curl -X POST https://api.example.com/api/v1/media/sign-upload \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{ "filename": "report.pdf", "contentType": "application/pdf" }'
# → { "uploadUrl": "https://<account>.r2.cloudflarestorage.com/...&X-Amz-Signature=...", "key": "...", "headers": { "Content-Type": "application/pdf" } }
# 2. Upload the bytes straight to R2 (replay the returned headers verbatim)
curl -X PUT "<uploadUrl>" -H "Content-Type: application/pdf" --data-binary @report.pdfstorage.signPutUrl(key, { contentType?, expiresIn?, bucket? }) returns
{ url, method, headers, key, expiresAt }; storage.signGetUrl(key, { expiresIn?, downloadFilename?, bucket? })
returns a presigned GET URL. Pass bucket to target a bucket other than the
configured default.
Presigning needs an R2 API token — set the R2_ACCOUNT_ID, R2_ACCESS_KEY_ID,
and R2_SECRET_ACCESS_KEY secrets. A browser deploy
refuses the project until those exist; it cannot mint them. Create the token in
the Cloudflare dashboard (R2 → Manage API Tokens, Object Read & Write) and set
the secrets with wrangler secret put / quickback deploy. See
R2 Setup → Presigned uploads.
Managed presign endpoints
With managed: true, the generated API also exposes turnkey presigned
endpoints backed by FILES_DB:
# 1. Request a presigned upload URL
curl -X POST https://api.example.com/storage/v1/presign/documents/report.pdf \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{ "contentType": "application/pdf", "size": 248000 }'
# → { "id": "obj_abc", "key": "...", "uploadUrl": "...", "headers": {...}, "expiresAt": "..." }
# 2. Upload directly to R2
curl -X PUT "<uploadUrl>" -H "Content-Type: application/pdf" --data-binary @report.pdf
# 3. Confirm (reconciles size + etag)
curl -X POST https://api.example.com/storage/v1/confirm/documents/report.pdf \
-H "Authorization: Bearer <token>"Download Flow
Public bucket / URL field — GET the stored URL. No session, no signature.
Presign-only, confidential files — mint a short-lived download URL with
storage.signGetUrl(key, { downloadFilename }) from your action.
Managed, session-gated buckets — the files worker re-checks membership:
# Download a file (auth + firewall enforced)
curl https://api.example.com/api/v1/files/file_abc123/download \
-H "Authorization: Bearer <token>"The download endpoint:
- Validates the user's session
- Checks firewall — the file must belong to the user's organization
- Checks access — the user must have the required role
- Streams the file from R2
Role-Based Access
File writes always respect the same security layers as your API:
- Firewall — Users can only upload files belonging to their organization
- Access — Role-based upload / delete permissions
File reads follow the bucket's readScope. public does not run those
checks on GET — the URL is the capability.
See Also
- R2 Setup — Read models, bucket creation, wrangler bindings
- Avatars — Avatar upload UI integration
- Deploy from your browser — R2 secrets block Start deploys