Available in Goribu 0.9.0. Managed R2 includes bucket configuration, storage APIs, signed downloads, and browser uploads.
Configure buckets
Declare a default bucket and, optionally, a second private bucket. Goribu creates or reuses them during deployment and supplies R2 and R2_PRIVATE bindings.
import { defineConfig } from "goribu/server";
export default defineConfig({
name: "my-app",
compatibilityDate: "2026-09-01",
r2: {
name: "my-app-media",
domain: "media.example.com",
private: { name: "my-app-files" },
// jurisdiction: "eu", // Optional; applies to both buckets.
},
});
- Public hostname:
domainmakes the default bucket public through that hostname. Use a separate media hostname in an active Cloudflare zone in your account. - Hostname format: Supply only the hostname, without a scheme, port, or path.
- Domain activation: Goribu connects the media hostname during deployment. DNS and certificate activation may take time.
- Single private bucket: For private storage only, omit
domainand access the default bucket throughreq.r2. - Second bucket: The optional
privatebucket requires a public default bucket. - Bucket names: Names must be distinct, use 3–63 lowercase letters, digits, or hyphens, and start and end with a letter or digit.
- Jurisdiction: Omit
jurisdictionfor default storage or use"eu"for both buckets.
A single private bucket needs only a name:
import { defineConfig } from "goribu/server";
export default defineConfig({
name: "my-app",
compatibilityDate: "2026-09-01",
r2: { name: "my-app-files" },
});
- Configuration ownership: Configure R2 only through
goribu.config.js. Handwritten Wrangler R2 declarations are rejected. - Bucket limit: Goribu supports at most two managed buckets.
Upload from a form
Declare uploads on the receiving route, then use FileUpload inside Form. Protect the route with middleware that checks the user's authorization; it runs before upload starts and again when the form is submitted.
import type { Handler, R2Upload, UploadPolicy } from "goribu/server";
import { Form, FileUpload } from "goribu";
export const uploads = {
avatar: { accept: ["image/*"], maxBytes: 5_000_000 },
} satisfies Record<string, UploadPolicy>;
export const POST: Handler = async (req, res) => {
// The exported policy makes avatar a verified upload, not a raw File.
const { avatar } = await req.body() as { avatar?: R2Upload };
if (!avatar) return res.invalid({ avatar: "Upload an image." });
// Save avatar.key with the user's record here.
return res.json({ url: avatar.url });
};
export default function Profile() {
return (
<Form<{ url?: string }> method="post">
{({ pending, uploading, errors, data }) => <>
<FileUpload name="avatar" />
{errors?.avatar && <p>{errors.avatar}</p>}
{errors?.root && <p>{errors.root}</p>}
{uploading && <p>Your file is uploading.</p>}
<button disabled={pending}>
{pending ? "Saving…" : "Save"}
</button>
{data?.url && <img src={data.url} alt="Saved avatar" />}
</>}
</Form>
);
}
import { Form, FileUpload } from "goribu";
export const uploads = {
avatar: { accept: ["image/*"], maxBytes: 5_000_000 },
};
export const POST = async (req, res) => {
// The exported policy makes avatar a verified upload, not a raw File.
const { avatar } = await req.body();
if (!avatar) return res.invalid({ avatar: "Upload an image." });
// Save avatar.key with the user's record here.
return res.json({ url: avatar.url });
};
export default function Profile() {
return (
<Form method="post">
{({ pending, uploading, errors, data }) => <>
<FileUpload name="avatar" />
{errors?.avatar && <p>{errors.avatar}</p>}
{errors?.root && <p>{errors.root}</p>}
{uploading && <p>Your file is uploading.</p>}
<button disabled={pending}>
{pending ? "Saving…" : "Save"}
</button>
{data?.url && <img src={data.url} alt="Saved avatar" />}
</>}
</Form>
);
}
- Immediate transfer: Picking a file starts the transfer immediately.
- Transfer path: By default, bytes stream through the Worker into R2.
direct: truesends them straight to R2 in production. - Form submission: Save waits for unfinished transfers, then submits signed references alongside the other form fields. File bytes are not sent again.
- Verified references:
await req.body()verifies each reference against the stored object and returns{ key, name, type, size, url? }. - Public URLs: Verified files in the public default bucket include
url. - Missing uploads: Invalid, expired, or failed uploads leave the field absent. Return
res.invalid()when the file is required. - Application records: Save the verified object key with your application record.
- Transfer state:
uploadingreports active transfers.pendingreports submission, including its wait for uploads. - Form encoding: Managed pickers have no input name, so the form stays URL-encoded unless it also contains an ordinary named file input.
- JavaScript requirement: Managed upload controls require JavaScript. See Forms for submission and reset behavior.
Policies and verification
import type { UploadPolicy } from "goribu/server";
export const uploads = {
avatar: {
accept: ["image/jpeg", "image/png"],
maxBytes: 5_000_000,
key: ({ ext }) => "avatars/" + crypto.randomUUID() + ext,
},
document: {
accept: ["application/pdf"],
maxBytes: 20_000_000,
private: true,
},
} satisfies Record<string, UploadPolicy>;
export const uploads = {
avatar: {
accept: ["image/jpeg", "image/png"],
maxBytes: 5_000_000,
key: ({ ext }) => "avatars/" + crypto.randomUUID() + ext,
},
document: {
accept: ["application/pdf"],
maxBytes: 20_000_000,
private: true,
},
};
- Required policy: Each upload field requires
acceptMIME types andmaxBytes, a positive integer. - MIME wildcards: Subtype wildcards such as
image/*are allowed;*/*is not. - Size limits: Worker uploads allow up to 100,000,000 bytes. Direct policies allow up to 5,000,000,000 bytes.
- Private field selection:
private: truestores the field in the second bucket,r2.private, and requires that bucket. - Single-bucket privacy:
private: truedoes not make a field private on its own. With one bucket and nodomain, every upload is already private; leaveprivateout. - Custom object keys: The optional synchronous
keyfunction receives{ req, file, ext }after middleware. - Default object keys: Without a key function, Goribu uses
uploads/<uuid><ext>. - No overwrites: Always choose a fresh key. Uploads refuse to overwrite existing objects; a fixed avatar key makes the next upload fail.
- Replacing a file: Save the verified new key first, then explicitly delete the old object.
- Receiving handler: A route exporting upload policies must also export
POST,PUT, orPATCH. - Server boundary: Upload policies and their key function remain server-only.
- Worker rendering: An upload-policy route runs on the Worker. Forcing
prerender = truefails the build. - Static upload pages: A static page can post to a separate action route containing the policies and handler. Its picker has no policy-derived type filter or instant size check; the action still enforces both.
- Expiry: Transfer targets last 60 seconds. Upload references last one hour.
- Reference scope: References bind the route pattern, field, bucket, and upload identity. They do not bind a user ID or a particular dynamic parameter.
- Application authorization: Authorize access in your app, keep references and signed URLs private, and handle repeated submissions deliberately.
- Content verification: Browser MIME types do not prove a file's contents. Keep files needing moderation private.
- Public caching: Public uploads receive
Cache-Control: public, max-age=31536000, immutable. - Media headers: Add an
X-Content-Type-Options: nosniffresponse header rule on the media domain.
Custom upload UI
<FileUpload name="avatar">
{({ file, previewUrl, status, errorMessage, showPicker, dropProps }) => (
<div {...dropProps}>
{previewUrl && <img src={previewUrl} alt="Selected image" />}
<button type="button" onClick={showPicker}>
{file ? file.name : "Choose or drop an image"}
</button>
{status === "uploading" && <p>Uploading…</p>}
{errorMessage && <p>{errorMessage}</p>}
</div>
)}
</FileUpload>
<FileUpload name="avatar">
{({ file, previewUrl, status, errorMessage, showPicker, dropProps }) => (
<div {...dropProps}>
{previewUrl && <img src={previewUrl} alt="Selected image" />}
<button type="button" onClick={showPicker}>
{file ? file.name : "Choose or drop an image"}
</button>
{status === "uploading" && <p>Uploading…</p>}
{errorMessage && <p>{errorMessage}</p>}
</div>
)}
</FileUpload>
- Picker props:
FileUploadtakesnameand an optional render child. - File count: Each upload field holds one file. Use distinct names for multiple fields.
- Render props: The render child receives
file,previewUrl,status,errorMessage,showPicker, anddropProps. - Status values: Upload status is
idle,uploading,done, orerror. - Opening the picker: Call
showPicker()from a user gesture. - New selections: Picking a new file cancels the previous upload attempt.
- Reset and unmount: Reset and unmount clear the reference, abort pending work, and revoke the image preview URL.
- Stored objects: Reset does not delete a file already stored in R2.
- Abandoned uploads: Goribu does not automatically clean up abandoned uploads. Configure an R2 lifecycle rule for an appropriate upload prefix if needed.
Upload without a form
Use upload() in a custom drop, paste or file-selection handler. Its action must be a same-origin route declaring the named policy. Submit the returned ref to that action to obtain the verified file.
import { upload, UploadError } from "goribu";
async function saveImage(file: File, signal?: AbortSignal) {
try {
const ref = await upload(file, {
action: "/profile/save",
name: "avatar",
signal,
});
return await fetch("/profile/save", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ avatar: ref }),
signal,
});
} catch (error) {
if (signal?.aborted) return;
if (error instanceof UploadError) {
// Display error.message in your UI.
}
throw error;
}
}
import { upload, UploadError } from "goribu";
async function saveImage(file, signal) {
try {
const ref = await upload(file, {
action: "/profile/save",
name: "avatar",
signal,
});
return await fetch("/profile/save", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ avatar: ref }),
signal,
});
} catch (error) {
if (signal?.aborted) return;
if (error instanceof UploadError) {
// Display error.message in your UI.
}
throw error;
}
}
- Displayable errors:
UploadError.messageis ready to display to the user. - Error codes:
UploadError.codeistype,size,forbidden,network,expired, orserver. - Cancellation: Cancellation also reports
network. Check the signal to suppress cancellation UI. - Retry: There is no automatic retry. Call
upload()again for a fresh target.
Read, write and download
- Request APIs: Handlers and middleware receive
req.r2and, when configured,req.r2.private. - Native methods: Both buckets expose
head,get,put,delete,list,createMultipartUpload, and synchronousresumeMultipartUpload. Options and results follow the Workers R2 API. - Content type on writes: A
FileorBlobsupplies its content type on put when you have not set one explicitly.
// In an authorized handler, after checking access to the record:
await req.r2.put("exports/latest.csv", csvBlob);
const publicUrl = req.r2.url("exports/latest.csv");
const downloadUrl = await req.r2.private.signedUrl(document.key, {
expiresIn: 900,
download: "report.pdf",
});
// In an authorized handler, after checking access to the record:
await req.r2.put("exports/latest.csv", csvBlob);
const publicUrl = req.r2.url("exports/latest.csv");
const downloadUrl = await req.r2.private.signedUrl(document.key, {
expiresIn: 900,
download: "report.pdf",
});
- Public URL helper:
url(key)requires the public bucket and encodes each path segment. Development uses a local public-file endpoint. - Signed URL helper:
signedUrl(key, options?)works on either bucket. - Signed lifetime: Signed links default to 900 seconds and accept 1–604800 seconds.
- Download disposition: Omit
downloadfor inline delivery, or supply an attachment filename.
Available in Goribu 0.9.1. For images or other files whose bytes never change under the same key, pass immutable: true to reuse links and allow private browser caching until they expire:
// Authorize this user first. Replacing the image must create a new object key.
const imageUrl = await req.r2.private.signedUrl(image.fileKey, {
expiresIn: 604800,
immutable: true,
});
// Authorize this user first. Replacing the image must create a new object key.
const imageUrl = await req.r2.private.signedUrl(image.fileKey, {
expiresIn: 604800,
immutable: true,
});
- Immutable default:
immutabledefaults tofalse. - Link reuse: With
immutable: true, the same key, bucket, and options produce identical URLs within windows of half the requested lifetime. Each newly issued link has at least half its lifetime left. - Rotation example: A seven-day immutable lifetime rotates URLs about every 3.5 days.
- Private browser cache: Immutable responses allow private browser caching bounded by link expiry, not shared CDN caching.
- Revocation limit: Cached bytes cannot be revoked by logout, permission changes, deletion, or secret rotation. Keep the default when that is unsuitable.
To serve a private file at a stable app URL instead of a signed link, check access in a route and return its body with res.stream(), available since 0.9.2.
- Worker downloads: Signed downloads use the Worker when R2 API keys are absent. Development always uses the Worker.
- Presigned downloads: With both keys in production, signed downloads use R2's S3 endpoint, including its EU endpoint for EU buckets.
- Partial credentials: A partial R2 key pair fails explicitly.
- Worker download features: Worker downloads support HEAD, single byte ranges, and conditional requests.
- Download sandbox: Worker downloads use
Content-Security-Policy: sandboxandX-Content-Type-Options: nosniff, so uploaded HTML or SVG cannot run scripts as the app. - Browser verification: Inline PDF viewing and attachment downloads were verified in Chrome 154 on macOS.
- Link authorization: Issue signed links at request time after checking access. Links cannot be baked into prerendered HTML.
- Bearer access: The Worker download endpoint under
/_goribu/does not run your middleware. A signed link is a bearer token until it expires. - Secret rotation: Rotating
GORIBU_SECRETinvalidates Worker download links and upload references. Presigned downloads use separate R2 credentials.
Direct uploads
import type { UploadPolicy } from "goribu/server";
export const uploads = {
video: {
accept: ["video/mp4"],
maxBytes: 500_000_000,
direct: true,
private: true,
},
} satisfies Record<string, UploadPolicy>;
export const uploads = {
video: {
accept: ["video/mp4"],
maxBytes: 500_000_000,
direct: true,
private: true,
},
};
- Receiving route: Add a direct-upload policy to a route with a receiving handler.
- Direct transfer: Production sends bytes straight from the browser to R2. Development still uses local Worker storage.
- Object verification: Both transfer paths verify the object when its reference is submitted and use conditional writes to prevent overwrites.
- Required secrets: Direct uploads need an R2 API token stored as Worker secrets
R2_ACCESS_KEY_IDandR2_SECRET_ACCESS_KEY. - Missing-key plan: Since 0.9.2, keys need not be set up before deployment.
pnpm checklists missing keys. - Interactive setup: When either key is missing,
pnpm run deployfrom a terminal creates the upload buckets and prints a link to the account's R2 API tokens page, with the buckets to scope a new Object Read & Write token to. - Hidden prompts: Deployment asks for each missing key through Wrangler's hidden prompt before uploading the Worker.
- Name-only inspection: Goribu checks secret names, never values. Keys declared only in
.env.productionstill lead to prompts. - Cancelled setup: Cancelling a key prompt stops deploy before Worker upload. Run deploy again to be asked for what is still missing.
- Non-interactive setup: A deploy without a terminal, such as CI, stops before any change when keys need setup and asks you to deploy once from a terminal.
- Later deployments: Later deploys reuse stored R2 secrets.
Deploy manages a goribu-uploads CORS rule only on buckets with direct fields, preserving unrelated rules. It allows PUT with content-type, cache-control, if-none-match and x-amz-meta-goribu-upload.
- Allowed origins: Direct-upload origins come from the app's workers.dev address unless
workersDev: false, Worker custom domains, and exact-host routes. - Excluded origins: Hostname wildcards are skipped with a warning. Preview URLs are excluded.
- Route schemes: Routes without a scheme allow both HTTP and HTTPS origins.
- Origin updates: There is no separate origins setting. Redeploy after adding or changing app domains or routes.
- Account inspection: Direct-upload inspection requires an account workers.dev subdomain unless
workersDev: false. - Inspection permissions: Inspection needs Workers Scripts Read, Zone Read, and Workers Routes Read for the zones it scans. A narrowly scoped token may need additional permissions.
- Key validation limit: A credentials-name check does not prove the keys work or can access the buckets.
Development and deployment
- Local storage: Development needs no R2 keys and keeps local bucket data across restarts.
- Local signing: Goribu creates
.goribu/dev-secretonce. Root env files still load normally; an app-providedGORIBU_SECRETwins. - Production exclusion: The generated dev secret is excluded from production output.
pnpm build
pnpm check
pnpm run deploy
- Build validation: Build validates upload policies without creating cloud resources.
- Read-only check: Check labels upload metadata with the last build time. Without a build, it skips only CORS and key checks.
- Deployment setup: Deploy builds fresh, creates missing buckets, connects the public domain, configures direct-upload CORS, and generates
GORIBU_SECRETif missing. - Draft Worker: A first deploy may create a draft Worker. A later failure leaves it for retry.
- Provisioning permissions: Provisioning needs the relevant R2 read/write permissions and Workers Scripts access. Domain attachment also needs Zone Read.
- Resource preservation: Goribu never deletes buckets or objects during provisioning or deployment.
- Renamed storage: Changing bucket names or jurisdiction does not migrate files.
- Public-domain removal: Removing the domain setting does not disconnect an existing public domain.
- Private-bucket conflict: Public access on a configured private bucket blocks deployment and prints a repair command to run through
pnpm exec goribu wrangler. - Verification cleanup: Failed upload verification can delete only an object with the matching signed upload identity whose size or type is wrong.
- Explicit deletion: Your app can delete files through the request API.
- Storage costs: Review costs and lifecycle rules for files left behind by abandoned forms.