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. Use this when you already have a media table and only need to sign large or confidential transfers — see Presigned uploads.
  • Managed (managed: true) — adds the turnkey subsystem on top: a metadata store (buckets + objects), the /storage/v1/* upload/download/ manage endpoints, and a files worker for serving. Use this for listing photos, avatars, and other files that should be public-if-you-have-the-link.

Managed mode works on every database provider. Where the metadata lives depends on which one you chose:

DatabaseMetadata storeMigrations
cloudflare-d1A second D1 database, bound as FILES_DBIts own drizzle.files.config.ts and migration folder
neon, planetscale-postgresA files schema in the same Postgres database as auth and feature data — no extra database, no bindingThe project's single journal

Nothing above the store moves: ids, object keys, JSON-encoded role and MIME lists, ISO-8601 timestamps and soft deletion are identical, so every /storage/v1/* request and response body is the same on both.

// 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. Existing projects set bucketName to a bucket they already have. A browser deploy creates the bucket; it cannot mint the R2 API-token secrets that presigning needs.

The sections below document the managed subsystem. For signed PUT/GET only, jump to Presigned uploads.

Read models

Most files should be semi-private: anyone who has the exact URL can GET them; anyone who does not, cannot. That is a public bucket (readScope: "public") plus a UUID in the object path. Signed URLs are the smaller case — confidential files that must stay unreadable even if the URL leaks.

Read modelWho can GETUse for
Public bucket (readScope: "public")Anyone with the exact URLListing photos, avatars, public media, shareable attachments
Session-gated (organization / user)Signed-in caller who passes RBACOrg-internal docs the CMS should still gate
Signed GET (storage.signGetUrl)Holder of a short-lived URLIDs, payroll, medical, anything that must expire

Public is not a directory listing. The files worker serves /public/... with no auth. Put a UUID in the key so /public/org/listings/hero.jpg is not guessable:

public/{orgId}/listings/{uuid}-hero.jpg

Do not enable Cloudflare's r2.dev public-bucket domain for this. That publishes every object in the bucket. Quickback's readScope: "public" keeps the R2 bucket private and only the public/ prefix anonymous — so a later private object is not world-readable.

A Cloudflare-public bucket (PUT …/domains/managed { enabled: true }) is OK only when that bucket will only ever hold public objects. PUTs stay authenticated either way. Presign still needs R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY; through-Worker uploads (POST /object) do not.

If the file already lives on the internet, store a URL instead of R2 — photoUrl: q.url(). No bucket, no secrets, no files worker. See Using R2.

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: {
        managed: true,
        binding: 'R2_BUCKET',
        bucketName: 'my-app-files',
        filesBinding: 'FILES_DB', // cloudflare-d1 only; ignored on Postgres
        maxFileSize: 10 * 1024 * 1024, // 10MB
        allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
      },
    },
  },
};

managed: true is what turns on everything on this page — the /storage/v1/* routes, the files worker, and the metadata store. Without it R2 file storage is presign-only: the compiler signs requests against the bucket by name and emits no bucket binding into wrangler.toml, so binding and filesBinding are ignored and env.R2_BUCKET is undefined at runtime (the compile warns when you set them anyway).

To read the bucket directly from your own code in presign-only mode, declare it yourself under bindings.r2Buckets.

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 — upload with a presigned PUT. The bytes go straight to R2, so you skip the Worker's request-body cap. Serving those files can still be a public URL; presign is the upload path, not the default read model. See Presigned uploads.

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": "listings",
  "readScope": "public",
  "writeScope": "organization",
  "writeRoles": ["admin", "member"]
}

readScope: "public" is the default for listing photos and avatars. Switch to organization or user only when a signed-in session must gate the GET.

Scope Options

ScopeRead BehaviorWrite Behavior
publicAnyone with the exact URL (no auth)N/A
organizationOrg members onlyOrg members only
userOwner onlyOwner only

writeScope: "user" is enforced per object, not per organization. Any member of the org may upload to such a bucket, so the object key is the only thing separating one member's files from another's — and keys are built from a client-supplied path. Before any write, Quickback checks whether the target key already has an owner: writing to a key owned by another user returns 403 ACCESS_OWNERSHIP_REQUIRED.

This applies to presigned uploads too. A signed PUT URL is a write capability, so ownership is checked before the URL is issued, not when the bytes land.

The read policy covers metadata, not just bytes. readScope and readRoles are applied on every route that can reveal an object — downloads, GET /object listings, and POST /url lookups alike. Being a member of the owning organization is not sufficient on its own.

  • Listing (GET /object) returns only objects in buckets you may read. The filter is applied in the query, so limit and offset page over your readable set — you never receive a short page because rows were removed after the fact. Naming a bucket you cannot read returns 403 ACCESS_ROLE_REQUIRED; with no bucket filter, unreadable buckets are simply absent.
  • URL lookup (POST /url) returns 404 when the bucket policy denies you — the same response as an object that does not exist. This is deliberate: a 403 would confirm that the id or key you supplied resolves to a real object, letting a caller enumerate objects they cannot read.

For readScope: "user" buckets, both routes additionally require that you own the object.

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": "public"
}

Presigned uploads

Streaming bytes through the Worker (POST /object) is fine for listing photos and other small files. It needs no R2 API token — the bucket binding is enough.

Use a presigned PUT for large media (video, audio, big PDFs) or when the object itself is confidential. The Worker runs the access checks and hands back a short-lived URL the client uploads to directly. The bytes never touch your Worker. Presigned GET (storage.signGetUrl) is the matching read model for those confidential files — not for listing photos.

# 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-Length": "73400320", "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>"

The size and contentType from step 1 are bound into the signature as Content-Length and Content-Type — the client MUST replay the returned headers verbatim on the PUT, or R2 rejects it. That is what makes maxFileSize and allowedTypes real limits on the object that lands, not just on the request that asked for a URL.

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:

quickback/features/events/actions/signMediaUpload.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
import { images } from "../images";

export default defineAction({
  description: 'Issue a presigned R2 upload URL for a media file on an event.',
  path: '/event/:eventId/media/sign-upload',
  method: 'POST',
  input: z.object({ filename: z.string(), contentType: z.string(), size: z.number().int().nonnegative() }),
  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,
      contentLength: input.size,
      expiresIn: 600,
    });
    await db.insert(images).values({ key, eventId: input.eventId /* … */ });
    return { uploadUrl: upload.url, key, headers: upload.headers };
  },
});

