2026-09-24

Direct-to-R2 uploads with presigned PUTs: presign, PUT, confirm

A work-order system needs to attach large design files to jobs: PDFs, images, .ai, .cdr, .eps, .svg, videos. Proxying those through a Worker means paying CPU and memory for bytes that the application doesn’t care about. The pattern I used instead has been standard on S3 for years and works unchanged on R2, because R2 speaks the S3 API.

Three steps

1. Presign    POST /api/orders/:id/files/presign  { filename, contentType, target }
              -> { uploadUrl, fileId, key }         (URL expires in 15 minutes)

2. PUT        browser  PUT  uploadUrl  <file bytes>
              (no auth header: the credentials are inside the signed URL)

3. Confirm    POST /api/orders/:id/files/confirm  { fileId, key, filename, contentType, target }
              -> the saved file record

The server never sees the file. It authorises the request, signs a short-lived URL for one key, and later records what was stored.

Why the confirm step exists

The presign call only permits an upload, and the browser could abort halfway, or never send the bytes at all. The database therefore records nothing at presign time. Only the confirm call writes the row (into an attachments or design-files table, depending on target), so the database describes files the client says it finished uploading.

My reading, not stated in the docs: this leaves a small class of orphans, objects that were uploaded but never confirmed. That is an acceptable trade for keeping the database honest, provided a lifecycle rule on the bucket eventually removes unreferenced objects.

Permission checks belong to the presign

Permissions are enforced in the service layer: the order must exist, and the user must hold the relevant permission. The check is module-based (does this role’s permission set include the module?) rather than comparing role strings, which is the reason a role rename doesn’t silently break upload access.

CORS is the part everyone forgets

A browser PUT to a different origin is a cross-origin request with a preflight, so the bucket must allow PUT from the frontend’s origin. Doing that by hand in a dashboard means every new environment breaks until someone remembers. So the server ensures the rule at startup, using the Cloudflare API:

PUT /accounts/:accountId/r2/buckets/:bucketName/cors    (allow PUT for the configured origins)

It logs that the rule was applied, or skips with a note if the credentials aren’t configured, which means local development still boots. Making environment setup part of application startup, and idempotent, removes an entire category of “works on my machine”.

The storage seam

Storage sits behind a tiny ObjectStorage interface with a presignPutUrl method. The S3-compatible R2 implementation is one class, so the controller and service code never mention a vendor.

Gotchas worth writing down

Takeaways

← All posts