---
title: Deploy from CI
description: Deploy on every push from GitHub Actions or GitLab CI, with a token that can deploy one app and nothing else.
---

Tori does not watch your repository. Your CI does: on a push to the branch you deploy from, a job runs `npx tori.host deploy` with a token, the same command you run locally. This page sets that up for GitHub Actions and GitLab CI.

## Before the first pipeline

1. **Deploy once from your machine.** The first deploy creates the app and writes `tori.json` with its slug. A deploy token cannot create an app, so the pipeline needs one that already exists.
2. **Commit `tori.json`.** It is how the pipeline knows which app to deploy. Every later deploy from that directory goes to the same app.
3. **Mint a deploy token.** In the console, **Settings → API tokens → + New token**. Choose **Deploy one app** and pick the app. Choose an expiry if you want the secret to stop working on its own; the default is never. Copy the secret: it starts with `torid_` and is shown once.

Through the API, a deploy token can deploy its app, read the app and its deploys, follow a deploy and read its build log, cancel a deploy and roll back. It cannot delete anything, read or set environment variables, touch the database, domains or runtime logs, reach another app, or mint another token. Those requests answer `403` with the code `token_scope`. Billing and sign-in requests answer `401`, as they would for a token they do not know. A token for the **Whole account** can do all of that. Keep it for MCP and your own scripts, not for a CI secret that every maintainer of the pipeline can read.

What a deploy token limits is the reach, not the trust. Whatever it deploys runs as that app, with that app's environment variables and database credentials. So whoever holds the token can deploy code that reads those secrets, or replace the site. Guard it like the app's own secrets. What a leak cannot do is touch your other apps, delete anything, or outlive a revoke.

## GitHub Actions

Store the secret as a repository secret named `TORI_TOKEN` (**Settings → Secrets and variables → Actions → New repository secret**). Then add `.github/workflows/tori.yml`:

```yaml
name: Deploy to Tori

on:
  push:
    branches: [main]

# One deploy at a time: Tori refuses a second deploy of an app while one is
# in progress (HTTP 409), so a push that lands mid-deploy waits its turn.
concurrency:
  group: tori-deploy
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm run build
      - run: npx tori.host deploy ./dist
        env:
          TORI_TOKEN: ${{ secrets.TORI_TOKEN }}
```

Replace `./dist` with the directory your build writes. Change `main` if you deploy from another branch.

## GitLab CI

Store the secret as a CI/CD variable named `TORI_TOKEN` (**Settings → CI/CD → Variables**), with **Masked** and **Protected** set, so it is hidden in job logs and only given to protected branches. Then add to `.gitlab-ci.yml`:

```yaml
deploy:
  stage: deploy
  image: node:22
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  # One deploy at a time, for the same reason as above.
  resource_group: tori
  script:
    - npm ci
    - npm run build
    - npx tori.host deploy ./dist
```

GitLab passes the variable to the job's environment, where the CLI reads `TORI_TOKEN` from.

## A service instead of a static site

For an app that runs as a container service, the source is built on Tori, not in your pipeline. Drop the build steps and deploy the directory that holds `tori.json`:

```yaml
      - run: npx tori.host deploy
        env:
          TORI_TOKEN: ${{ secrets.TORI_TOKEN }}
```

The same applies to GitLab: `script: [npx tori.host deploy]`. How Tori decides what to build is in [Deploy a container service](/guides/service-deploy/).

## What the job prints, and when it fails

The CLI sees that stderr is not a terminal and prints each phase as a line instead of a live status line. It waits until the deploy is live, and exits non-zero if the deploy fails, so the pipeline goes red with it. For deploy identifiers and phase timings in the log, set `TORI_VERBOSE=1`; see [what the CLI prints](/guides/deploys/#what-the-cli-prints).

| The job says | What it means |
| --- | --- |
| `the API refused TORI_TOKEN (401)` on `tori deploy` | The token was revoked, has expired, or was copied wrong. Mint a new one and replace the secret. A deploy token also gets 401 from billing and sign-in requests; that one says nothing about the token. |
| `This token may only deploy <app> and read its deploys.` | The token is a deploy token for another app than the one in `tori.json`, or the step does something a deploy token cannot. |
| `no API token found, and this looks like CI` | `TORI_TOKEN` is not set in the step that runs the CLI. |
| `another deploy of <app> is already in progress` | The server refused this deploy with HTTP 409 before the upload. Serialise the jobs as above, or wait. |

## Rotating and revoking

Revoke a token in **Settings → API tokens**; it stops working at once. To rotate one, mint the new token, replace the CI secret, run the pipeline, then revoke the old one. A deploy token also stops working when its app is deleted. The list shows when each token was last used, which is how you tell a forgotten pipeline's token from a live one.

## Related

- [Sign in](/getting-started/#sign-in) for the other ways the CLI finds a token.
- [Deploy status and recovery](/guides/deploys/) to inspect, watch, cancel or roll back a deploy from the same token.
- [API](/api/) for the HTTP side: `POST /api/tokens` mints a deploy token with `scope: "deploy"` and `appSlug`.

Download this page as [Markdown](/downloads/markdown/guides/ci.md).
