File Storage (R2)
Quickback provides built-in file storage using Cloudflare R2.
Two modes
defineFileStorage("cloudflare-r2") has two modes:
- Presign-only (default) — emits just the
ctx.storagesigner (presigned PUT/GET URLs straight to R2). No new D1, no/storage/v1/*endpoints, no files worker. You compute keys and store file references in your own table. This is the recommended path for most apps and the lightest way to add uploads — see Presigned uploads. - Managed (
managed: true) — adds the turnkey subsystem on top: a FILES_DB metadata database (buckets + objects), the/storage/v1/*upload/download/ manage endpoints, and a public files worker for serving. Opt in only when you want buckets/objects handled for you instead of bringing your own media table.
// Presign-only (default) — ctx.storage against a bucket, nothing else
defineFileStorage("cloudflare-r2", { bucketName: "my-app-media" })
// Managed subsystem — adds FILES_DB + /storage/v1/* + files worker
defineFileStorage("cloudflare-r2", { managed: true, bucketName: "my-app-files" })New projects can omit bucketName — it defaults to <project>-media, so the
only setup is wrangler r2 bucket create <name> once plus the R2 API-token
secrets. Existing projects set bucketName to a bucket they already have.
The sections below document the managed subsystem; for the default presign-only path jump to Presigned uploads.
Architecture
┌─────────────────────────────────────────────────────────────────────┐
│ Your Application │
│ │
│ Upload/Manage Files Serve Files │
│ ───────────────────── ────────────── │
│ api.yourdomain.com files.yourdomain.com │
│ /storage/v1/* /* │
│ │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ API Worker │ │ Files Worker │ │
│ │ │ │ │ │
│ │ POST /bucket │ │ GET /public/* │ │
│ │ GET /bucket │ │ → No auth │ │
│ │ POST /object/* │ │ │ │
│ │ DELETE /object/* │ │ GET /* │ │
│ │ │ │ → Session + RBAC │ │
│ └──────────┬──────────┘ └──────────┬──────────┘ │
│ │ │ │
│ └───────────┬───────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ R2 Bucket │ │
│ │ quickback-files │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘Enabling File Storage
Add the fileStorage provider to your quickback.config.ts:
export default {
name: 'my-app',
providers: {
runtime: { name: 'cloudflare' },
database: { name: 'cloudflare-d1' },
auth: { name: 'better-auth' },
fileStorage: {
name: 'cloudflare-r2',
config: {
binding: 'R2_BUCKET',
bucketName: 'my-app-files',
filesBinding: 'FILES_DB',
maxFileSize: 10 * 1024 * 1024, // 10MB
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
},
},
},
};API Endpoints
Storage API (api.yourdomain.com/storage/v1)
| Method | Endpoint | Description |
|---|---|---|
| POST | /bucket | Create a bucket |
| GET | /bucket | List buckets |
| GET | /bucket/:name | Get bucket info |
| DELETE | /bucket/:name | Delete bucket (must be empty) |
| POST | /object/:bucket/*path | Upload file (bytes through the Worker) |
| POST | /presign/:bucket/*path | Presigned upload URL (bytes direct to R2) |
| POST | /confirm/:bucket/*path | Confirm a presigned upload (reconcile size + etag) |
| GET | /object/:bucket/*path | Download file |
| HEAD | /object/:bucket/*path | Get file metadata |
| DELETE | /object/:bucket/*path | Delete file (soft delete) |
| GET | /object | List objects |
| POST | /url | Get file URL for serving |
For anything larger than a small image — video, audio, big PDFs — prefer the presigned path. The bytes go straight to R2, so you skip the Worker's request-body cap and don't burn Worker CPU streaming the upload. See Presigned uploads below.
Files Worker (files.yourdomain.com)
| Method | Path | Auth Required |
|---|---|---|
| GET/HEAD | /public/* | No |
| GET/HEAD | /* | Yes (session + RBAC) |
Buckets
Buckets organize files and define access control policies.
Creating a Bucket
POST /storage/v1/bucket
{
"name": "avatars",
"readScope": "organization",
"writeScope": "organization",
"readRoles": ["admin", "member"],
"writeRoles": ["admin"],
"deleteRoles": ["admin"]
}Scope Options
| Scope | Read Behavior | Write Behavior |
|---|---|---|
public | Anyone can read | N/A |
organization | Org members only | Org members only |
user | Owner only | Owner only |
Role-Based Access
You can restrict operations to specific roles:
{
"readRoles": ["admin", "member"],
"writeRoles": ["admin", "editor"],
"deleteRoles": ["admin"]
}An empty array [] means no role restriction (all authenticated users).
Uploading Files
POST /storage/v1/object/avatars/profile.jpg
Content-Type: image/jpeg
Content-Length: 12345
<binary data>Response:
{
"id": "obj_123",
"key": "org_abc/avatars/profile.jpg",
"bucket": "avatars",
"name": "profile.jpg",
"size": 12345,
"mimeType": "image/jpeg",
"readScope": "organization"
}Presigned uploads (recommended)
Streaming bytes through the Worker (POST /object) is fine for small files, but
it puts every byte on the Worker's request path — subject to the body-size cap,
and billed as Worker CPU/duration. For media (video, audio, large PDFs), use a
presigned URL: the Worker runs all the access checks and hands back a
short-lived URL the client uploads to directly. The bytes never touch your
Worker.
# 1. Ask the API to sign an upload URL (auth + bucket + role checks run here)
curl -X POST https://api.example.com/storage/v1/presign/videos/clip.mp4 \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{ "contentType": "video/mp4", "size": 73400320 }'
# Response:
# {
# "id": "obj_abc",
# "key": "org_abc/videos/clip.mp4",
# "uploadUrl": "https://<account>.r2.cloudflarestorage.com/...&X-Amz-Signature=...",
# "method": "PUT",
# "headers": { "Content-Type": "video/mp4" },
# "expiresAt": "2026-06-02T12:10:00.000Z"
# }
# 2. Upload the bytes straight to R2 (replay the returned headers verbatim)
curl -X PUT "<uploadUrl>" -H "Content-Type: video/mp4" --data-binary @clip.mp4
# 3. Confirm — reconciles the recorded size + etag against what actually landed
curl -X POST https://api.example.com/storage/v1/confirm/videos/clip.mp4 \
-H "Authorization: Bearer <token>"If contentType is supplied at step 1 it is bound into the signature — the
client MUST send exactly that Content-Type header on the PUT, or R2 rejects
it. Omit it to let the client send any type.
Required secrets
Presigning uses R2's S3-compatible API, which needs an R2 API token — the Workers bucket binding alone cannot presign. Create a token in the Cloudflare dashboard (R2 → Manage API Tokens) and set:
wrangler secret put R2_ACCOUNT_ID # your Cloudflare account id
wrangler secret put R2_ACCESS_KEY_ID # R2 API token access key id
wrangler secret put R2_SECRET_ACCESS_KEY # R2 API token secret
# Optional: wrangler secret put R2_S3_ENDPOINT # override the derived endpointSigning from your own action
The same signer is exposed as ctx.storage inside any
defineAction — so you can author an upload endpoint that
runs your own access rules (org, team, relationship/scoped roles) and stores the
key in your own table, instead of the generic buckets/objects metadata:
export default defineAction({
path: '/event/:eventId/media/sign-upload',
method: 'POST',
input: z.object({ filename: z.string(), contentType: z.string() }),
access: { roles: ['admin', 'member', 'scope:event:attendee'] },
async execute({ input, ctx, db, storage }) {
// Tenant-scoped key — secure by construction
const key = `org/${ctx.activeOrgId}/event/${input.eventId}/${crypto.randomUUID()}-${input.filename}`;
const upload = await storage.signPutUrl(key, {
contentType: input.contentType,
expiresIn: 600,
});
await db.insert(images).values({ key, eventId: input.eventId /* … */ });
return { uploadUrl: upload.url, key, headers: upload.headers };
},
});storage exposes:
| Method | Returns | Use |
|---|---|---|
signPutUrl(key, { contentType?, expiresIn? }) | { url, method, headers, key, expiresAt } | Client uploads directly to R2 |
signGetUrl(key, { expiresIn?, downloadFilename? }) | string | Client downloads directly from R2 |
This is the supported replacement for streaming a raw binary body through an action — the JSON body parser and its ~1 MiB cap stay on for every other route.
Serving Files
Public Files
Files in buckets with readScope: "public" are stored with a public/ prefix:
https://files.yourdomain.com/public/org_abc/avatars/logo.pngNo authentication required.
Private Files
Private files require a valid Better Auth session cookie:
https://files.yourdomain.com/org_abc/documents/report.pdfThe files worker:
- Validates the session token
- Checks the user's organization matches
- Verifies role permissions (if
readRolesconfigured) - Serves the file or returns 403
Generated Files
When file storage is configured, the compiler generates:
| File | Purpose |
|---|---|
src/storage/routes.ts | Storage API routes (upload, presign, confirm, download) |
src/storage/presign.ts | R2 presigned-URL signer (createPresigner, backs ctx.storage) |
src/files/schema.ts | Files database schema (buckets, objects) |
cloudflare-workers/files/index.ts | Files worker for serving |
cloudflare-workers/files/wrangler.toml | Files worker config |
The compiler also adds aws4fetch to your
package.json (used by the presigner) and the R2 presign secrets to your
generated CloudflareBindings type.
Deployment
After compiling:
-
Create the R2 bucket:
wrangler r2 bucket create my-app-files -
Create the files database:
wrangler d1 create my-app-files -
Run migrations:
wrangler d1 migrations apply my-app-files --local wrangler d1 migrations apply my-app-files --remote -
Deploy the API:
wrangler deploy -
Deploy the files worker:
cd cloudflare-workers/files wrangler deploy -
Set the R2 presign secrets (only if you use presigned uploads — see Presigned uploads):
wrangler secret put R2_ACCOUNT_ID wrangler secret put R2_ACCESS_KEY_ID wrangler secret put R2_SECRET_ACCESS_KEY
Configuration Reference
| Option | Type | Default | Description |
|---|---|---|---|
managed | boolean | false | Opt into the FILES_DB subsystem + /storage/v1/* + files worker. Off = presign-only. |
bucketName | string | <project>-media | R2 bucket the signer targets (override per-call with signPutUrl(key, { bucket })) |
presign | object | - | Env-var name overrides: { accountIdEnv, accessKeyIdEnv, secretAccessKeyEnv, endpointEnv } |
binding | string | R2_BUCKET | R2 bucket binding name (managed mode) |
filesBinding | string | FILES_DB | Files metadata D1 binding (managed mode) |
maxFileSize | number | string | 10MB | Max upload size — bytes or "100mb" (managed mode) |
allowedTypes | string[] | Images only | Allowed MIME types (managed mode) |
publicDomain | string | - | Custom domain for files worker (managed mode) |
Security Model
- Upload security: Enforced by API worker (auth middleware, org check, role check)
- Serve security: Enforced by files worker (session validation, metadata RBAC)
- Soft deletes: Files are marked deleted in metadata but retained in R2
- Tenant isolation: All files are prefixed with organization ID