File storage
env.STORAGE is your app's object store for user uploads, generated files, and assets. It's an object-storage bucket with a put/get/list/delete API, and a built-in way to serve files publicly over HTTP.
Reading & writing
// api/upload.js
export default {
async fetch(request, env, ctx) {
const user = await env.AUTH.getUser(request);
if (!user) return new Response("Unauthorized", { status: 401 });
const body = await request.arrayBuffer();
const key = user.id + "/avatar.png";
await env.STORAGE.put(key, body, {
httpMetadata: { contentType: "image/png" },
});
return Response.json({ key });
},
};put(key, value, opts?)·get(key, opts?)·head(key)·delete(key | key[])list(opts?)— keys are returned relative to your app's space.createMultipartUpload/resumeMultipartUploadfor large files.
Serving files publicly
Mark an object public when you store it, and it's served at /files/<key> on your app's domain — no route code needed:
await env.STORAGE.put(key, body, {
httpMetadata: { contentType: "image/png" },
customMetadata: { public: "true" },
});
// now reachable at https://your-app.com/files/<key>Objects without public: "true" aren't served by the /files route — fetch those in your own handler with env.STORAGE.get after an auth check, or hand the browser a signed URL (below).
Public files are cached hard at the edge for speed, so if you overwrite the same key, call env.STORAGE.invalidate(key) afterwards to force viewers to get the new version:
await env.STORAGE.put("logo.png", newBytes, {
httpMetadata: { contentType: "image/png" },
customMetadata: { public: "true" },
});
await env.STORAGE.invalidate("logo.png"); // clear the cached old copyIf you write to a fresh key each time (e.g. a hash or timestamp suffix), you don't need this — a new key is never stale.
/files/<key> supports HTTP Range requests (partial reads). That means you can host large files that are read in pieces — video with seeking, or a PMTiles vector tileset read with MapLibre's pmtiles:// protocol — straight from storage, without streaming the whole file. See Spatial queries for the large-map pattern.Signed URLs for private files
To let a browser load a private object directly — without routing the bytes through your own handler on every request — generate a short-lived signed URL. It carries an expiring signature, so you can hand it to an authenticated user after your own access check and they can fetch the file straight from storage:
// in your route, after checking the user may see this file:
const url = await env.STORAGE.signedUrl("invoices/2026-07.pdf", 300); // valid 300s
return Response.json({ url });
// browser fetches url directly; the link stops working after 5 minutesThe signature covers the exact key and expiry, so it can't be altered or reused for another object. Responses are sent Cache-Control: private, no-store so the file is never held in a shared cache. Use this for per-user documents, receipts, or media; keep genuinely public assets on the /files route instead.
/files/<key>.Make a file searchable
Pass { index: true } to put and Cloudrizz extracts the file's text and indexes it for semantic search — PDFs, Word docs, and text/markdown/HTML/CSV. Deleting the file removes its index entries too.
await env.STORAGE.put("docs/handbook.pdf", pdfBytes, {
httpMetadata: { contentType: "application/pdf" },
index: true,
});Then query it with env.SEARCH — see Semantic search & RAG (a paid-plan feature).
Unattended uploads (a script, cron, or CI)
To publish files into storage from outside a chat session — a nightly job that rebuilds a dataset, a CI step, an external scheduler — expose an authenticated upload route in your app. It checks a secret you control and streams the request body straight to storage, so even large files (tens of MB) never buffer in memory:
// api/upload.js — PUT a file into storage, guarded by a secret
export async function PUT(request, env) {
if (request.headers.get("x-upload-key") !== env.UPLOAD_KEY) {
return new Response("Unauthorized", { status: 401 });
}
const key = new URL(request.url).searchParams.get("key"); // e.g. "data/parcels.pmtiles"
await env.STORAGE.put(key, request.body, {
httpMetadata: { contentType: request.headers.get("content-type") || "application/octet-stream" },
customMetadata: { public: "true" },
});
return Response.json({ ok: true, key });
}Set the UPLOAD_KEY secret once with manage_app_env (action: set) (ask your assistant), then your external job just PUTs to the route — no session, no human:
curl -X PUT --data-binary @parcels.pmtiles \
-H "x-upload-key: $UPLOAD_KEY" \
"https://your-app.com/upload?key=data/parcels.pmtiles"The file lands in storage and is served at /files/data/parcels.pmtiles with HTTP Range support — ideal for a scheduled refresh of large assets like PMTiles map data. Rotate the key any time by changing the env var; keep it secret (it grants write access to your storage).
Large uploads
For very large user uploads, an assistant can request a direct upload URL with the request_upload_url MCP tool rather than streaming the bytes through a route. See Limits & quotas for file-size caps.