Tenants
In saas mode, your platform runs your customers' apps: an AI app builder deploying the apps it generates, a site builder hosting its users' sites. Each customer is a tenant; each tenant has apps. Your product's backend drives them through the tenants API, and your customers, if you let them, through your tenants' CLI with a token of their own tenant.
A tenant's app is handlers in TypeScript or JavaScript, shaped like Cloudflare Workers', that your platform runs on scaledbox's runtime: each environment of an app is one sandboxed process (a box), with no path to other tenants, within limits you set. An app with nothing to do sleeps, holding nothing at all, and wakes in milliseconds on its next request, queue message or scheduled time: the request is held meanwhile. Its resources are standard concepts (invocations, CPU time, rows, messages…), not servers or containers.
Your API's reference (https://api.shop.example/docs) has every operation and field; this page is the
tour. Paths below start with https://api.shop.example/v1.
A token for your backend
shop-ops tokens create backend --role tenants
A tenants token reaches only your product's tenants: it can't deploy your product or read its secrets. Store it as one of your product's secrets.
Your API is behind Cloudflare's proxy, in your zone. Have your backend send a User-Agent that names it
(e.g. shop-backend/1.4), as your CLIs do (shop/<version>). If Cloudflare ever answers in the API's
place (a challenge carries the header cf-mitigated: challenge; a block, a text such as error code: 1020), log its status, that header and the cf-ray header: the Ray ID finds the request in your zone's
security events, and the rule that stopped it.
A part of your system that serves one customer needs only that customer: give it one tenant's token (or let them sign the CLI in themselves: below).
shop-ops tokens create acme --role tenants --tenant acme
or, from your backend with its tenants token, POST /v1/product/tokens with
{"name": "acme", "role": "tenants", "tenant": "acme"} (it expires no later than the token that made it, and is revoked with it).
It reaches only that tenant's apps, deploys, environments, domains, data, logs and usage, not its limits,
its suspension or its removal, nor any other tenant. Your backend's token lists and revokes such tokens
(GET, DELETE /v1/product/tokens/<id>); removing the tenant revokes them.
Your tenants' CLI signs in through your page
Your customers sign shop, your tenants' CLI, in to their own tenant through your page: your
product owns their accounts, your platform only their tenants (OAuth 2.0 device authorization, RFC
8628, as gh auth login and fly auth login do). Name the page in your platform's cluster file:
device_page = "https://shop.example/cli"
-
Your customer runs
shop login. The CLI shows a code such asBCDF-GHJKand openshttps://shop.example/cli?code=BCDF-GHJK. -
Your page, where they are signed in to your product, shows the code and asks them to confirm that it is the one in their terminal. Never approve a code nobody confirmed: whoever started the sign-in gets the token.
-
Your backend approves it for their tenant, with its tenants token:
POST /v1/tenants/<tenant>/device/approve {"user_code": "BCDF-GHJK"}204once approved;404if no CLI waits with that code (expired after ten minutes, already decided, or mistyped: any case, dashes and spaces are ignored). If they decline,POST /v1/device/denywith the same body tells the CLI so. One tenant's token can do neither. -
The CLI, which polls meanwhile, receives a token of that tenant only and keeps it in the user's configuration directory, under your brand's name (
~/.config/shop/credentials.jsonon Linux,~/Library/Application Support/shop/on macOS). It expires in 30 days at most, never after the token that approved it, and is revoked with it;shop logoutrevokes it at once.
Your tenants' tokens show in GET /v1/product/tokens (named shop cli <tenant> …), where your
backend can revoke one.
What the CLI calls, if you write your own client: POST /v1/device (no token) answers
device_code, user_code, verification_uri (your page), verification_uri_complete, expires_in and
interval; then POST /v1/device/token with {"device_code": "..."} every interval
seconds answers 428 ("error": "authorization_pending") until your backend decides, then the token
(secret), or 400 with "error": "access_denied" or "expired_token".
The tenants' CLI
shop, built from tenants.Main with your brand (Getting started),
is the tenants' CLI: what a tenant (or your backend, for one) runs with one tenant's token. Your CI
builds it for your customers' machines, and you publish it where they download it.
The tenant is the token's (GET /v1/whoami says whose it is), so no command names it; with your
product's tenants token, for all its tenants, --tenant acme names one.
export SHOP_TOKEN=sbx_... # acme's token (or: shop login)
shop deploy blog ./dist --assets ./dist/public
shop apps # each app's environments, their status and URLs
shop environments list blog
shop environments delete blog staging # its own database with it: type its name
shop apps delete blog # type its name; --confirm blog without a terminal
shop releases blog
shop rollback blog --env prod # the release before; or --to <digest, or its start>
shop logs blog --env prod --since 30m
shop secrets set STRIPE_KEY --env prod # asks for the value
shop domains add shop.acme.com --app blog # prints the records to create
shop dev ./dist # run it on this machine
SHOP_API_URL names another API than your brand's (https://api.shop.example). Your product's own
commands (its deploys, its tokens, any of its tenants: tenants deploy acme blog ./dist) are the
operator's CLI's, shop-ops, which is never handed to tenants.
Tenants and their limits
PUT /tenants/acme
{"limits": {"apps": 3, "database": "1Gi", "bucket": "5Gi",
"box": {"memory_mb": 128, "cpu_ms": 1000},
"month": {"invocations": 1000000, "cpu_ms": 3600000}}}
The limits are its plan, in standard units (Limits has every one, with its
default and maximum): how many apps, and environments each (a deploy to one more is refused); its
database's and bucket's sizes; box, the caps of each of its apps' boxes and of each invocation (an
app's box.toml may ask for less, never more); awake and
connections, its apps awake and WebSockets open at once, all together; cron_interval, how often a
schedule may fire; requests_per_second and concurrent_requests, each of its apps' names' rate and
requests open at once; and month, what it may use a month. New limits redeploy nothing: they hold
within moments where its apps run (an awake app takes new caps at its next request). A cap lowered below
what a running app's box.toml asks holds the app to it: GET .../apps/{app} says so in its
environment's held, and your product is told (tenant.quota_exceeded); a new release asking more is
refused. Past a month's allowance, new work is refused or slowed and nothing running is stopped:
| Past its month's | What happens until the next month (or a higher limit) |
|---|---|
invocations |
its apps answer at most 5 requests a second each (others get 429) |
cpu_ms |
new invocations are refused (429) |
memory_gb_s |
its sleeping apps don't wake (503); awake ones go on |
egress |
its apps' calls out to the internet go at 1 Mbit/s each, its bucket's downloads at 1 Mbit/s (never cut) |
Past its logs_per_day (UTC), its apps' output isn't kept until the next day.
Past its class_a and
class_b together, its bucket answers 5 requests a second (503 SlowDown, which S3 SDKs retry).
Your product is told (webhook tenant.quota_exceeded, then tenant.quota_cleared). A database at its
size turns read-only; a bucket at its size takes nothing more; its objects are at most its
bucket_object (default 25M, at most 100M). Suspending a tenant ends its bucket's transfers at once. Its database's and bucket's sizes are
charged at your prices (Spending), and so are its month's memory time and CPU, as used,
once you set their prices; its other usage is metered and shown, not charged.
POST /tenants/acme/suspend takes all its apps down in one call (they answer 503 and never wake);
POST /tenants/acme/resume brings them back. GET /tenants/acme/usage shows what its apps used, per
environment and unit: invocations, CPU-ms, memory time, rows, KV units, messages, egress, logs.
GET /tenants/acme/metrics?app=blog&env=prod&since=24h&step=1h gives its apps' (one app's, one
environment's) requests, CPU and memory over time, for you to show your customer.
GET /tenants/acme/analytics?app=blog&env=prod&since=24h&step=1h gives their traffic, as Workers' and
Vercel's analytics do: requests by status, latency percentiles (p50, p90, p99 of the time to the
answer's head) and the most requested paths. Every request is counted; paths are grouped, a segment
that looks like an id (digits, a UUID, a long token) as :id, and past an environment's first 50 paths
as (other). For one environment (app and env), versions gives each version of its deployment
apart (its requests, its 5xx and its percentiles, by its short id), so a gradual deployment's new
version is judged by its error rate and latency beside the old one's before you promote it.
Apps
PUT /tenants/acme/apps/blog
{"kind": "function", "subdomain": "acme-blog"}
declares the app: a function (handlers, with the resources its manifest binds) or static (files
only); its kind is fixed once made. It answers at https://<subdomain>.<tenant domain> (your
[tenants] domain, see Domains; the subdomain defaults to
<app>--<tenant>).
Before deploying, run it on your machine as a deploy would: Develop locally.
Deploying
Your platform builds nothing: your product builds its customers' apps (it already does: an app builder
bundles what it generates, with esbuild or its bundler of choice) and deploys what it built, the way
Cloudflare's Workers for Platforms take scripts. A deploy is the app's files by their SHA-256: its
JavaScript modules (ES modules, as many as the app has, importing each other by relative paths and
cloudflare:workers only), their source maps (for its logs' stack traces) and its static
assets by URL path (a static app's files; a function app's beside its handlers, at the paths its
fetch doesn't answer: [assets]), with its box.toml. Only the files your platform doesn't
have yet are sent:
POST /tenants/acme/apps/blog/deploys
{"box_toml": "main = \"index.js\"\n...",
"modules": {"index.js": {"hash": "sha256:9f2c…", "size": 4754}, "lib/db.js": {"hash": "sha256:…", "size": 812}},
"source_maps": {"index.js": {"hash": "sha256:…", "size": 8716}},
"assets": {"/index.html": {"hash": "sha256:…", "size": 1290}, "/logo.svg": {"hash": "sha256:…", "size": 3311}}}
The answer is the deploy (its id) and the files to upload in buckets (none if your platform has them
all, as when only box.toml changed). Upload each bucket as one multipart/form-data request, a part
per file named by its hash, a few buckets at a time:
POST /tenants/acme/apps/blog/deploys/<id>/files
then finish, within the hour:
POST /tenants/acme/apps/blog/deploys/<id>/finish
Finishing compiles each new module to bytecode on the tenant's cluster (a module compiled before, by
any deploy, isn't compiled again), records the release (identified by its digest) and deploys it to
the environment ("environment": "staging" in the manifest; prod by default): its answer is 202
with the deploy and the environment, deploying. A module that doesn't compile fails the deploy with a
422 naming the module, its line and the error; nothing changes. The manifest may be gzipped
(Content-Encoding: gzip); a code-only change sends no assets and "keep_assets": true to keep the
ones the environment runs. GET .../deploys/<id> shows a deploy and what compiling it took.
The CLI does all of it from a directory (its box.toml, its modules and their .map files):
shop deploy blog ./dist --assets ./dist/public on the tenants' CLI with acme's token (shop-ops tenants deploy acme blog ... from yours).
A box of the new release must wake and answer its health check before the environment moves to it
(within 3 minutes); until then, and if it fails, the environment keeps running what it ran. Poll the app
(GET .../apps/blog) for each environment's status; you get app.live or app.failed.
Compiling counts against the tenant's compile budget, month.compile_cpu_ms (ten minutes of CPU by
default): past it, its deploys are refused until the next month; nothing running stops.
GET .../apps/blog/releaseslists its releases; any can be deployed to any environment with{"release": "sha256:..."}.POST .../apps/blog/rollbackdeploys the release an environment ran before (or one you name);POST .../apps/blog/redeploydeploys its current one again.- Other environments (
staging, or a previewpr-<n>, gone after a week without a deploy) answer atstaging--<subdomain>.<tenant domain>, one level deep like the app (an environment's name has at least 3 characters, and its name there can't be another app's); they share the tenant's bucket, their keys apart. Each has a database of its own, made at its first deploy that binds one: a copy of production's as it was then (cloned in a fraction of a second; production's sessions end for about 0.1 s, its apps' pools reconnect), or an empty one on an instance not yet on a data node's ZFS pool. Its migrations never touch production's data, its kv and queues start empty, its size is capped and reserved as the tenant's database is (limits.database), and it goes with the environment. An environment's own database is shown with it (database: its name, origin, size); its dumps are the tenant's dump routes with?app=<app>&environment=<env>.
Versions and gradual deployments
A release is a version, as in Cloudflare Workers. An environment's deployment is one version
taking all its traffic, or two splitting it by percentage while you roll the new one out. Every version
answers alone at its preview URL, https://<version>--<subdomain>.<tenant domain> (for prod;
<version>--staging--<subdomain> for staging), its version the release's first 8 hex digits: test it
there before it takes any traffic.
Deploy a release without traffic, beside the one the environment runs ("percent": 0 in the deploy's
manifest, or shop deploy blog ./dist --percent 0), then move it forward, promote it
or roll it back:
PUT /tenants/acme/apps/blog/environments/prod/versions
{"versions": [{"release": "sha256:<v1>", "percent": 90}, {"release": "sha256:<v2>", "percent": 10}]}
- Shares sum to 100;
{"versions": [{"release": "sha256:<v2>", "percent": 100}]}promotes v2.POST .../apps/blog/rollbackduring a split puts all traffic back on the version it ran before. - The environment lists its
versions, each with itspercentandpreview_url. - A client keeps its version while the split lasts: send the same
Scaledbox-Version-Keyheader (or ascaledbox-version-keycookie, e.g. your session's id) and its requests land on the same version; without one, the client's address decides. A client on the version whose share grows stays on it.Scaledbox-Version: <short id or digest>asks for one version of the deployment. - Each version runs in a box of its own while the split lasts. The first version's
cronschedules, queue consumers and ingest paths are the environment's; they run on it. - Each Durable Object runs one version while the split lasts, chosen by its id, as on Workers: a stub's call from the other version, an object's sockets and its alarms all reach that version's code. Moving the split restarts only the objects it moves, as on Workers: each starts again on its new version from what its storage holds (a write it hadn't committed yet fails, as a Durable Object's in-flight work does when it is reset), its sockets stay open and its next events reach the new version. Every other object, socket and request goes on untouched: no box is replaced.
- A WebSocket stays on the version it opened on (one an object accepted, on its object's), and a
client's static assets come from its version too (both versions have assets, or neither). A path is
routed as the client's version says: its version is chosen first, then its
run_worker_firstdecides whetherfetchor its files answer (so a path only the new version'sfetchanswers is a file for the clients still on the old one); each version's preview URL routes as its own. - A static app's deployment has one version: try a new one at an environment of its own.
- Names: an app's subdomain, or an environment's name beside it, leaves room for the version (at most 53 characters), and no app or environment is named as 8 hex digits.
Logs
Every line of an app's boxes is kept 14 days: its console.* lines, a line per request (level
request: method, path, status, duration) and the runtime's own (runtime), each with its environment,
release and request id (the request's X-Request-Id, else the runtime's own); a thrown error's line has
its stack in stack, mapped to your source by the source maps you deployed (../src/util.ts:6:40;
the stack in the bundle's lines is in bundle_stack).
GET /tenants/acme/logs?app=blog&environment=prod&level=error&since=1h
GET /tenants/acme/logs?request_id=5f0c2a9e-1
GET /tenants/acme/logs/tail?app=blog
A search answers the newest lines (limit, at most 1000), oldest first; q keeps those with a phrase.
tail streams server-sent events, a log event per new line about a second after it was written
(since=5m sends the last five minutes' first); a line the store took late (around a box's move to
another node) comes within about 6 seconds of its writing, out of order, and one it took over 5 seconds late (while it
restarted) is in searches only. It ends after an hour, and a tenant has 5 at once. A box's lines from before it was claimed for the app are the platform's warm-up,
not the app's, and aren't in its logs.
GET /tenants/acme/traces/5f0c2a9e-1
is a request's trace, as Workers' automatic tracing gives it, in OpenTelemetry's span model: the
request's own span, and a span for each fetch (each redirect) and each call of a binding (db.query
with its SQL, kv.get, queue.send, bucket.put…), with its start, duration, attributes and error; a
thrown error is an exception event, its stack mapped. A trace is kept with its request's line (14 days;
a request keeps its first 64 spans), so a box writing more than its 100 lines a second loses some.
box.toml
main = "index.js" # the module (of the deploy's) whose default export handles requests
hosts = ["api.stripe.com"] # what fetch may reach (https only); nothing else
cron = ["0 * * * *"] # scheduled() runs (UTC)
[bindings.DB] # env.DB
kind = "database"
[bindings.KV]
kind = "kv"
[bindings.JOBS]
kind = "queue"
consumer = true # its messages go to this app's queue()
[bindings.FILES]
kind = "bucket"
[[bindings.FILES.events]] # its objects' changes, one message each on JOBS
queue = "JOBS"
actions = ["object-create", "object-delete"]
prefix = "uploads/" # optional, and suffix
[bindings.STRIPE_KEY] # env.STRIPE_KEY, a string: the tenant's secret STRIPE_KEY
kind = "secret"
[bindings.AUTH] # the app's own users (env.AUTH; /api/auth/* served by the platform)
kind = "auth"
user_events = "USERS" # a queue binding (consumer = true) each new user is a message on
providers = ["github"] # sign-in with GitHub (and/or "google"): the tenant's OAuth app, set first
[bindings.USERS]
kind = "queue"
consumer = true
[limits] # within the tenant's box limits
memory_mb = 128
cpu_ms = 100 # per invocation, at most the tenant's box.cpu_ms (200 unless you raise it)
[auth] # your product's users' JWTs, verified before the handler runs
jwks_url = "https://shop.example/.well-known/jwks.json"
[[routes]]
method = "POST"
path = "/api/orders"
auth = true # needs a valid JWT; its claims are ctx.user
schema = { type = "object", required = ["item"] } # the body's JSON Schema: 400 if it doesn't fit
[assets] # with the deploy's assets
run_worker_first = ["/api/*", "!/api/docs/*"] # the paths fetch answers; the rest are assets
not_found_handling = "single-page-application" # a path no asset has: index.html for a browser
| Key | What it is |
|---|---|
main |
One of the deploy's modules (.js, .mjs: TypeScript and JSX are bundled by you); its default export has the handlers. A static app has none |
hosts |
Host names fetch may reach, over https on port 443 (at most 50); every redirect is checked too |
cron |
Schedules for scheduled(): five fields (minute hour day month weekday, UTC) or @hourly, @daily, @weekly, @monthly; at most 5 |
[bindings.<NAME>] |
A resource the code reaches as env.<NAME>: kind is database, kv, queue, workflow, bucket, webhooks, objects (with class, the exported class each object is, at most 55 characters), secret, ai or auth (one at most); at most 20 |
secret |
A secret binding's: the tenant's secret it binds, when not the binding's own name ([bindings.STRIPE] kind = "secret" with secret = "STRIPE_KEY") |
user_events, providers |
auth's: the queue binding (with consumer = true) each new user's user.created message goes to; the OAuth providers its users may sign in with (github, google) |
consumer |
A queue's: its messages are delivered to this app's queue(); max_batch_size (10, at most 100), max_batch_timeout (seconds, 5), max_retries (3) as Workers Queues' |
queue |
A product's service's queue's: its environment's queue it is (default: the binding's name), which its services share, one consuming it; a tenant app's queues are its own (refused) |
class |
A workflow's: the exported class that runs it |
[[bindings.<NAME>.events]] |
A bucket's: its objects' changes sent to one of the app's queue bindings (queue), one message each; actions are object-create (PutObject, CopyObject, CompleteMultipartUpload) and object-delete (DeleteObject, LifecycleDeletion); optional prefix and suffix; at most 10 |
[[routes]] |
method, path (/api/orders/:id), optional schema (a JSON Schema for the body) and auth |
[auth] |
jwks_url (https), optional issuer and audience |
[[ingest]] |
path and queue: POST and PUT requests to that path are stored on that queue binding and answered 202, without waking the app (webhooks you must never lose) |
[limits] |
memory_mb, cpu_ms, duration_ms, subrequests, body_bytes, rows_read_per_tx, rows_written_per_tx, idle_seconds: 0 or unset is the tenant's |
[assets] |
run_worker_first and not_found_handling, as Workers' (below) |
Unknown keys and invalid values are refused before anything is uploaded, each message saying what to write instead.
Assets
An app's static assets are the deploy's assets, served at their paths by the tenants' cluster's file
server: /docs/ serves /docs/index.html, and their answers aren't invocations (they are egress). A
function app's assets share its URLs with its fetch, as a Worker's do, named as Workers' settings:
run_worker_firstlists the pathsfetchanswers, as patterns:*matches anything (/included),!excludes (["/api/*", "!/api/docs/*"]:/api/and below but its docs); at most 100. By default["/api/*"]. Every other path is an asset's, served without waking the app: unlike on Workers, a path no asset has is never passed on tofetch, so a pagefetchrenders is a path ofrun_worker_first. A deploy whose[[routes]]or[[ingest]]paths are assets' is refused, saying which.not_found_handlingis what answers a path no asset has:404-page(the default) is the nearest404.htmlup its path with404(/docs/404.htmlfor/docs/gone, else/404.html), or a plain404;single-page-applicationanswers a browser's navigation (Sec-Fetch-Mode: navigate, orAccept: text/htmlfrom a client that sends no fetch metadata) with/index.htmland200, for the app's client-side router, and anything else as404-page(a missing script stays a404). Astaticapp may be a single-page app too.
Handlers
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const { rows } = await env.DB.query("select id, total from orders where customer = $1", [42]);
return Response.json(rows);
},
async scheduled(event, env, ctx) {
// event.cron, event.scheduledTime
},
async queue(batch, env, ctx) {
for (const message of batch.messages) {
// message.body, message.attempts; message.ack() or message.retry()
}
},
};
- The runtime is QuickJS with the standard Web APIs (WinterTC's set):
fetch,Request,Response,Headers,URL,crypto,TextEncoder/TextDecoder,AbortController, timers,Blob, streams,console,performance. Packages from npm are bundled in by you; Hono and similar frameworks work. Not available: Node's built-in modules (fs,net,child_process,process),Intl, WebAssembly, native modules, threads, servers of your own. The modules imported at run time arecloudflare:workers(WorkflowEntrypoint,NonRetryableError) andnode:async_hooks. AsyncLocalStorage(fromnode:async_hooks, also a global, as Node's:run,getStore,exit,enterWith,AsyncLocalStorage.bindandsnapshot; andAsyncResource): a store follows the request across itsawaits, timers and microtasks, so requests handled at once each see their own (Better Auth's and similar libraries' request context works).ctx.waitUntil(promise)keeps work going after the answer;console.*lines are the app's logs.- A queue handler acks each message it doesn't retry when it returns, and retries them all when it
throws (Workers' semantics). A retried message waits 5 s, doubling each attempt; past
max_retriesit is set aside as a dead letter. - A scheduled run is skipped while the previous one still runs; schedules are spread over the
minute, so
0 * * * *runs within that minute.
Bindings
| Kind | env.<NAME> |
|---|---|
database |
The tenant's Postgres database, as a pg Pool: query(text, values) → {rows, rowCount, fields}; transaction(async (client) => …) commits when it resolves; connect(). Drizzle's node-postgres driver and Kysely's PostgresDialect attach to it. int8 and numeric come as strings, timestamps as ISO strings, bytea as Uint8Array, json as values. Session state (SET, LISTEN, temporary tables) doesn't last past a transaction; SET LOCAL does. A transaction ends with its request: one left open is rolled back once the request is answered (one begun after the answer, in waitUntil, once the app has no request in flight) |
kv |
Workers KV's API: get(key, {type}), getWithMetadata, put(key, value, {expirationTtl, metadata}), delete, list({prefix, cursor}); strongly consistent, and atomic() with Deno KV's checks: .check({key, versionstamp}).set(…).sum(…).commit() |
queue |
Workers Queues': send(body, {delaySeconds}), sendBatch([…]); JSON or text bodies, delays up to 12 hours |
workflow |
Workers Workflows': create({id, params}), get(id) → status(), sendEvent({type, payload}); the class (extends WorkflowEntrypoint) runs step.do, step.sleep, step.sleepUntil, step.waitForEvent, each step's result kept, so a run resumes where it was |
bucket |
R2's binding API over the tenant's object storage: get, head, put, delete, list; and presign(key, {method, expiresIn}) for a URL your users upload or download with (a transfer ends when its URL does: give it minutes) |
objects |
Durable Objects, with class = "Room" (an exported class extending DurableObject from cloudflare:workers): env.ROOMS.idFromName(name), idFromString(id), newUniqueId(), get(id) and getByName(name) give a stub, whose fetch(request) and methods (RPC, arguments and results copied as structuredClone copies them) run the object: one instance per id, made with (ctx, env). ctx.storage is Durable Objects' key-value storage API: get, put, delete, deleteAll, list({prefix, start, startAfter, end, reverse, limit}), transaction(fn), sync(), getAlarm, setAlarm(time), deleteAlarm (and alarm() runs then, retried if it throws); keys are strings of at most 2 KiB, values at most 128 KiB; an answer waits until its writes are stored, and while a read waits for the database no other event reaches the object. ctx.blockConcurrencyWhile(fn), ctx.acceptWebSocket(ws, tags), ctx.getWebSockets(tag), ctx.getTags(ws) as on Workers. There is no SQL storage per object: use the database binding for SQL. Stored in the tenant's database, its size counting them |
secret |
The tenant's secret as a string (below), as its environment sees it: new Stripe(env.STRIPE_KEY) works at the module's top level |
webhooks |
send({eventType, payload}, {idempotencyKey}): signed (Standard Webhooks), delivered to the tenant's endpoints and retried by Svix. Its endpoints, in Svix's shapes: endpoints({limit, iterator}) lists them, createEndpoint({url, filterTypes, channels, uid, …}, {idempotencyKey}), endpoint(id), updateEndpoint(id, {…}) and deleteEndpoint(id) manage them (by id or uid), endpointSecret(id) gives one's signing secret for its receiver, rotateEndpointSecret(id, {key}) replaces it (the old one still signs for 24 hours). Only the tenant's own |
ai |
Your product's AI gateway (its [ai]), with its provider keys: OpenAI's Chat Completions (/v1/chat/completions) and Responses (/v1/responses) and Anthropic's Messages (/v1/messages), a model named <provider>/<model>. fetch(input, init) answers a call whole, and the providers' SDKs take it as theirs (new OpenAI({apiKey: "unused", baseURL: "https://ai.invalid/v1", fetch: env.AI.fetch})); stream(path, request, {ttl}) gives {url, expires}, a single-use URL your user's browser fetches the answer streamed from (new EventSource(url), or a POST), while your app sleeps; handoff(path, request) gives a Response to return: your user gets the answer streamed instead, your app done at once. Your tenant's tokens are metered per model; past its month's (limits.month.ai_tokens) or at its requests at once (limits.ai_streams), new calls get 429 and those under way finish |
auth |
The app's own users (Better Auth's, run by the platform): getSession(request) → {session, user} or null, user(request) → the user or null, read from the request's session cookie in a fraction of a millisecond, no library in your bundle. Its users, sessions and organizations are tables of schema auth in the tenant's database (auth."user", auth.session, auth.organization, auth.member…): yours to read and join in SQL |
auth serves the app's users at its own host, under /api/auth/ (Better Auth's client works as is:
createAuthClient() from better-auth/client, with its organizationClient, emailOTPClient and
passkeyClient plugins): sign-up and sign-in with an email and password (argon2id), email codes,
passkeys, GitHub or Google (the tenant's OAuth app: PUT .../tenants/{tenant}/auth/providers/github
with its client_id and client_secret, its callback https://<the app's host>/api/auth/callback/github),
organizations with members and invitations. GET /api/auth/get-session is answered by the app itself,
without waking anything else. Sessions last 7 days, extended as they are used.
- Emails (codes, invitations) are sent by your platform from its own address, worded by it with the
app's name; a tenant's apps send at most 100 an hour together, and its month's
emails(10,000 by default), past which the request is answered429. An invitation links to/accept-invitation?id=<id>on the app's first host: your page there callsorganization.acceptInvitation. - A new user is a message on the
user_eventsqueue,{"type": "user.created", "user": {id, email, name, emailVerified, createdAt}}, sent with the user (one per user, however made) and delivered to the app'squeue()after: a failing handler is retried, never fails the sign-up. - The users are the tenant's: every app of the tenant with
authshares them (each app its own sessions). - Rate limits per client address (Better Auth's: sign-ups and sign-ins a few in 10 seconds) answer
429.
A bucket's events (as R2's event notifications): a tenant's bucket is its apps' bucket bindings of
one name (every FILES of its apps is one bucket), and each app environment declaring
[[bindings.FILES.events]] gets the changes its rules match on its queue, at least once, as R2's
message:
{"account": "acme", "action": "PutObject", "bucket": "FILES",
"object": {"key": "uploads/a.png", "size": 5120, "eTag": "9a0364b9e99bb480dd25e1f0284c8555"},
"eventTime": "2026-10-26T10:00:00Z"}
A change is answered only once its events are stored (a client cut off sends it again: an event may
come twice). A copy's event has no size; a deletion's neither size nor ETag. Each message counts in the
tenant's messages; past its allowance, events are dropped.
Lifecycle rules (S3's lifecycle configuration): objects under a prefix deleted some days after they
were written, multipart uploads left incomplete aborted some days after they began, applied once a day
(a day is 24 hours; an object several rules match goes by the fewest days). Deletions send the
bucket's LifecycleDeletion events. Rules are a bucket's whole set, replaced by each PUT; a tenant's
token may set its own:
curl -X PUT -H "Authorization: Bearer $TOKEN" https://api.shop.example/v1/tenants/acme/buckets/FILES/lifecycle \
-d '{"rules": [{"id": "tmp", "prefix": "tmp/", "expire_days": 7}, {"id": "uploads", "abort_incomplete_multipart_days": 1}]}'
WebSockets are held by the platform while the app sleeps; each belongs to an object. As on Workers, an object accepts one while answering its upgrade request:
export class Room extends DurableObject {
async fetch(request) {
const [client, server] = Object.values(new WebSocketPair());
this.ctx.acceptWebSocket(server, ["lobby"]);
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws, message) {
for (const s of this.ctx.getWebSockets()) s.send(message);
}
}
export default { fetch: (request, env) => env.ROOMS.getByName("lobby").fetch(request) };
Its webSocketMessage(ws, message) and webSocketClose(ws, code, reason, wasClean) run for its
sockets, its box woken if it slept; getWebSockets() answers every socket of the object, on whichever
of your servers holds it. Without objects, the default export's handlers hold the app's
sockets (its default object): webSocketOpen(ws, request, env, ctx) (a Response other than 2xx
refuses it), webSocketMessage(ws, data, env, ctx) (what it returns is sent back) and
webSocketClose(ws, code, reason, wasClean, env, ctx), ctx.getWebSockets() their sockets. await ws.send(…), ws.close(…) and ws.serializeAttachment(value) (at most 16 KiB, kept with the socket)
work as on Durable Objects; the sends of one moment go out together. Pings never wake the app.
Secrets
A tenant's secrets (its Stripe key, its own API keys) are set once for the tenant and bound by its apps:
PATCH /tenants/acme/secrets
{"set": {"STRIPE_KEY": "sk_test_…"}}
PATCH /tenants/acme/secrets
{"environment": "prod", "set": {"STRIPE_KEY": "sk_live_…"}}
The first is for every environment of the tenant's apps; the second an override for prod
("environment": "preview": every preview, pr-<n>; so no environment is named preview). An environment sees its own value, then (a
preview) the previews', then the tenant's. "unset": ["STRIPE_KEY"] removes a scope's value;
DELETE /tenants/acme/secrets/STRIPE_KEY all of them. Your backend, or the tenant's own token, does it;
the CLI asks for the value (shop secrets set STRIPE_KEY --env prod), never on the
command line.
- Write-only: values are sealed and never returned.
GET /tenants/acme/secretslists each secret's versions per scope, a digest (within a secret, equal digests are equal values), who set it and when, and the app environments binding it with the version each sees. - Versions: every change is the secret's next version (setting the value it has is none);
GET …/secrets/STRIPE_KEY/versionslists the last 10, andPOST …/secrets/STRIPE_KEY/restore {"version": 3}makes that value current again. A secret removed everywhere can be restored for 30 days. - Nothing is redeployed: an awake app binding a changed value takes it at its next request (the previous one finishes what it has in flight); one that sleeps wakes with it. A rolled-back release sees the secrets as they are now.
- Refused: a deploy of a release binding a secret its environment doesn't see (
409, before it compiles), and removing a value a running release binds (409, naming the apps). - Limits: 64 secrets a tenant, 4 KiB a value, 32 KiB in all. Your product's own secrets never reach your tenants' apps.
Domains
A tenant's own domain for one of its apps:
PUT /tenants/acme/domains/blog.acme.example
returns the two records the domain's owner creates: a TXT record proving it (its value in quotation
marks, as Cloudflare wants a TXT record's content) and a CNAME to the app's
own target (<id>.shop.<targets>, the same for each of its domains), proxied (orange cloud; at an
apex Cloudflare flattens it). For now the domain's DNS must be
on Cloudflare, in its owner's own account (its free plan does): a domain elsewhere is refused, since
the app is served through that zone's proxy. The app's production answers on it once both hold (no
certificate of its own to wait for); its other environments answer only at their names under your tenant
domain. Your platform checks the CNAME by fetching /.well-known/scaledbox-check/<token> through the name
over plain HTTP: no redirect rule of the zone may catch that path.
Each own domain is a paid resource: your price book's custom_domain_month, charged hour by hour to
the tenant's ledger while it has the domain, whatever its status (see Spending). The
names your tenants' apps get under your tenant domain are free.
Events
PUT /webhook
{"url": "https://shop.example/hooks/platform"}
sends your product's events: app.live, app.failed,
domain.verified, domain.failed, domain.released, tenant.quota_exceeded, tenant.quota_cleared,
tenant.suspended, tenant.resumed, tenant.deleted, tenant.secrets_changed (the names and their scope, never a value), tenant_app.unhealthy and tenant_app.recovered (below),
service.unhealthy and service.recovered (your own services': see
Deploying). They are
Standard Webhooks: verify the webhook-signature header with the
secret the call returns once; webhook-id is the idempotency key. Undelivered events are retried for a
day.
Your tenants' apps' health
A tenant's app is never probed: a check would wake its box. Its health is its real traffic's, over the
last 5 minutes, once it had 20 requests or more: failing when 20% of them failed, degraded at 5%
or when its answers' 95th percentile (to their first byte) took 5 seconds or more. Failed means the
app's own failures: a 5xx it answered, a request it took and didn't answer, a wake its code threw in at
its top level; never the platform's own refusals (a tenant past its limits, suspended, a bot kept from
waking it). Checked every minute: tenant_app.unhealthy says so, {tenant, app, environment, status, reason, requests, error_rate, p95_ms, since} (again when degraded turns failing), and
tenant_app.recovered once it is healthy again (or quiet: no failure in the window). GET .../apps/<app> shows a degraded or failing environment's health (its status, why, since when), and
GET /v1/health all of your tenants' (tenant_apps).
Retries
A POST with an Idempotency-Key header is answered once: sent again with the same key within a day,
it gets the first answer.
Data: dumps, export, deletion
-
GET /tenants/acme/dumpslists its database's dumps (nightly and on request, kept 14 days);GET .../dumps/<dump>gives a download link for 15 minutes;POST .../dumpstakes one now. -
POST /tenants/acme/restorewith{"dump": "...", "confirm": "acme"}restores one in place, after dumping the database first so it can be undone. A restore replaces the tenant's data, so it needs the tenant's name (confirm, typed by a person) and your product's token: your tenants token for all your tenants, or your operator's CLI; never one tenant's token. -
Checkpoints (where its database's Postgres instance is on a data node's ZFS pool): a snapshot of the instance's disk, taken in seconds without pausing anything, before every deploy of one of the tenant's apps'
prodand whenever you ask (POST /tenants/acme/checkpoints).GET /tenants/acme/checkpointslists the points you can go back to (every one of the last hour, the last of each hour of the last day, the last of each day of the last week), each with what it holds alone;POST /tenants/acme/checkpoints/<id>/restorewith{"confirm": "acme"}(as a dump's restore: the tenant's name, your product's token) brings the tenant's database back to one, after taking a checkpoint of now (undo_dump: restore that to undo it). The instance's other tenants never notice: the database is copied out of a temporary copy of the instance started from the checkpoint, then swapped in by rename (its sessions end once; its apps reconnect). An app environment's own database has its dumps and checkpoints too: add?app=<app>&environment=<env>to each. -
The tenants' CLI does the same with the tenant's token (restoring with your product's,
--tenant acme;shop-ops tenants dumps acme ...from yours):shop dumps # oldest first shop dump # one now; prints a 15-minute download link shop link 2026-10-21 # a download link for one shop checkpoints # newest first shop checkpoint # one now shop restore --from 2026-10-21 # or --from cp-...; type its name; prints the undo shop restore --app blog --env staging --from cp-20261026-101500-ab12 -
GET /tenants/acme/exportis everything it stores, to hand over: its newest dump and its objects, each with a link.shop-ops tenants export acmeprints them all. -
DELETE /tenants/acme?confirm=acmeremoves it and deletes its data for good, in the background: it answers 202 with a deletion; pollGET /tenants/acme/deletions/<id>until it succeeds, itstenantsaying until when copies remain in backups.shop-ops tenants delete acmeasks for its name first. -
DELETE /tenants/acme/apps/blog?confirm=blogremoves one of its apps, its environments' own databases and their dumps with it (itsprod's data is the tenant's database, which stays), in the background: a deletion polled atGET .../apps/blog/deletions/<id>. It needs the app's name (confirm, typed by a person) whichever token asks, the tenant's own included: the tenant decides about its apps, but never by a script's slip. So doesDELETE .../apps/blog/environments/staging?confirm=stagingwhen the environment has a database of its own (itsdatabasefield); one without (a preview that binds none) goes without it.shop apps delete blogandshop environments delete blog stagingask for the name at a terminal; a script without one passes--confirm <name>. -
Rotating its credentials after a leak:
POST /tenants/acme/credentials/rotatewith{"database": true, "bucket": true}(either or both) gives its database roles (its own and its apps' environments' own) new passwords and its bucket a new key (shop-ops credentials rotate --tenant acme --database --bucket). When it answers, the old values work nowhere (sessions opened with them are closed) and its apps run with the new ones, without a deploy: an awake app is replaced at once, finishing what it has in flight. Its token may rotate its own; the answer never holds a value.
Show your customers an acceptable-use policy, with your own abuse contact: Acceptable use is a starting point.