Deploying from CI
CI deploys with a deploy token: scoped to one project (optionally one environment), expiring, revocable, audited. It may deploy and read that project, nothing else: no secrets, no tokens, no other projects. Any CI works the same way; GitHub Actions is the example below.
1. Create a deploy token
scaledbox-ops tokens create ci-prod --env prod # --days 90 by default; without --env: every environment
The token (sbx_…) is shown once. Store it as a CI secret, e.g. SCALEDBOX_DEPLOY_TOKEN in the
repository's GitHub Actions secrets. Rotate it before it expires: create a new one, update the secret,
then revoke the old one (scaledbox-ops tokens list, scaledbox-ops tokens revoke <id>).
2. Build your services' modules
Each service's dir (in scaledbox.toml) must hold its box.toml and its built JavaScript modules
(ES modules; TypeScript and JSX are bundled by you), each module's source map beside it, and its
assets directory its static files. Any bundler does: esbuild, for example.
- uses: actions/setup-node@<pinned>
with:
node-version: 24
- run: npm ci
- run: npx esbuild src/index.ts --bundle --format=esm --platform=neutral --sourcemap --outfile=dist/web/index.js
- run: cp box.toml dist/web/
Nothing is built on scaledbox and no image is pushed: the deploy sends the files.
3. Deploy in the workflow
Deploying is the product owner's, on the operator's CLI (scaledbox-ops, built from the module, never
distributed; the tenants' CLI, scaledbox, has no product commands). With SCALEDBOX_API_URL and
SCALEDBOX_TOKEN set it calls the public API with the token instead of going through the control
node, so a workflow deploys with the deploy token. It sends scaledbox.toml and each service's files
by their SHA-256, uploads only those scaledbox lacks, and waits for the release:
- uses: actions/setup-go@<pinned>
- name: Deploy to scaledbox
env:
SCALEDBOX_API_URL: https://api.scaledbox.dev
SCALEDBOX_TOKEN: ${{ secrets.SCALEDBOX_DEPLOY_TOKEN }}
run: go run github.com/scaledbox/scaledbox/cmd/scaledbox-ops@<version> deploy --env prod
A failed release leaves the previous one running; the step fails with the reason (e.g. the release handler's error, or a health path that didn't answer).
Services on Cloudflare (static services) are deployed by the same step and the same token: their files go to the platform, whose server serves them from Cloudflare with the platform's own Cloudflare token; the step waits for that too. CI holds no Cloudflare credentials.
A static platform
A static platform (mode = "static" in [app]: no server and no cluster, every service static on
Cloudflare; see static platforms) has no API to post to: the same
command applies its services at Cloudflare itself, from the workflow, with a Cloudflare token of the
site's own, scoped as narrowly as it can be (Workers Scripts: Edit on the account; Zone: Read, DNS: Read
and Workers Routes: Edit on the site's zones), and no SCALEDBOX_TOKEN:
- name: Deploy to Cloudflare
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_SITE_TOKEN }} # this site's only
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
run: go run github.com/scaledbox/scaledbox/cmd/scaledbox-ops@<version> deploy --env prod
It prints its plan, uploads only the files Cloudflare lacks, and removes what the environment no longer declares, last. Keep the token in the repository's secrets, never in the workflow file.
Without the CLI, the API takes the same three steps: POST /v1/environments/<env>/deploys with scaledbox.toml and each service's files'
hashes (it answers the missing ones, in buckets), POST .../deploys/<id>/files with each bucket
(multipart, a part per file named by its hash), then POST .../deploys/<id>/finish, which answers
the release to poll at GET .../releases/<number> until it is live or failed.
A pull request's workflow deploys its preview the same way (--env pr-<number> --preview) and deletes
it when the pull request closes (scaledbox-ops environments delete --env pr-<number>).