File uploads and storage for a static HTML site
A static page cannot hold storage keys, so a small Worker signs a 5-minute upload URL and the browser PUTs the file straight into Cloudflare R2: 10 GB-month free, no egress fees, $0.015 per GB-month after that.
// Worker: returns a presigned PUT URL for one upload
import { AwsClient } from "aws4fetch";
const ALLOWED_TYPES = new Set(["image/png", "image/jpeg", "application/pdf"]);
export default {
async fetch(request, env) {
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
const { type } = await request.json();
if (!ALLOWED_TYPES.has(type)) {
return new Response("File type not allowed", { status: 415 });
}
const key = `uploads/${crypto.randomUUID()}`;
const url = new URL(`https://${env.ACCOUNT_ID}.r2.cloudflarestorage.com/${env.BUCKET}/${key}`);
url.searchParams.set("X-Amz-Expires", "300");
const r2 = new AwsClient({
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
});
const signed = await r2.sign(new Request(url, { method: "PUT" }), { aws: { signQuery: true } });
return Response.json({ uploadUrl: signed.url, key });
},
};
// Browser: ask for a URL, then send the file straight to R2
const file = document.querySelector("input[type=file]").files[0];
const res = await fetch("/api/upload-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: file.type }),
});
const { uploadUrl, key } = await res.json();
await fetch(uploadUrl, { method: "PUT", body: file });
The R2 keys stay in Worker secrets, the browser only ever sees a URL that works for one object for 300 seconds, and the file never passes through the Worker, so a 2 GB video costs the Worker the same as a 20 KB avatar. Run the Worker on the same hostname as the page (a route such as /api/upload-url) and the first request needs no CORS setup. The signing code follows Cloudflare's presigned URL docs and uses the small aws4fetch library.
Storage options for a static site compared
| Option | Free allowance | Egress | Paid price | How the browser uploads |
|---|---|---|---|---|
| Cloudflare R2 | 10 GB-month, 1M Class A and 10M Class B operations a month | $0 | $0.015 per GB-month | Presigned PUT URL from a Worker, valid 1 second to 7 days |
| Supabase Storage | 1 GB, 50 MB max per file | Part of the 5 GB free project egress | Pro from $25 a month | supabase-js upload, gated by row level security policies |
| Files shipped with the site | ship.page: 25 MB ZIP; Cloudflare Pages: 20,000 files at 25 MiB each | Served by the host | ship.page Pro $4 a month for 100 MB ZIPs | No upload: the files are part of the deploy |
Backblaze B2 is the usual third option for large archives at $6.95 per TB per month (B2 pricing), but for files users upload from a page, R2's free egress makes it the default pick.
Cloudflare R2 rules that trip people up
R2 presigned URLs support GET, HEAD, PUT and DELETE only. A classic HTML form upload (POST with multipart form data) is not supported, which is why the example uses fetch with PUT. Expiry can be anything from 1 second to 7 days. Presigned URLs only work on the S3 API hostname, <ACCOUNT_ID>.r2.cloudflarestorage.com, and cannot be used with a custom domain on the bucket.
Because the PUT goes from the page's origin to the R2 hostname, the bucket needs a CORS rule that allows your origin, the PUT method and the Content-Type header (R2 CORS docs). Without it the browser blocks the upload before it starts, and the error in the console only says the CORS preflight failed.
Each upload is one Class A operation. The free 1 million a month is plenty for a real site, and it is also the number an attacker burns first if the signing endpoint is open to anyone.
Supabase Storage when your data already lives in Supabase
Supabase Storage is the simpler path if the site already uses Supabase for its database. The free plan includes 1 GB of file storage, and a single file can be at most 50 MB on free projects. Uploads go through supabase-js with the publishable key, and storage policies decide who can write where. Free projects are paused after 1 week of inactivity, so a forgotten demo can stop accepting uploads (Supabase pricing).
Files that ship with the site instead
Images, PDFs and downloads that you control do not need a storage service at all. Put them in the deploy: ship.page takes a ZIP of up to 25 MB on the free plan (100 MB unpacked, 100 files per request), and Cloudflare Pages accepts 20,000 files at up to 25 MiB each (Pages limits). Storage services are for files your visitors send you.
Locking down the upload signer
The signing Worker is the only server code here, so it carries the security work:
- Require a session or a signed token before handing out a URL, or at least rate limit by IP with a Cloudflare rate limiting rule.
- Generate the object key on the server. Letting the browser pick the path invites overwrites of other users' files.
- Keep the expiry short. 300 seconds is enough for one upload on a slow connection.
- The signature does not pin the file type or size. Check the object with a HEAD request after upload and delete anything over your size limit or with the wrong type.
- Store the R2 keys as Worker secrets with an R2 token scoped to one bucket, never in the page or in a public repo.
Frequently asked questions about storage for static sites
Can I upload files to Cloudflare R2 directly from the browser?
Yes, with a presigned URL. A Worker signs a PUT URL with your R2 keys, valid for 1 second up to 7 days, and the browser sends the file to it with fetch. Add a CORS rule on the bucket for your page's origin first, or the browser blocks the request.
Do R2 presigned URLs work with a custom domain?
No. Cloudflare's docs say presigned URLs work only on the S3 API hostname, <ACCOUNT_ID>.r2.cloudflarestorage.com, not on a custom domain attached to the bucket. You can still serve the stored files publicly through a custom domain; only the signed upload has to use the S3 hostname.
What is the largest file Supabase Storage accepts on the free plan?
50 MB per file. Supabase's file limits page says the limit cannot exceed 50 MB on free projects, out of 1 GB of total storage. For bigger uploads on a free budget, use Cloudflare R2 with presigned URLs instead.
Official pricing and documentation sources
- Cloudflare R2 pricing
- R2 presigned URLs
- R2 bucket CORS
- aws4fetch on GitHub
- Supabase Storage file limits
- Supabase pricing
- Backblaze B2 pricing
- Cloudflare Pages limits
- ship.page limits
Related static site guides