storage exposes:

MethodReturnsUse
signPutUrl(key, { contentLength, contentType?, expiresIn? }){ url, method, headers, key, expiresAt }Client uploads directly to R2 — size (and type) are signed and checked against maxFileSize / allowedTypes
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 (semi-private) files — the usual case

Files in buckets with readScope: "public" are stored with a public/ prefix and served with no authentication. Anyone who has the exact URL can open the file. Put a UUID in the path so the URL is not guessable:

https://files.yourdomain.com/public/org_abc/listings/3f2a…-hero.jpg

Store that URL on the listing row. Send it to the public listing page. This is the right model for property photos, avatars, and shareable media.

No session is required, but the request is not unconditional: the files worker looks the key up in the metadata store and serves bytes only when a live (not soft-deleted) object exists in a bucket whose readScope is public. Anything else is a 404, not a 403 — an anonymous caller must not be able to probe for private keys. So a soft-deleted public object stops being served, and a key that merely starts with public/ proves nothing on its own.

Public responses still carry Cache-Control: public, max-age=31536000, immutable, so a CDN or browser that already has the bytes keeps them. Soft deletion is not a revocation mechanism for something already fetched.

Session-gated 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. Re-checks the caller's current membership in the object's organization
  3. Verifies role permissions (if readRoles configured)
  4. Serves the file or returns 403

