Deploying
A product runs in environments (prod, staging, previews of pull requests), each described by
scaledbox.toml with its overrides from [env.<environment>]. Each deploy makes an immutable
release. The commands below run in the directory holding scaledbox.toml (or name it with
--spec).
Services
A product's services run on scaledbox's runtime, as its tenants' apps do: each service is a box
of the JavaScript modules your CI builds, with Workers-shaped handlers (fetch, queue,
scheduled, and release for migrations). There is no image, Dockerfile or registry: scaledbox
compiles each module and builds nothing.
[services.web]
dir = "dist/web" # its built modules and box.toml (default: the service's name)
assets = "dist/web/public" # its static files (optional)
health = "/healthz" # asked (2xx) before a release goes live
memory = "256Mi" # each box's (32Mi to 2Gi; default 256Mi)
env = { LOG_LEVEL = "info" } # strings in env
A service's dir holds its box.toml and its modules (ES modules, each module's source map beside
it as <module>.map), as your bundler wrote them:
# dist/web/box.toml
main = "index.js" # the module whose default export has the handlers
hosts = ["api.stripe.com"] # what fetch may reach (https)
cron = ["*/5 * * * *"] # scheduled(), UTC, a minute at the most often
[bindings.DB]
kind = "database" # env.DB, on [postgres.main]
[bindings.JOBS]
kind = "queue" # env.JOBS.send(); queue() receives them
consumer = true
[limits]
cpu_ms = 30000 # a request's CPU (up to 900 s); duration_ms, subrequests, … as tenants' apps
export default {
async fetch(request, env, ctx) { /* ... */ },
async queue(batch, env) { /* ... */ },
async scheduled(event, env) { /* ... */ },
async release(event, env) {
// { environment, release, first, preview }: before the release goes live
await env.DB.query("CREATE TABLE IF NOT EXISTS visits (id serial PRIMARY KEY)");
},
};
Run a service on your machine before deploying it, as a deploy would run it (its release handler and
health path, its variables, local stand-ins for its bindings): scaledbox-ops dev --service web
(Develop locally).
A product's bindings are database, kv, queue, workflow and objects (on the environment's
[postgres.main] database), and bucket (the environment's prefix of its bucket); see the
box.toml reference for each one's API. The environment's variables reach env as
strings: SCALEDBOX_PROJECT, SCALEDBOX_ENVIRONMENT, SCALEDBOX_SERVICE, its secrets and the
service's env.
A queue is the environment's: a service's queue binding names it (the binding's name, lower-cased,
or queue = "jobs"), every service binding it may send to it, and one service consumes it
(consumer = true; two are refused), as a Worker's producer and another's consumer share a queue:
# dist/web/box.toml: the web service hands work over
[bindings.JOBS]
kind = "queue"
# dist/worker/box.toml: the worker does it
[bindings.WORK]
kind = "queue"
queue = "jobs"
consumer = true
A bucket binding's events send its objects' changes (created, deleted) to one of the service's
queue bindings, as a tenant app's do (see Tenants).
Static services on Cloudflare
A static service (its assets, and a box.toml without main, or none) may be served by Cloudflare
instead of the platform's clusters: a homepage, docs, downloads, an app's front end.
[services.site]
assets = "dist/site" # its files
domains = ["example.com"] # required: its names, in zones of the platform's Cloudflare account
provider = "cloudflare" # default: the platform's clusters
scaledbox deploy uploads its files to the platform's API as it does any service's (by SHA-256, only
those the platform lacks) and never reaches Cloudflare itself: it holds no Cloudflare credentials. Once
the release is live, the platform's server serves it from Cloudflare with the platform's own token:
each environment's service is an assets-only Worker named <app>-<service>-<environment>, served at
its domains as Workers Custom Domains (Cloudflare makes their records and certificates), its files
uploaded there only when Cloudflare lacks them (the same files again change nothing). The deploy waits
for that and prints what changed at Cloudflare, or fails saying why (the release stays live; the
server tries again every 5 minutes). The server keeps it so: a Worker or a name changed by hand in
Cloudflare's dashboard is put back, and a service no longer declared (or its environment deleted) has
its Worker and names removed, last (they hold no data: its files are in the release). It touches only
Workers it made (tagged with the app, the environment and the service), and refuses a name another
Worker serves. A name with a DNS record of its own (e.g. served by the clusters until now) can't be a
Custom Domain: move such a service in two deploys (without the name on the clusters, then on
Cloudflare once its record is gone).
The platform needs its Cloudflare token for this, set once by its operator: CLOUDFLARE_WORKERS_TOKEN
(Workers Scripts: Edit on the account; Zone: Read, DNS: Read and Workers Routes: Edit on the services'
zones) and CLOUDFLARE_ACCOUNT_ID in the platform's .env, then scaledbox-ops sync, which gives
them to its servers. Without them, a deploy declaring a service on Cloudflare is refused, saying so.
A static platform
A platform whose services are all static (a homepage, docs, downloads) needs no server: declare it static, and its deploy applies them at Cloudflare itself, from CI, with a Cloudflare token of its own (CI):
[app]
name = "site"
mode = "static" # no server, no cluster: every service on Cloudflare
[services.home]
assets = "dist/home"
domains = ["example.com"]
provider = "cloudflare"
scaledbox deploy then reads CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID (default: the account
of its first domain's zone), prints its plan, and applies it with the same plan, apply and prune as a
platform's server, reaching no scaledbox API. A static platform has only services with provider = "cloudflare", and no [postgres], [bucket], [release], [previews], [status], [tenants],
[ai] or [notify]; a platform's server refuses a static platform's deploy, and a platform with a
server is never applied from the CLI. To remove the last service, deploy with none (and
CLOUDFLARE_ACCOUNT_ID set).
A static platform's service may have one small Worker script for paths that need a key (downloads behind a key, for one), run first on those paths only; every other path is still served as a file:
[services.home]
assets = "dist/home"
domains = ["example.com"]
provider = "cloudflare"
script = "worker/gate.mjs" # a JavaScript module, outside its assets, at most 1 MiB
script_paths = ["/v2", "/v2/*"] # Workers' run_worker_first rules (!/... excludes one)
The deploy uploads the module with the files. It reaches them through its ASSETS binding
(env.ASSETS.fetch(request)) and the Worker's secrets through env; set those at Cloudflare (wrangler secret put): every deploy keeps them. Requests that run the script count against Workers' free plan's
100,000 a day. Any other mode refuses script: a platform with a server runs code as boxes.
Requests to static assets are free and unlimited on Workers' free plan; a file is at most 25 MiB and
a site at most 20,000 files (checked when the deploy is sent). Only static files: a service with
main runs on the clusters, and health, replicas, memory and env are refused, as is a different
provider per environment (its domains may differ). Its box.toml's [assets] not_found_handling (404-page, the default, or single-page-application) is the Worker's. Previews
deploy the clusters' services only.
Releases
scaledbox-ops deploy --env prod
scaledbox-ops releases --env prod
scaledbox-ops rollback --env prod # the release that was live before; or --to <number>
scaledbox-ops deploy sends scaledbox.toml and each service's files by their SHA-256: only those
scaledbox lacks are uploaded (an unchanged module or asset costs nothing), and each new module is
compiled. A deploy then:
- runs the
[release]service'srelease()handler in a box of the new release (e.g. migrations): if it fails, nothing changes; - wakes a box of each service's new release and asks its
healthpath (2xx); - moves each service to the new release: new requests go to its new boxes, the old ones finish theirs. If anything before fails, the previous release keeps running and the error says why.
A rollback deploys an earlier release's spec as a new release, like any deploy: its release handler
runs again (event.release is the new release's number), then the health checks. So write the handler
to be run again safely: migrations that skip what is already applied, as migration tools do. A
rollback doesn't undo a migration: a down migration is yours to run. It keeps the current secrets,
so a rotated key stays rotated. --no-wait returns once the release is recorded. (When a deploy fails
and the previous release is put back by itself, its release handler doesn't run again: it ran when
that release went live.)
Instances
A service's boxes are a capacity: the fewest and the most it runs.
[services.web]
replicas = 2 # fixed: always 2 boxes awake
[services.api]
min_replicas = 1 # 1 kept awake, up to 4 as it gets busy
max_replicas = 4
[services.site]
min_replicas = 0 # sleeps when idle, wakes on its next request
max_replicas = 2
domains = ["shop.example"]
[services.worker] # no domains: a worker, kept awake for its queue() and scheduled()
replicas = 1
Its fewest boxes are kept awake (one that dies is started again within seconds). A request goes to
another box, up to its most, once each awake one has 20 requests in flight; queue deliveries and cron
runs are spread the same way; a box beyond its fewest sleeps once idle. At min_replicas = 0 a
service sleeps when idle and its next request wakes it in milliseconds. Memory is reserved on the fleet at
max_replicas (see Spending), so a change within it admits nothing more.
Health checks
A service's health path is asked of each of its awake boxes every 10 seconds, as a client's GET
at its first domain (2xx within 5 seconds). A box failing 3 checks in a row is replaced: new requests
go to a new box at once, and the old one finishes what it has in flight. A sleeping box is never woken
to be checked, nor kept awake by it: a preview sleeps as before, and its uptime check is its
certificate alone.
Once none of a service's boxes has passed for 3 checks in a row (about 30 seconds), the service is
failing: your product's webhook gets service.unhealthy, {type, timestamp, data} with the
environment, the service, its status (failing), why (reason, e.g. its health path answered 500, its health path timed out after 5s) and since; once one passes again, service.recovered
with how long it lasted (seconds). Both also go to the environment's [notify] chat, if it has
one. GET /v1/health lists each environment's failing services with why and since
when, and your status page shows the service's names down.
Deploy notifications
[notify]
chat = "DEPLOYS_SLACK" # the secret holding a Slack incoming webhook's URL
webhook = true # also to your product's webhook
Each deploy of an environment is announced when it starts, when it is live, when it fails, and when
it failed and the previous release runs again. chat names a secret of each environment announced
(the URL is a credential, so it is never in the file): a Slack incoming webhook's URL, a Discord
webhook's or an ntfy topic's.
scaledbox-ops secrets set --env prod DEPLOYS_SLACK # https://hooks.slack.com/services/...
A deploy of an environment without that secret is refused, saying so. webhook = true sends the same
to your product's webhook (PUT /v1/webhook) as deploy.started,
deploy.succeeded, deploy.failed and deploy.rolled_back events, each {type, timestamp, data}
with the environment, the release, its description, who made it, the message, and for a rollback the
release running again (data.running): signed as Standard Webhooks
like your product's other events. Both are retried for a day while they don't answer. Previews aren't
announced: their pull requests' checks are.
Secrets
Secrets belong to an environment, not to a release, and never go in the repository or on a command
line. They are encrypted before they are stored, and the services get them as strings in env.
scaledbox-ops secrets set --env prod STRIPE_KEY # asks for the value, without echo
op read op://prod/stripe | scaledbox-ops secrets set --env prod STRIPE_KEY # or from stdin
scaledbox-ops secrets set --env staging --from-file .env.staging
scaledbox-ops secrets list --env prod # names only
scaledbox-ops secrets unset --env prod OLD_TOKEN
A change rolls out as a new release with the same checks as a deploy (its release handler runs again);
--stage only stores it, for the next deploy.
Logs
scaledbox-ops logs --env prod web --since 30m -q timeout
prints an environment's newest log lines, oldest first: its services' boxes' lines (console.log and
the like, each request's, the runtime's), each with its service, level and release, a thrown error's
stack at your source (by the release's source maps), kept 14 days. Without a service, all of them;
-q keeps only lines with a phrase; --limit (up to 1000) the newest lines.
Environments
The first deploy to an environment creates it. scaledbox-ops environments list shows them;
scaledbox-ops environments delete --env <environment> deletes one with everything it runs and holds
(databases, bucket, releases, secrets): an admin of the product must type its name.
It runs in the background and the command waits for it; through the API, DELETE /v1/environments/<environment> answers 202 with a deletion to poll at
.../environments/<environment>/deletions/<id>.
Previews
With [previews] in scaledbox.toml, a pull request's workflow deploys a preview:
[previews]
service = "web"
domain = "shop.example" # one of your own, a zone's apex: previews answer at pr-<number>.shop.example
branch_from = "staging" # or: its databases are copies of staging's
scaledbox-ops deploy --env pr-42 --preview
A preview gets databases of its own and its own bucket. They start empty (its release handler's
first run, event.preview and event.first true, may fill them), or, with
branch_from, as copies of that environment's databases as they are at the preview's first deploy: on
a data node's ZFS pool each is cloned in a fraction of a second whatever its size (the source's
sessions end for about 0.1 s; its apps' pools reconnect), so the pull request's migrations run against
real data and never touch the source's. A database the source lacks, or one on an instance not yet on
a pool, starts empty. Its DNS needs *.shop.example (or each preview's
name) pointed at your product's target (see Domains). Its services sleep when idle (at
most one box each) and wake on their next request or message. scaledbox-ops environments delete --env pr-42 removes it when the pull request
closes; a deploy token may do that. Previews without a deploy for a week go by themselves.
Databases
[postgres.<name>] gives every environment its own database and role on your product's own Postgres
instance (Postgres 18), with point-in-time recovery from continuous backups. main is the one the
services' bindings reach (database, and kv, queue, workflow, objects, kept in it by
scaledbox), through the instance's connection pooler; others are for your tools (dumps, psql).
Removing one from the file never drops it.
The instance keeps the major version it was made with: nothing upgrades it by itself. A new major
version is an upgrade the operator plans and starts (as Render's and Fly's): a fresh backup first,
then Postgres upgraded in place (pg_upgrade), your databases unreachable for that while (usually under
a minute; deploys wait), then checked and backed up again. Point-in-time recovery to before the upgrade
stays possible for the backups' retention (14 days).
The instance's disk holds what you declare: every environment's databases at their size (previews'
too), and a quarter more for Postgres's write-ahead log, at least 5Gi. A deploy that declares more
grows it first. On a data server with a ZFS pool (where the platform puts instances) the same disk
grows in place, in seconds. An instance still on a server without one moves first: a copy of it on
the new disk, then a switchover to it (a few seconds of reconnects; your services keep running). A
large database takes a while to copy: after 30 minutes the deploy fails without applying anything of
the release, the copy goes on, and the next deploy finishes the move. If there is no room for it, the
deploy is refused until the operator adds disk. A disk never shrinks.
Every database is also dumped nightly (pg_dump, kept 14 days):
scaledbox-ops db dumps --env prod main # oldest first
scaledbox-ops db dump --env prod main # dump it now; prints a 15-minute download link
scaledbox-ops db link --env prod main 2026-10-21 # a download link for one
scaledbox-ops db restore --env prod main --from 2026-10-21 # type its name; prints the undo dump
A restore dumps the database first, so it can be undone, and runs in one transaction: a failure
changes nothing. It replaces the database's data, so its name is typed to confirm (the API's
confirm), and it takes you (signed in) or the operator: a deploy token, CI's, may dump and download
but never restore. The console's environment page does the same.
The instance's own backups (its write-ahead log archived continuously, a base backup daily, kept 14 days) are what point-in-time recovery uses. See that they work, and since when you can go back:
scaledbox-ops db backups # each instance: WAL archiving, the earliest recovery point, its base backups
Only the operator recovers a whole instance from them; one database goes back by its dumps.
Object storage
[bucket] gives each environment its own prefix of your product's bucket, which a service's
bucket binding reaches (R2's API: get, put, list, delete, multipart uploads, presigned
URLs). A download or upload by a presigned URL ends the moment its URL does (so give a URL time for
its transfer: minutes, not seconds); public = true makes its objects readable by anyone. Objects are
at most max_object (default "25M", at most "100M", 100,000,000 bytes): a larger upload, part or
multipart total is refused with 400 EntityTooLarge. The bucket is remote object storage: an
occasional request takes seconds, so don't call it on every request.
Rotating credentials
Your services never see a database password or a bucket key in your repository: scaledbox gives them when they run. If one leaks (a log, a screenshot), rotate it:
scaledbox-ops credentials rotate --env prod --database # every [postgres.<name>]'s password
scaledbox-ops credentials rotate --env prod --bucket # the bucket's key
scaledbox-ops credentials rotate --env prod --database --bucket # both
When it returns, the old value works nowhere: logins with it are refused, and sessions opened with it
(through the pooler too) are closed. Your services get the new one without a deploy: a service's
running instances are replaced at once, finishing what they have in flight, and reconnect. Only the
environment named changes; while a deploy of it runs, a rotation is refused (rotate once it is done). A deploy token rotates its own environment's; the API is POST /v1/environments/<env>/credentials/rotate with {"database": true, "bucket": true}. The answer says which credentials changed, never their values.
Third-party services
Keep a list of the services your product depends on, with their accounts and renewal dates; scaledbox is alerted 30 days before a renewal date. Never a secret value: keys are secrets of an environment.
scaledbox-ops inventory set stripe --kind payments --account billing@shop.example --renews-on 2027-03-01
scaledbox-ops inventory list
Removing a product
A product is removed by scaledbox's operator, on your request: everything it runs and stores goes,
its tenants with it, their data included. Export what you want to keep first: your databases' dumps
(scaledbox-ops db dumps, scaledbox-ops db link), your tenants' data (scaledbox-ops tenants export). From the
moment its removal begins nothing new is admitted for it: deploys, new tenants and changes are refused
(409). Its environments and its tenants are deleted as their own deletions are, then its Postgres
instances, its bucket's objects, its tokens (deploy and tenants tokens stop working) and its records;
its names stop being served (DNS records in your own zones are yours to remove).
What stays: its ledger and its audit trail; its name, which no product gets again; and copies of its data in backups, for a while, so a removal by mistake can be undone by the operator: its databases in its Postgres instances' backups for 14 days, everything in the offsite copy for 30 days after it left the bucket and the backups. Nobody restores them but the operator, and only whole.