Zaroz
Deploy from CI/CD

Deploy from CI with an API token

Every guide in this section does the same four things, in any CI system:

  1. build an image and push it to a registry, with one immutable tag per commit;
  2. tell Zaroz to run that image (PUT /service-instances/{id}/source);
  3. start a deployment and wait for it (POST /service-instances/{id}/deployments);
  4. fail the job if the rollout failed, and put the previous image back.

Steps 2 to 4 are one small script, zaroz-deploy.sh. The GitHub, GitLab and Forgejo guides only differ in how they build, where the secrets live and how they stop two deploys running at once.

CI systemGuide
GitHub ActionsGitHub Actions
GitLab CIGitLab CI
Forgejo Actions and Gitea ActionsForgejo and Gitea Actions
anything else (Jenkins, Drone, a cron on your server)Generic shell

Create a token that can only deploy

CI should never hold a token that can read your invoices or delete your account. Give it a token limited to one service instance.

services.deploy is the deploy permission. It allows exactly two writes on a service: PUT /service-instances/{id}/source and POST /service-instances/{id}/deployments. It does not read anything, change environment variables, backups, files or the firewall, start, stop or restart the service (services.power), open a terminal, or touch billing or members. Add services.read so the job can read the source and wait for the deployment to finish. Which token to use depends on how narrow you need it:

  • A personal token (POST /me/api-tokens, below) can be limited to one service instance. It acts as the person who created it and needs a full signed-in session to mint: an API token cannot mint another.
  • An organization token (POST /organizations/{id}/api-tokens) belongs to the organization and can outlive its creator ("lifetime": "organization_owned"), but every permission in it is scoped to the whole organization, never to one service. GET /organizations/{id}/api-tokens/grantable-capabilities lists what you may put in it. Use it for a team pipeline that deploys several services.

curl

TerminalCode
# 1. what your own account may grant # (the header goes through curl's config on stdin so it never shows in `ps`) zcurl() { printf 'header = "Authorization: Bearer %s"\n' "$ZAROZ_TOKEN" | curl -sS -K - "$@"; } zcurl "$ZAROZ_API/me/api-token-capabilities" # 2. a token that can only deploy ONE service instance zcurl -X POST "$ZAROZ_API/me/api-tokens" -H "Content-Type: application/json" -d '{ "name": "ci-deploy-my-api", "expires_at": "2027-01-01T00:00:00Z", "authorization": {"grants": [ {"capability": "services.deploy", "scope": {"kind": "resource", "resource_type": "service_instance", "resource_id": "<service instance uuid>"}}, {"capability": "services.read", "scope": {"kind": "resource", "resource_type": "service_instance", "resource_id": "<service instance uuid>"}} ]} }' # {"token": {...}, "secret": "zaroz_..."} the secret is shown this once

TypeScript

TypeScriptCode
const res = await fetch(`${API}/me/api-tokens`, { method: "POST", headers: { Authorization: `Bearer ${ADMIN_JWT}`, "Content-Type": "application/json" }, body: JSON.stringify({ name: "ci-deploy-my-api", expires_at: "2027-01-01T00:00:00Z", authorization: { grants: ["services.deploy", "services.read"].map((capability) => ({ capability, scope: { kind: "resource", resource_type: "service_instance", resource_id: SERVICE }, })), }, }), }); console.log((await res.json()).secret);

Python

Code
r = requests.post( f"{API}/me/api-tokens", headers={"Authorization": "Bearer " + ADMIN_JWT}, json={ "name": "ci-deploy-my-api", "expires_at": "2027-01-01T00:00:00Z", "authorization": {"grants": [ {"capability": c, "scope": {"kind": "resource", "resource_type": "service_instance", "resource_id": SERVICE}} for c in ("services.deploy", "services.read") ]}, }, timeout=20, ) print(r.json()["secret"])
  • resource_id is the service instance id, the same UUID that is in the Workload's URL in the console.
  • Give the token an expires_at and put a rotation reminder on it. A personal token is revoked with DELETE /me/api-tokens/{token_id}.
  • The secret (zaroz_...) is shown once. Store it straight into your CI's secret store. Never put it in the repository, in .env files that are committed, or in the image.
  • Keep the secret out of command-line arguments, which any user on a shared runner can read with ps. Pass it as an environment variable from the CI secret store, as the workflows do; the script sends it to curl through standard input.
  • Mint one token per service and per environment (staging, production), so that revoking one does not stop the others.

A personal token acts as the person who created it: if that person leaves the organization or loses access to the service, the token stops working. For a team, prefer an organization token with lifetime: "organization_owned" (it survives whoever created it, but covers every service of the organization):

TerminalCode
zcurl -X POST "$ZAROZ_API/organizations/<organization uuid>/api-tokens" -H "Content-Type: application/json" -d '{ "name": "ci-deploy", "lifetime": "organization_owned", "expires_at": "2027-01-01T00:00:00Z", "capabilities": ["services.deploy", "services.read"] }' # {"token": {...}, "secret": "zaroz_pat_..."} shown once

Minting needs an Owner or Admin membership. Omit lifetime and the token is revoked when your membership ends.

What the token does at each step

StepRequestPermission
Read what runs nowGET /service-instances/{id}/sourceservices.read
Choose the imagePUT /service-instances/{id}/sourceservices.deploy
DeployPOST /service-instances/{id}/deploymentsservices.deploy
Wait and checkGET /service-instances/{id}/deploymentsservices.read

PUT /source does not deploy: it only saves what the next deployment will pull. POST /deployments takes no body and deploys what is saved, so there is no way to deploy something that was not saved first.

Rollout, success and rollback

A deployment has a status (how the operation ended) and an outcome (succeeded or failed, with a failure_reason such as cluster_rejected or subscription_not_active). succeeded means the rollout was applied; it does not say the new pods are healthy. So the script also reads workloads[].status from the same list and requires every workload to be running.

Rollback is a deploy of the previous image. The script reads running_image before changing anything and, if the new rollout fails, saves that image again and deploys it. Set ROLLBACK=0 to turn this off. A deploy is refused with 409 while a restore, a resize or another deployment holds the service, and the response says which operation and since when.

Zaroz does not watch a registry for a moved tag (watch_tag is always false), so a pipeline that pushes latest and deploys nothing will never update. Use a unique tag per commit, as the guides do.

Private images

If the image is private, add a pull-only registry token and pass it as REGISTRY_USERNAME and REGISTRY_PASSWORD. Zaroz stores it, never returns it, and uses it to pull from inside the cluster. It must not be the token your CI uses to push.

Example repositories

Each example has a ready deploy workflow for all three CI systems and runs end to end against a project of your own.

RepositoryWhat it shows
example-scaled-apian HTTP API on 1 to 8 autoscaled units behind one endpoint
example-queue-workerworkers that scale with a queue
example-game-networkhub, lobbies, duels and plots (Minecraft), phase 2
example-job-runnerone unit per job, started by key, result by event

They live in the zaroz-oss organization at https://git.zaroz.cloud/zaroz-oss/.

Last modified on