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 deploydeploy 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-runA 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 deployWalkthrough: 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 arel_id and a content digest. Build one without deploying it withrelease create, and stop a release from ever being served again withrelease revoke. - A deployment makes a release live on a set of origins.
deploycreates one to every primary origin;deployment create --release <id>deploys an existing release. Primary origins are never named one by one. deployment listshows the history (newest first),deployment get <id>reads one, anddeployment rollbackre-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
previewneedsZITADEL_ENV=preview. Without it, the preview is skipped. previewdetects preview URLs only on Vercel and Netlify. Pass--originwith the URL your hosting step reports.fetch-depth: 0keeps the historydeploydiffs against. With the default shallow checkout, the diff is not shown.- When a pull request's
previewbuilt the release, the release records GitHub's merge commit, which is not in the main branch's history, so thedeployafter 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 }}intorun:: 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_foundwhenregisteris also a purpose). Violations faildeploybefore 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*_envreference 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 —
deploynever rewrites them.
For agents:
npx @zitadel/cli@alpha deploy --dry-run --non-interactive --json
npx @zitadel/cli@alpha deploy -m "<message>" --non-interactive --jsonUnder --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?