Deploy from CI
Sign-in without stored secrets
CI jobs can trade their own OIDC token for a short-lived xvii token through a trust rule. Nothing is
stored in CI settings, and the token can't be reused outside the job.
Add a trust rule
In id, open the project → CI → Add rule: the issuer (GitLab or GitHub), the repository, branch
patterns (or protected refs only), the scope (deploy is usually right) and the lifetime.
Give the job an OIDC token
GitLab: an id_tokens entry (the templates below do it). GitHub: permissions: id-token: write.
Run the CLI
XVII_DOMAIN selects the instance; the CLI finds the job's token on its own.
A wildcard repository in a rule defeats the project boundary; keep rules narrow. Protected environments
still need the rights their protection asks for.
If OIDC isn't available, create a narrow token (xvii tokens create ci --scope deploy --ttl 30d), store it
as a masked CI variable XVII_TOKEN, and never print it.
GitLab CI
include:
- remote: https://pages.xvii.network/ci/preview.yml
build:
stage: build
image: node:24-alpine
script: [corepack enable, pnpm install --frozen-lockfile, pnpm build]
artifacts: { paths: [dist/] }
variables:
PAGES_DIR: dist
The default branch goes to <project>.pages.xvii.network, other branches to
<project>-<branch>.pages.xvii.network; merging or deleting the branch removes the preview.
include:
- remote: https://apps.xvii.network/ci/gitlab.yml
variables:
APPS_APP: api # made in apps first: xvii apps create api --project shop --port 3000
APPS_PROJECT: shop # when app names repeat
APPS_ENV: production
APPS_DIR: . # a folder with a Dockerfile, or one Railpack knows
The default branch deploys; a merge request deploys a preview <app>-mr-<iid> shown as its environment
(enable merge request pipelines). Closing it stops the preview, and it goes by itself after APPS_TTL
without a deploy. The job needs no Docker: the build runs on the node. Without a trust rule, set a masked
APPS_TOKEN (a token with the deploy scope).
GitHub Actions
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
id-token: write
concurrency:
group: production
cancel-in-progress: false
env:
XVII_DOMAIN: xvii.network
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
curl -fsSL https://xvii.network/install.sh | XVII_INSTALL_DIR="$HOME/.local/bin" sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- run: xvii apps deploy api --project shop --dir . --dotenv apps.env
- run: cat apps.env >> "$GITHUB_STEP_SUMMARY"
--dotenv writes APPS_URL=<address> once the release is live. Ready-made workflows:
https://pages.xvii.network/ci/github.yml and https://apps.xvii.network/ci/github.yml (save as
.github/workflows/apps.yml; set APPS_APP; pull requests get a preview <app>-pr-<number> and a
comment).
Secrets in CI
Read them from env with the same job token, instead of copying them into CI settings:
xvii env run --project shop --env preview -- ./integration-tests.sh
Keep production secrets out of preview environments: previews often run code nobody reviewed yet.
When a deploy fails
The CLI waits for a live release and exits non-zero on a build or health failure, printing the log.
| Symptom |
Look at |
| build failed |
the build log: Dockerfile path, build context, lockfile, network access to registries |
| release stays deploying, then fails |
the health path, startup migrations, the memory limit, the logs |
401 / 403 |
the trust rule's repository and branch, the scope, a protected environment |
| works in CI, 502 in the browser |
the port the app listens on (PORT), the node's state in xvii apps nodes |
Don't cancel a deploy in the middle of a data migration, and remember that rolling an image back doesn't
roll a database schema back.