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 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).
Releases
scaledbox deploy --env prod
scaledbox releases --env prod
scaledbox rollback --env prod # the release that was live before; or --to <number>
scaledbox 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 (its release handler doesn't run
again). It keeps the current secrets, so a rotated key stays rotated. --no-wait returns once the
release is recorded.
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 billed 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/projects/{project}/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 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/projects/{project}/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 secrets set --env prod STRIPE_KEY # asks for the value, without echo
op read op://prod/stripe | scaledbox secrets set --env prod STRIPE_KEY # or from stdin
scaledbox secrets set --env staging --from-file .env.staging
scaledbox secrets list --env prod # names only
scaledbox 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 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 environments list shows them;
scaledbox 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/projects/<project>/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 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 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 db dumps --env prod main # oldest first
scaledbox db dump --env prod main # dump it now; prints a 15-minute download link
scaledbox db link --env prod main 2026-10-21 # a download link for one
scaledbox 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. The console's environment page does the same.
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 credentials rotate --env prod --database # every [postgres.<name>]'s password
scaledbox credentials rotate --env prod --bucket # the bucket's key
scaledbox 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/projects/<product>/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 inventory set stripe --kind payments --account [email protected] --renews-on 2027-03-01
scaledbox inventory list