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>).