Step 2 is a live lookup, not a read of the session row. A session outlives membership changes, so its stored active organization only counts while a current member row backs it. Remove a member and their org-scoped private-file access stops on the next request — including on the JWT fast path, where the signed orgId and role claims are re-derived from the database rather than trusted as minted.

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 metadata schema (buckets, objects) — sqliteTable on D1, a files Postgres schema otherwise
src/files/index.tscreateFilesDb — the D1 binding handle, or the Postgres service-lane handle
drizzle.files.config.tsSecond migration target — D1 only
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. Package-mode compiles (Start browser deploys) resolve it from the compiler image's /deps/node_modules, not from an npm install of that generated package.json.

Deployment

After compiling:

  1. Create the R2 bucket:

    wrangler r2 bucket create my-app-files
  2. Create the files databasecloudflare-d1 only. On Postgres the metadata lives in the files schema of the database you already have:

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

    # cloudflare-d1
    wrangler d1 migrations apply my-app-files --local
    wrangler d1 migrations apply my-app-files --remote
    
    # Postgres — the files schema is in the project's single journal
    npm run db:migrate
  4. Deploy the API:

    wrangler deploy
  5. Deploy the files worker:

    cd cloudflare-workers/files
    wrangler deploy

    quickback deploy ships the API Worker only and then prints this command — managed file storage is two deployed Workers, and having shipped one but not the other is a real state worth seeing.

    On Postgres the files worker reads the same database over the same transport as the API Worker: a HYPERDRIVE binding (already in its wrangler.toml), or a DATABASE_URL secret you must also set on this Worker:

    cd cloudflare-workers/files && wrangler secret put DATABASE_URL   # HTTP only
  6. Set the R2 presign secrets only if you use signed PUT/GET. Public-bucket uploads through POST /object do not need them. See Presigned uploads:

    wrangler secret put R2_ACCOUNT_ID
    wrangler secret put R2_ACCESS_KEY_ID
    wrangler secret put R2_SECRET_ACCESS_KEY

Named environments deploy two Workers per target

A project with named environments must name the files Worker for every target. It is never derived: the script name is the deployed identity, and Wrangler's routes key is inheritable, so a shared <project>-files default would let deploy --env dev replace production's file server and take its hostname.

environments: {
  dev:  { name: 'app-dev', filesWorker: { name: 'app-files-dev', domain: 'files-dev.example.com' }, /* … */ },
  prod: { name: 'app',     filesWorker: { name: 'app-files',     domain: 'files.example.com' },     /* … */ },
}

The compile fails with the config path to fix when a target omits it, or when two targets claim the same Worker name or hostname. Omitting filesWorker.domain emits routes = [] — the explicit workers.dev-only opt-out. The files Worker reuses that environment's R2 bucket and database bindings; it does not declare its own.

cd cloudflare-workers/files && npx wrangler deploy --env dev

Configuration Reference

OptionTypeDefaultDescription
managedbooleanfalseOpt into the metadata store + /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, cloudflare-d1 only — Postgres uses the files schema of the single database and emits no binding)
maxFileSizenumber | string10MBMax upload size — bytes or "100mb" (managed mode)
allowedTypesstring[]Images onlyAllowed MIME types (managed mode)
publicDomainstring-Custom domain for files worker (managed mode)

How the size limit is enforced

maxFileSize (and a bucket's fileSizeLimit) is enforced against bytes that actually arrive, not against what the client claims:

  • Through-Worker uploads (POST /object/...) are read through a byte counter that aborts the moment the limit is crossed, so nothing over-limit reaches R2. Content-Length is still checked first — it cheaply turns away honest oversized clients — but it is optional and client-supplied, so it is never the enforcement point.
  • Presigned uploads go straight from the client to R2, and the signature binds the content type, not the length. The size you declare at presign time is therefore advisory. POST /confirm/... HEADs the object, compares the real size against the bucket limit, and deletes an over-limit object rather than recording it — otherwise the declared-size check could be sidestepped by simply never calling confirm.

Both paths answer 413 with the actual and permitted byte counts.

Security Model

  • Upload security: Enforced by the API worker (auth, org, role). A public read scope never implies a public write.
  • Serve security: Public prefix = URL is the capability. Session-gated paths re-check membership on every GET. Signed GET expires.
  • Soft deletes: Files are marked deleted in metadata but retained in R2
  • Tenant isolation: All files are prefixed with organization ID

On this page