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:

  1. runs the [release] service's release() handler in a box of the new release (e.g. migrations): if it fails, nothing changes;
  2. wakes a box of each service's new release and asks its health path (2xx);
  3. 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