# 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`](/guides/ci/shell). 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 system | Guide |
|---|---|
| GitHub Actions | [GitHub Actions](/guides/ci/github-actions) |
| GitLab CI | [GitLab CI](/guides/ci/gitlab-ci) |
| Forgejo Actions and Gitea Actions | [Forgejo and Gitea Actions](/guides/ci/forgejo-actions) |
| anything else (Jenkins, Drone, a cron on your server) | [Generic shell](/guides/ci/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**.

:::note
**`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**

```bash
# 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**

```ts
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**

```python
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):

```bash
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

| Step | Request | Permission |
|---|---|---|
| Read what runs now | `GET /service-instances/{id}/source` | `services.read` |
| Choose the image | `PUT /service-instances/{id}/source` | `services.deploy` |
| Deploy | `POST /service-instances/{id}/deployments` | `services.deploy` |
| Wait and check | `GET /service-instances/{id}/deployments` | `services.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.

| Repository | What it shows |
|---|---|
| `example-scaled-api` | an HTTP API on 1 to 8 autoscaled units behind one endpoint |
| `example-queue-worker` | workers that scale with a queue |
| `example-game-network` | hub, lobbies, duels and plots (Minecraft), phase 2 |
| `example-job-runner` | one unit per job, started by key, result by event |

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