Docs

Deploy

Build a release from local Zitadel configuration and deploy it.

.zitadel/schemas/*.json, .zitadel/flows/*.json and .zitadel/branding/ are the source of truth for your project's user schemas, login flows and branding. Edit them, then publish with deploy: it builds the files into one immutable release and makes that release live on your project's origins.

npx @zitadel/cli@alpha deploy

deploy validates the configuration, shows a git diff of .zitadel/ against the release that is live now, creates the release, and deploys it to every primary origin. --dry-run stops after the diff and writes nothing:

npx @zitadel/cli@alpha deploy --dry-run

A release carries a content digest, so re-running deploy on unchanged configuration returns the same release and changes nothing.

Walkthrough: add a field to registration

Add an optional company property to .zitadel/schemas/default-human-user.json:

"properties": {
  "company": { "type": "string", "description": "Company name" }
}

(Leave required untouched to keep the field optional.)

Then let users enter it during registration — add "company" to the register step's fields in .zitadel/flows/default-login.json.

Preview, then publish:

npx @zitadel/cli@alpha deploy --dry-run
npx @zitadel/cli@alpha deploy

Walkthrough: a second flow, selected by name

Every file in .zitadel/flows/ goes into the release, but the widget runs exactly one flow per sign-in — an extra flow needs a distinct name so the app can ask for it.

Drop .zitadel/flows/kiosk-login.json next to the default flow. Same shape as default-login.json (start from a copy), with its own name and whatever steps the journey needs:

{
  "$schema": "../meta/flow-definition.json",
  "name": "kiosk-login",
  "status": "active",
  "user_schema": "human-user",
  "purposes": { "login": "identifier" },
  "steps": [ "…copied from default-login.json, then edited…" ]
}

Deploy it, then point the widget at it:

<ZitadelLogin flowName="kiosk-login" />

(or flow-name="kiosk-login" on the plain <zitadel-login> element). An unknown name or a flow that doesn't serve the requested purpose surfaces as a startup error in the widget.

Without flowName, the platform serves the newest active flow that has no audience. A flow scoped via audience.app_ids / audience.team_ids is selected only when the flow-start request's hints identify that app or team (app match beats team match) — it never captures the project default.

Releases, deployments and rollback

  • A release is an immutable snapshot of .zitadel/, identified by a rel_ id and a content digest. Build one without deploying it with release create, and stop a release from ever being served again with release revoke.
  • A deployment makes a release live on a set of origins. deploy creates one to every primary origin; deployment create --release <id> deploys an existing release. Primary origins are never named one by one.
  • deployment list shows the history (newest first), deployment get <id> reads one, and deployment rollback re-applies an earlier deployment — bare, it undoes the last deploy. It prints the revisions the restored release pins, including the schema revision new registrations use.
  • A rollback restores configuration, not user data. Users keep the schema revision and attributes they have, including users you migrated to a newer revision.

Add the origins a project serves with origin add before the first deploy; deploy needs at least one primary origin.

Messages

deploy -m and preview -m record a short summary on the release and on the deployment. Without -m, both use the title of the pull request on a GitHub pull request build, Vercel's commit message on a Vercel build, and otherwise the subject of the current git commit when there are no uncommitted changes. With none of these, a non-interactive deploy fails without -m. Record why a rollback happened with deployment rollback -m.

A release keeps the message of the run that first built its content. When a pull request's preview built it, the deploy after merge reuses that release, and its message is recorded on the deployment only.

Deploy from GitHub Actions

Run preview on pull requests and deploy on pushes to the main branch:

# .github/workflows/zitadel-preview.yml, on: pull_request
- uses: actions/checkout@v4
# Your hosting deploy, reporting the preview URL as an output named url.
- id: host
  run: echo "url=https://your-preview.example.com" >> "$GITHUB_OUTPUT"
- run: npx @zitadel/cli@alpha preview --origin "$PREVIEW_URL" --non-interactive --json
  env:
    ZITADEL_ENV: preview
    ZITADEL_URL: ${{ vars.ZITADEL_URL }}
    ZITADEL_PROJECT_ID: ${{ vars.ZITADEL_PROJECT_ID }}
    ZITADEL_PROJECT_SECRET: ${{ secrets.ZITADEL_PROJECT_SECRET }}
    PREVIEW_URL: ${{ steps.host.outputs.url }}

# .github/workflows/zitadel-deploy.yml, on: push to main
- uses: actions/checkout@v4
  with:
    fetch-depth: 0
- run: npx @zitadel/cli@alpha deploy --non-interactive --json
  env:
    ZITADEL_ENV: production
    ZITADEL_URL: ${{ vars.ZITADEL_URL }}
    ZITADEL_PROJECT_ID: ${{ vars.ZITADEL_PROJECT_ID }}
    ZITADEL_PROJECT_SECRET: ${{ secrets.ZITADEL_PROJECT_SECRET }}
  • GitHub Actions sets no environment name, so preview needs ZITADEL_ENV=preview. Without it, the preview is skipped.
  • preview detects preview URLs only on Vercel and Netlify. Pass --origin with the URL your hosting step reports.
  • fetch-depth: 0 keeps the history deploy diffs against. With the default shallow checkout, the diff is not shown.
  • When a pull request's preview built the release, the release records GitHub's merge commit, which is not in the main branch's history, so the deploy after merge shows no diff either.
  • To pass your own message, put it in env: and use -m "$MESSAGE". Never write ${{ github.event.head_commit.message }} into run:: the shell would run whatever the commit message contains.

How publishing works

  • Flows are validated before the release is built against the same rules the platform enforces — transition wiring, step reachability, schema field references, and cross-purpose invariants (e.g. a login entry step must wire user_not_found when register is also a purpose). Violations fail deploy before anything is uploaded, with the same error text the server would return. Non-blocking product guidance is reported as warnings.
  • The release carries the resources as authored. The server reuses the revision it already holds when the content matches and allocates a new one otherwise, so an unchanged resource is never re-revisioned.
  • Every ${VAR} and *_env reference is checked against the running environment before the release is built, so a missing variable fails locally rather than shipping a placeholder.
  • Flow files carry "$schema": "../meta/flow-definition.json" — a pointer at the flow dialect spec setup copies into .zitadel/meta/, so editors validate and autocomplete flow edits offline. It is local-only and stripped before upload.
  • Your local files stay as written — deploy never rewrites them.

For agents:

npx @zitadel/cli@alpha deploy --dry-run --non-interactive --json
npx @zitadel/cli@alpha deploy -m "<message>" --non-interactive --json

Under --json, both carry the diff the terminal shows in data.diff: files lists each changed file with its status and its patch, the file's part of the diff. files is null when there is nothing to diff against yet, such as before the first deploy, and note says why.

Keep source-controlled configuration in zitadel.json and .zitadel/**. Keep .zitadel/local/** out of source control.

Was this helpful?