Quickback Docs

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.storage signer (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)

MethodEndpointDescription
POST/bucketCreate a bucket
GET/bucketList buckets
GET/bucket/:nameGet bucket info
DELETE/bucket/:nameDelete bucket (must be empty)
POST/object/:bucket/*pathUpload file (bytes through the Worker)
POST/presign/:bucket/*pathPresigned upload URL (bytes direct to R2)
POST/confirm/:bucket/*pathConfirm a presigned upload (reconcile size + etag)
GET/object/:bucket/*pathDownload file
HEAD/object/:bucket/*pathGet file metadata
DELETE/object/:bucket/*pathDelete file (soft delete)
GET/objectList objects
POST/urlGet 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)

MethodPathAuth 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

ScopeRead BehaviorWrite Behavior
publicAnyone can readN/A
organizationOrg members onlyOrg members only
userOwner onlyOwner 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"
}

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 endpoint

Signing 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:

MethodReturnsUse
signPutUrl(key, { contentType?, expiresIn? }){ url, method, headers, key, expiresAt }Client uploads directly to R2
signGetUrl(key, { expiresIn?, downloadFilename? })stringClient 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.png

No authentication required.

Private Files

Private files require a valid Better Auth session cookie:

https://files.yourdomain.com/org_abc/documents/report.pdf

The files worker:

  1. Validates the session token
  2. Checks the user's organization matches
  3. Verifies role permissions (if readRoles configured)
  4. Serves the file or returns 403

Generated Files

When file storage is configured, the compiler generates:

FilePurpose
src/storage/routes.tsStorage API routes (upload, presign, confirm, download)
src/storage/presign.tsR2 presigned-URL signer (createPresigner, backs ctx.storage)
src/files/schema.tsFiles database schema (buckets, objects)
cloudflare-workers/files/index.tsFiles worker for serving
cloudflare-workers/files/wrangler.tomlFiles 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:

  1. Create the R2 bucket:

    wrangler r2 bucket create my-app-files
  2. Create the files database:

    wrangler d1 create my-app-files
  3. Run migrations:

    wrangler d1 migrations apply my-app-files --local
    wrangler d1 migrations apply my-app-files --remote
  4. Deploy the API:

    wrangler deploy
  5. Deploy the files worker:

    cd cloudflare-workers/files
    wrangler deploy
  6. 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

OptionTypeDefaultDescription
managedbooleanfalseOpt into the FILES_DB subsystem + /storage/v1/* + files worker. Off = presign-only.
bucketNamestring<project>-mediaR2 bucket the signer targets (override per-call with signPutUrl(key, { bucket }))
presignobject-Env-var name overrides: { accountIdEnv, accessKeyIdEnv, secretAccessKeyEnv, endpointEnv }
bindingstringR2_BUCKETR2 bucket binding name (managed mode)
filesBindingstringFILES_DBFiles metadata D1 binding (managed mode)
maxFileSizenumber | string10MBMax upload size — bytes or "100mb" (managed mode)
allowedTypesstring[]Images onlyAllowed MIME types (managed mode)
publicDomainstring-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

On this page