Storage
Storage gives every app a place for its own files, and gives a container app the same SQL database a Worker has. Both belong to the app. No other app, in your workspace or anyone else’s, can reach them.
Storage is on every plan. Container apps need a paid plan, so a container’s database does too. Anyone who can deploy the app uses it from the app’s code. From your computer, jiayang files and the dashboard’s Files tab are for the workspace’s owners and admins: see Manage an app’s files.
Which apps get what
Section titled “Which apps get what”| Kind | Files | Database |
|---|---|---|
| Worker | Yes, bound as FILES. |
Yes, bound as DB. See Database. |
| Container | Yes, on its storage host. | Yes, on its storage host. |
| Static site | No. | No. |
Both are made on the app’s first deploy with storage on, and stay the same through every deploy and rollback.
On Team and Business an app can also have key-value storage and a queue, when its jiayang.json asks.
Both are made near where the app was created from, so they answer your code quickly when its users are near you too. An app created in London keeps its database and files in western Europe, one created in Singapore in Asia-Pacific, and one created anywhere in the Americas in eastern North America. Where they go is decided once, when the app is created, and doesn’t move after. Apps from before we did this have theirs in eastern North America.
Files from a Worker
Section titled “Files from a Worker”env.FILES is a Cloudflare R2 bucket of the app’s own:
export default { async fetch(request, env) { const url = new URL(request.url); const name = url.pathname.slice(1); if (request.method === "PUT") { await env.FILES.put(name, request.body, { httpMetadata: { contentType: request.headers.get("content-type") ?? "application/octet-stream" } }); return new Response(null, { status: 201 }); } const file = await env.FILES.get(name); if (!file) return new Response("Not found", { status: 404 }); return new Response(file.body, { headers: { "content-type": file.httpMetadata?.contentType ?? "application/octet-stream" } }); },};FILES is the platform’s name for it, so an app can’t have a variable called FILES. An app that set one before storage was on is asked to remove it on its next deploy, with jiayang env remove.
In TypeScript, declare it on your Env:
interface Env { DB: D1Database; FILES: R2Bucket;}Files and a database from a container
Section titled “Files and a database from a container”A container app reaches its database and files over plain HTTP, at http://storage.jiayang.internal. That host only exists inside your app’s container: the platform answers it with your app’s own database and files, and it can’t be reached from anywhere else. Your code holds no key, token or database name for it, and needs none.
Send X-Jiayang-Storage: 1 with every request to it. A request without that header gets 403 and touches nothing. It keeps your files safe from your own app: if the app fetches a URL someone gave it, as a link preview or an import does, a redirect could point that fetch at the storage host, and a redirect never adds the header. So set it only on the requests you send to the storage host, never as a default for every request your app makes.
Most HTTP clients send a header they were given again on every redirect they follow. A header set as a default on a client, like requests.Session().headers, axios’s defaults.headers or curl -L -H, goes to wherever a redirect points, the storage host too. Use one client for the storage host alone, or plain calls with the header on each, as in the examples below, and fetch everything else without it.
| Request | What it does |
|---|---|
POST /db/query |
Runs one statement. Send {"sql": "...", "params": [...]}. |
POST /db/batch |
Runs several in one transaction. Send {"statements": [{"sql": "...", "params": [...]}]}. If one fails, none of them is kept. |
GET /files?prefix=&cursor=&limit= |
Lists up to 1,000 files, oldest name first, and a cursor for the next page, or null. |
GET /files/<name> |
Returns the file, with its type. HEAD returns only the headers. |
PUT /files/<name> |
Writes the file, replacing one with the same name. Send Content-Length. Up to 100 MB. The host reads exactly Content-Length bytes: a body that ends before then writes nothing, and anything sent past it is read as the start of your next request on that connection, as HTTP/1.1 reads it. Send the length the body really has. |
DELETE /files/<name> |
Deletes the file. Deleting one that isn’t there is fine. |
/files?key=<name> |
The same four, for any name, encoded as a query value: GET, HEAD, PUT and DELETE. |
A query answers with its rows and what the database counted:
{ "results": [{ "id": 1, "body": "first note" }], "meta": { "rows_read": 1, "rows_written": 0, "changes": 0, "last_row_id": 1, "duration": 0.2 } }Parameters are strings, numbers, true, false or null. A statement the database refuses comes back as 500 with its message in message, and a request it can’t read as 400. A request about the database is at most 1 MB.
From Node:
const STORAGE = "http://storage.jiayang.internal";const MARK = { "x-jiayang-storage": "1" };
async function query(sql, params = []) { const res = await fetch(`${STORAGE}/db/query`, { method: "POST", headers: { ...MARK, "content-type": "application/json" }, body: JSON.stringify({ sql, params }), }); if (!res.ok) throw new Error((await res.json()).message); return (await res.json()).results;}
await query("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT)");await query("INSERT INTO notes (body) VALUES (?)", ["first note"]);
const report = Buffer.from("a,b\n1,2\n");await fetch(`${STORAGE}/files/reports/october.csv`, { method: "PUT", headers: { ...MARK, "content-type": "text/csv", "content-length": String(report.length) }, body: report,});From Python:
import jsonimport urllib.request
STORAGE = "http://storage.jiayang.internal"MARK = {"x-jiayang-storage": "1"}
def query(sql, params=()): body = json.dumps({"sql": sql, "params": list(params)}).encode() req = urllib.request.Request(f"{STORAGE}/db/query", data=body, headers={**MARK, "content-type": "application/json"}) with urllib.request.urlopen(req) as res: return json.load(res)["results"]
rows = query("SELECT id, body FROM notes WHERE id = ?", [1])
req = urllib.request.Request(f"{STORAGE}/files/reports/october.csv", headers=MARK)with urllib.request.urlopen(req) as res: report = res.read()Use plain http://: the request never leaves the platform, so there’s nothing for TLS to protect. Don’t rely on https:// for this host.
While the app is stopped (its workspace is suspended, it’s past its usage limit or its storage, or it was taken down) the storage host answers 503 with "error": "stopped" and touches nothing, within 10 seconds of the app stopping. Try again later rather than in a tight loop.
While the workspace is over its database limit, a Workers app answers only the workspace’s owners and admins, so nothing else writes to its database. A container’s background jobs, timers and queue consumers need no visitor, so the storage host holds them to the same: it runs only a single SELECT, DELETE, DROP TABLE, DROP INDEX or DROP TRIGGER, and anything else gets 507 with "error": "database_full" and doesn’t run. A batch runs only if every statement in it is one of those. To be sure of what it runs, the storage host also refuses a comment before the first word and a ; anywhere but at the end, even inside a string. A trigger can write when a DELETE fires it, and so can a DROP TABLE whose rows an ON DELETE CASCADE deletes from another table, so while the database has any trigger in it, a DELETE and a DROP TABLE get 507 too: drop the triggers with DROP TRIGGER first, and make them again once there’s room. A foreign key with ON DELETE SET NULL or SET DEFAULT changes other rows when one goes, so while any table has one, a DELETE and a DROP TABLE get 507: delete with jiayang db exec instead. A delete from a full-text (FTS5) table can still add a little to its index until it’s merged. Files aren’t affected; they have their own limit. If the database still reaches 10% over the limit, the app stops altogether.
File names
Section titled “File names”In a storage host path, /files/<name>, a file’s name is letters, digits and -_.~!$&'()*+,;=:@, in parts joined by /, like reports/2026/october.csv. No part can be empty, . or .., and a name is at most 1,024 characters. Nothing in a path is ever decoded, so put the name in it as it is. A name with anything else in it is refused there with 400 bad_key, and nothing is written. That includes a . or .. part and a % escape: a request for /files/a/../b or /files/a%2Fb, sent as written, is refused, never read as another name. Many HTTP clients, fetch among them, resolve . and .. parts before the request leaves your app, so fetch given http://storage.jiayang.internal/files/a/../b sends /files/b, and reads or writes b. Either way the request stays in the app’s own storage, but check names in your code before you put them in a URL.
Any other name goes in the query instead, encoded as any query value is: /files?key=cache%2Fpage%3Fid%3D1 names the file cache/page?id=1. The query is decoded once and never resolved, so a name there can hold a space, a %, a ? or a .. part, up to 1,024 bytes, and it names exactly that file in the app’s own storage.
A Worker’s FILES binding takes any name, spaces and all. jiayang files ls lists those too, and jiayang files get, put and rm take them by the name exactly as the app wrote it, so you can always download them and make room. A leading ./ is part of the name too, so ./notes.txt and notes.txt are two files. jiayang files rm takes every name an app can write but one with both a control character and a part that is . or .., which no call to Cloudflare can carry; a file like that goes when its app is deleted. get and put can’t reach a name with a part that is . or .., a leading ./ included; read or write those from the app.
Limits
Section titled “Limits”Files count toward the workspace’s storage, together with its databases and the files its sites serve: 1 GB on Free, 10 GB on Team and 100 GB on Business. A file counts as its own bytes, the size jiayang files ls shows, without its name or metadata. We measure files every five minutes, from R2’s analytics. Those can leave out a new app’s files for an hour or more, so until they include an app’s files, we count them by listing them instead, up to 10,000 files at a time. An app with more files than that counts as at least those 10,000 until the analytics catch up. After jiayang files put, or once an app writes to files we last measured as empty, the next measure lists them again, and until then usage says they aren’t measured yet.
Over storage, nothing new deploys, and jiayang files put is refused. If any app keeps files, every app with a place for files also stops answering, with 507, for everyone including the workspace’s owners and admins, because nothing else would stop an app from writing more. Every app with code of its own has a place for files, so once any of them keeps files, that’s every one of them, whatever made the workspace go over. A container app’s storage host stops too.
Since the apps won’t answer you either, make room from the command line: jiayang files rm deletes files, and jiayang db exec deletes rows. Or move to a bigger plan, which takes effect at once. The first time you delete an app’s files in a day, what’s left counts at once, for two hours. A delete later that day counts only once our measure catches up, which can take up to two hours. Otherwise the apps answer again within a few minutes of the next measure.
On Free, if we can’t measure how much the files take up for two hours, apps with files stop until we can, as they do when their rows can’t be counted. They also stop if an app’s files can’t be counted either way for 15 minutes: the analytics don’t include them yet, and we can’t list them whole, because there are more than 10,000 or the listing fails. They answer again once we can count them.
A workspace that stays over storage because of its apps’ files loses those files: after 30 days on Team and Business, and after 7 days on Free. Its owners get an email a week before, which on Free is as soon as a second measure, five minutes on, still finds it over, and another if it gets back under in time, saying the files won’t be deleted. A workspace back under before that second measure gets no email: nothing of it will be deleted. Its apps with files answer 507 from the first measure, and the dashboard says why to everyone in the workspace. Then every one of its apps’ files is deleted within a day or two, and its apps answer again. Its databases and its sites’ files aren’t touched. If the workspace would still be over without its apps’ files, they aren’t deleted: deleting them wouldn’t help. A legal hold on the workspace keeps the files, even one placed after the deleting has begun: within a minute nothing more is deleted.
Reading and writing files counts toward the workspace’s database rows: each write or listing as 5 rows written, and each read as 360 rows read. Deletes are free. Only what the app and you do counts: the listings we make to measure the files, and making the app’s place for them, don’t. So on Free, apps stop for the month after about 20,000 file writes or 139,000 reads. Team includes about 100,000 writes and 1,390,000 reads a month, and Business ten times that. Past those, they’re charged as rows. Cloudflare often gives its figures for file operations as estimates, even for a handful: those count toward the limits as estimated, and as with rows, we bill only Cloudflare’s exact figures. Operations can show up an hour or more after they’re made, more so in an app’s first hour of files, and count when they do.
Manage an app’s files
Section titled “Manage an app’s files”The dashboard’s Files tab, on an app’s page, shows the app’s files by folder, where each part of a name before a / is a folder. A workspace owner can download a file, upload one into the folder that’s open, and delete one. A workspace admin can see the files and delete them. An upload replaces a file with the same name, and like a delete it takes effect at once, for the live app too. A file is always downloaded, never opened in the browser, whatever kind of file it is.
In the dashboard, downloading needs a sign-in from the last hour, and uploading or deleting one from the last 15 minutes. If yours is older, the dashboard asks you to sign in again.
jiayang files lists, downloads, uploads and deletes an app’s files from your computer. A workspace owner can do all four. A workspace admin can list and delete, and can’t read or write anything, as with jiayang db exec. A listing shows each file’s name, so admins see the names. If a name would say too much, have the app name its files by an ID.
If the app was taken down by us, its files can still be listed and deleted, but not read or written.
A static site serves the files it was deployed with and keeps none of its own, so jiayang files has nothing to show for one and says so. An app with code of its own keeps files through env.FILES.
On a platform with storage switched off, jiayang files can still list, download and delete the files apps kept from before, and refuses an upload. With --json, its code is storage_off, and the API answers 409 with the same code.
jiayang files ls acme/notes reports/jiayang files get acme/notes reports/2026/october.csvjiayang files put acme/notes reports/2026/november.csv ./november.csvjiayang files rm acme/notes reports/2025/old.csvjiayang files ls shows each file’s size, when it was written, in UTC, and its name. jiayang files rm says when there was no file by that name, so nothing was deleted.
jiayang files get saves the file under the last part of its name, or where --out says, or to stdout with --stdout or --out -. It won’t replace a file that’s already there unless you add --force. If the app has no file by that name, it says so, and with --json its code is no_such_file. jiayang files put takes up to 25 MB; for bigger files, have the app write them. - reads the file from stdin.
A workspace can make 30 jiayang files calls a minute, in bursts of up to 30, and each person 20 a minute across all their workspaces. Past that, a call is refused with 429 and says how many seconds to wait. Each call reaches storage through the same API every deploy on the platform uses, so this keeps one busy script from slowing everyone’s deploys. When the platform as a whole is busy, the last of its calls are kept for jiayang files rm in workspaces over their storage limit, each taking at most 5 of them a minute, so other people’s calls can’t keep you from deleting your way back under.
An app can name its files anything, so jiayang files ls shows control characters and anything else outside plain ASCII in a name escaped, like \u{1b}.
The dashboard’s calls count toward the same limits. The audit log records each of these as app.files, with what was done and a keyed hash of the name, never the name itself. If storage then fails, app.files_failed follows it, so a call that didn’t happen never reads as done.
If storage is switched off after an app got files, nothing new goes up, and within a few minutes running apps lose the FILES binding, and container apps their storage host, without a deploy. The files themselves stay: they still count toward storage, and jiayang files ls, get and rm still work on them.
What happens to the files
Section titled “What happens to the files”Deploys and rollbacks don’t touch them. An upload in parts that an app starts and never finishes is dropped a day after it began. R2 keeps no history of a file: one the app overwrites or deletes is gone at once, and can’t be restored. Deleting an app for good deletes its files. Up to 5,000 go at once, and the bucket with them. When there are more, we go on deleting them from the next minute, about 20,000 a minute, and the last of them are gone within two days at most. They can’t be restored, so download what you want to keep first. An app deleted without --for-good keeps its files for 30 days, and they come back if it’s restored: see Backups and restore.