R2 presigned URLs
A presigned URL lets a browser upload or download one object in an
R2 bucket directly, without credentials. Your
Worker mints the URL, and the same code works deployed and under
alchemy dev.
Effect-native Workers
Section titled “Effect-native Workers”Bind the bucket with PresignPutObject / PresignGetObject and
provide their *Token layers:
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";import { Uploads } from "./uploads.ts";
export default Cloudflare.Worker( "Api", { main: import.meta.url }, Effect.gen(function* () { const presignPut = yield* Cloudflare.R2.PresignPutObject(Uploads); const presignGet = yield* Cloudflare.R2.PresignGetObject(Uploads);
return { fetch: Effect.gen(function* () { const request = yield* HttpServerRequest; const url = new URL(request.url, "http://worker"); const key = url.searchParams.get("key")!;
if (url.pathname === "/upload-url") { return yield* HttpServerResponse.json({ url: yield* presignPut({ key, contentType: "image/png" }), }); } return yield* HttpServerResponse.json({ url: yield* presignGet({ key, expiresIn: 60 }), }); }).pipe(Effect.orDie), }; }).pipe( Effect.provide( Layer.mergeAll( Cloudflare.R2.PresignPutObjectToken, Cloudflare.R2.PresignGetObjectToken, ), ), ),);Deployed, Alchemy mints a scoped API token for the Worker, and the
URLs point at https://{accountId}.r2.cloudflarestorage.com. Under
alchemy dev they point at the Worker’s local R2 endpoint,
http://localhost:{port}/cdn-cgi/local/r2/s3. Signing happens inside
the Worker, so minting a URL makes no network request.
The browser uploads with a plain fetch, sending the Content-Type
that was signed into the URL:
const { url } = await (await fetch(`/upload-url?key=${key}`)).json();await fetch(url, { method: "PUT", body: file, headers: { "content-type": "image/png" },});Both functions accept a few options:
yield* presignPut({ key: "avatars/me.png", expiresIn: 300, // seconds, 900 by default, 7 days at most contentType: "image/png", // the uploader must send this header});
yield* presignGet({ key: "reports/2026-09.pdf", expiresIn: 60, contentType: "application/pdf", // served with this Content-Type contentDisposition: 'attachment; filename="report.pdf"', // force a download});Async Workers
Section titled “Async Workers”Async Workers sign URLs themselves with an S3 client, which needs S3
credentials. S3Credentials is the easy way to get them: it creates an
API token scoped to the permissions you ask for and hands the Worker
its S3 keys. Declare it on env:
import * as Cloudflare from "alchemy/Cloudflare";import { Uploads } from "./src/uploads.ts";
export const Api = Cloudflare.Worker("Api", { main: "./src/worker.ts", env: { UPLOADS_S3: Cloudflare.R2.S3Credentials(Uploads, { access: "write" }), },});
export type ApiEnv = Cloudflare.InferEnv<typeof Api>;Then sign with any S3 client. This one uses
aws4fetch:
import { AwsClient } from "aws4fetch";import type * as Cloudflare from "alchemy/Cloudflare";import type { ApiEnv } from "../alchemy.run.ts";
export default { async fetch(request: Request, env: ApiEnv) { const key = new URL(request.url).searchParams.get("key")!; const s3: Cloudflare.R2.S3CredentialsValue = JSON.parse(env.UPLOADS_S3); const client = new AwsClient({ ...s3, service: "s3" });
const path = key.split("/").map(encodeURIComponent).join("/"); const url = new URL( `${s3.endpoint}/${encodeURIComponent(s3.bucketName)}/${path}`, ); url.searchParams.set("X-Amz-Expires", "900"); const signed = await client.sign(url.toString(), { method: "PUT", headers: { "content-type": "image/png" }, aws: { signQuery: true, allHeaders: true }, });
return Response.json({ url: signed.url }); },};UPLOADS_S3 is a JSON string holding endpoint, bucketName,
region, accessKeyId and secretAccessKey. Deployed, it’s a
secret. Under alchemy dev, no token is created and it points at the
local endpoint with local credentials.
access is required and sets the token’s permissions:
Cloudflare.R2.S3Credentials(Uploads, { access: "read" }); // downloads onlyCloudflare.R2.S3Credentials(Uploads, { access: "write" }); // uploads onlyCloudflare.R2.S3Credentials(Uploads, { access: "read-write" }); // bothIn an Effect-native Worker, yield* Cloudflare.R2.S3Credentials(Uploads, { access })
gives you the same values for your own S3 client.
Browser uploads to a deployed bucket
Section titled “Browser uploads to a deployed bucket”A browser calling the URL from another origin needs a CORS rule on the bucket. The local endpoint allows any origin and ignores these rules, so a missing origin or header only shows up once deployed.
import * as Cloudflare from "alchemy/Cloudflare";
export const Uploads = Cloudflare.R2.Bucket("Uploads", { cors: [ { allowedMethods: ["GET", "PUT"], allowedOrigins: ["https://app.example.com"], allowedHeaders: ["content-type"], }, ],});Local development
Section titled “Local development”The local endpoint rejects expired and tampered URLs like R2 does,
and objects uploaded through it are visible to the Worker’s own R2
bindings. S3 clients such as the AWS SDK work against it too. A
bucket piped through Alchemy.remote() uses real R2 in dev.
Mint presigned URLs on demand and store object keys, not URLs. URLs
expire, and a local URL points at the Worker’s dev port, which can
change between alchemy dev runs.
R2 doesn’t support presigned POST (HTML form) uploads, locally or
deployed. Use presigned PUT URLs.