This repository contains a pre-release Zitadel preview. It is public for visibility, feedback, and security reporting, but we are not yet accepting external code contributions here unless a maintainer explicitly asks for one. The workflows below are primarily for the Zitadel team and invited contributors while the preview stabilizes.
If you want to add Zitadel to your own app rather than contribute here, see the quick-start guide in README.md.
- Go version from
go.mod - Node.js from
.nvmrc - pnpm 10 from
package.json(corepack enable) - Moon
The devcontainer at .devcontainer/ pins Go 1.26. With no database configured, the server uses SQLite under the server data directory (same zero-config default as outside the container). After changing devcontainer configuration, use Dev Containers: Rebuild Container so features and volume mounts apply.
The devcontainer reuses the host Docker daemon (Docker-outside-of-Docker), so
container-backed workflows such as the
database integration tests work inside it —
verify with docker info. If docker info fails and the host uses
rootless Docker, override the socket mount in
.devcontainer/devcontainer.json per the
docker-outside-of-docker feature docs,
for example bind /run/user/<uid>/docker.sock to /var/run/docker-host.sock
(use id -u on the host for <uid>).
For changes to the Go server, APIs, or database layer.
| I want to... | Run |
|---|---|
| Install dependencies | corepack pnpm install --frozen-lockfile |
| Verify my toolchain | moon run workspace:doctor |
| Start the server (builds console + login-ui, then Go) | moon run workspace:server |
| Start the server (skip UI builds) | go run . server |
| Debug and attach VSCode | moon run workspace:server-debug |
| Regenerate Go/OpenAPI artifacts | moon run server:generate |
| Verify committed generated output | moon run server:check-generate |
| Run lint, type checks, and tests | moon ci :lint :typecheck :build :test |
| Preview planned Changesets bumps | corepack pnpm exec changeset status --since origin/main |
Run corepack pnpm install --frozen-lockfile first — Moon does not install
node_modules automatically (.moon/workspace.yml sets installDependencies: false).
The Go binary embeds production builds of two frontend apps:
apps/console→internal/staticui/console/dist/apps/login-ui→internal/staticui/login/dist/
moon run workspace:server builds both apps before starting Go. If you have not
changed any frontend code, skip the UI builds with go run . server.
To build the UIs manually (when bypassing the wrapper):
moon run console:build login-ui:build
go run . serverWith no database configured, the server uses SQLite at
<server.data_dir>/zitadel.db. Override with -c docs/operations/nextgen.example.yaml,
NEXTGEN_DATABASE_SQLITE, or NEXTGEN_DATABASE_POSTGRES when you want a
path or DSN you manage.
Open http://localhost:8080/ui/console/ and http://localhost:8080/ui/login/
Use server-debug to build the binary with debug symbols and disabled inlining,
then attach VSCode's Go debugger by PID:
moon run workspace:server-debug -- server --user-file examples/bootstrap-users/demo-admin.jsonThe task prints the exact go build invocation and the PID of the running process:
[server-debug] build: go build -gcflags 'all=-N -l' -ldflags '-X main.version=debug' -o dist/server/nextgen-debug .
[server-debug] run: ./dist/server/nextgen-debug server --user-file examples/bootstrap-users/demo-admin.json
[server-debug] PID 98765 — VSCode: Run ▸ Start Debugging ▸ "Attach to Process"
In VSCode, open the Run and Debug panel (⇧⌘D), select "Attach to Process", and type
nextgen-debug in the picker to filter.
Add the following configuration to your .vscode/launch.json to enable it:
{
"name": "Attach to Process",
"type": "go",
"request": "attach",
"mode": "local",
"processId": "${command:pickProcess}"
}The debug binary stamps version=debug so it is distinguishable from a production
build (semver) and a plain go run build (dev).
Contributors testing the published CLI flow against local workspace packages use
moon run workspace:cli -- start and moon run workspace:cli -- setup --server local
instead of npx. This requires the source repository to be checked out.
Before invoking the local CLI, the wrapper builds and packs the public workspace
packages, publishes them to a persistent local Verdaccio registry under
tmp/cli-local-registry, and points the generated app install at that registry.
Set ZITADEL_CLI_USE_PUBLIC_PACKAGES=1 to install public npm packages in the
generated app instead of local ones.
Pass --runtime docker, --image <tag>, or set ZITADEL_LOCAL_IMAGE=<tag> when
intentionally testing the Docker backend.
For changes to the admin console (apps/console) or the login UI (apps/login-ui) —
the two SPAs bundled directly into the server binary.
| I want to... | Run |
|---|---|
| Install dependencies | corepack pnpm install --frozen-lockfile |
| Verify my toolchain | moon run workspace:doctor |
| Open the component workbench (Storybook) | moon run storybook:dev → http://localhost:6006 |
| Start the console dev server | moon run console:dev → http://localhost:5174 |
| Start the login-UI dev server | moon run login-ui:dev → http://localhost:5175 |
| Build both apps and test with the Go server | moon run console:build login-ui:build then go run . server |
| Run lint, type checks, and tests | moon ci :lint :typecheck :build :test |
Run corepack pnpm install --frozen-lockfile first.
The console dev server uses in-browser API mocking, so you do not need a running backend to iterate on the console. The login-UI dev server renders the real sign-in flow instead; so you do need a running backend server to test the authentication flows.
Dev servers serve at /; the embedded production builds are served at
/ui/console/ and /ui/login/.
The Go backend does not create a project on startup. Create one before the login UI can boot a flow. Project creation provisions the default human user schema and default login/register flow definition.
1. Start the API-only backend:
NEXTGEN_SERVER_CONSOLE_ENABLED=false NEXTGEN_SERVER_LOGIN_ENABLED=false go run . serverThis skips the embedded UI dist checks, so it works before you have built
apps/console or apps/login-ui.
2. Create a project:
curl -s -X POST http://localhost:8080/projects \
-H "Content-Type: application/json" \
-d '{"name": "dev"}'The response contains an id field — that is your project ID. If you have
jq installed, append | jq . to pretty-print the response.
3. Configure the dev server:
Copy the example env file and fill in your project ID:
cp apps/login-ui/.env.example apps/login-ui/.env.development.local
# edit .env.development.local and uncomment VITE_PROJECT_ID=<id-from-step-2>These Vite env vars are only for the local dev server. VITE_PROXY_PATH tells
the login UI to prefix all API requests with
/__nextgen, which the Vite dev server proxies to the Go backend on port 8080.
Set VITE_BACKEND_URL in .env.development.local to override the target
(default: http://localhost:8080). Production hosted-login builds ignore these
development env vars and use request/URL-derived context instead.
As a one-off alternative, pass ?project_id=<id> in the browser URL instead
of setting VITE_PROJECT_ID.
Run the doctor once to verify your toolchain, then run the standard check suite before opening a PR:
moon run workspace:doctor
moon ci :lint :typecheck :build :testDocker is needed for container builds, Docker-fallback journeys, and container-backed integration tests; it is not required for the default npm-binary local runtime. Playwright browsers remain advisory for opt-in e2e and journey workflows.
If you are new to Moon, start by listing the project graph and available tasks:
moon projects
moon tasks
moon project cli
moon task cli:testMoon task targets use the <project>:<task> form, for example moon run cli:test.
Use corepack pnpm exec changeset status --since origin/main when you want to
preview the package bumps Changesets will plan from your PR. Pull requests also
get an informational Changesets comment; maintainers use that and the
changeset decision table to review release
intent.
Task runs are accelerated by Moon's remote cache, configured under remote in
.moon/workspace.yml; Depot CI runners authenticate
automatically. Local runs skip the remote cache unless you export a Depot API
token as DEPOT_CACHE_TOKEN — with one set, your machine downloads shared task
outputs but never uploads (uploads happen only in CI). Pull requests from forks
run on GitHub-hosted runners without the Depot cache credential.
To run the full CI-parity suite locally — including database integration
tests, package checks, and the fresh-app journey — run
moon run workspace:check -- --full. The demo end-to-end suites are not part
of --full; run them with moon run workspace:check -- --only node:e2e or
the Moon tasks below. To re-run a single failing task, use
moon run <project>:<task>. To re-run one check phase:
moon run workspace:check -- --only <phase>.
Branch protection requires the GitHub Actions context full-pr, shown in the
pull request UI as ci / full-pr and defined in
.github/workflows/ci.yml. The job installs
dependencies, then picks one of two modes via scripts/ci-mode.mjs:
Full mode (normal PRs) runs, in order:
moon run server:check-generate— Go generated-file drift check.- Playwright Chromium install for
@zitadel/components. moon ci :lint :typecheck :build :test :test-browser :check-adrs.moon run server:test, thenmoon run server:test-postgres,moon run server:test-spanner(Spanner emulator testcontainer, or a real instance whenSPANNER_TEST_INSTANCEis set), andmoon run server:test-sqlite.moon run release:snapshot -- --skip-container— a non-publishing release snapshot.- The fresh-app consumer journey (
cli-journey-e2e:e2e-local) with the npm binary runtime against the snapshot's packed tarballs, one passkey-first preset journey run, and the test-kit consumer journey (cli-journey-e2e:e2e-testkit). - The real-instance suites:
testing:test-integrationwithdemo-next-e2e:e2e-real, thenconsole-e2e:e2e-real.
Within full mode, the steps after the moon ci graph are additionally gated
by moon's affected task selection (moon query tasks --affected --downstream deep, computed in scripts/ci-mode.mjs): a lane is skipped when the diff
provably cannot reach its
tasks — a docs-only PR runs almost nothing, a frontend-only PR skips the Go
suites, a console-only PR runs the e2e suites but no journeys. The journey
variants gate per surface (the map lives as task-space constants in
scripts/ci-mode.mjs): the fresh-app journey answers to every SDK plus the
shared login surface, the passkey-preset and test-kit journeys to the shared
surface only, and the fresh-app framework matrix collapses to a single
framework unless an SDK, the CLI, or the journey project itself moved. The
snapshot runs iff any journey does — the tarball handoff between them is a
filesystem contract moon cannot see. The gates fail open: known repo-wide
files no moon task claims (workflow definitions, scripts/, moon config,
root manifests and compiler/release inputs such as tsconfig.base.json), an
empty diff, or a failed query all force the
complete run — and when the query returns an empty affected set, the run is
only skipped if every changed file is on a narrow explicitly-inert allowlist
(docs/, root agent notes), because unclaimed files are not assumed inert.
Version-only mode (Changesets version PRs) runs release:version,
release:pack, and tarball verification instead.
CI consumes the workflow's packed npm tarballs, not public Zitadel packages. Changesets PR comments are informational release-intent feedback, not a blocking gate. Workflow artifacts (the release snapshot whenever a journey lane runs — a run whose journeys are gated off, such as a docs-only or console-only PR, uploads no snapshot — and journey diagnostics on failure) expire after 7 days. The demo end-to-end suites and the Docker-fallback journey do not run in CI; they stay opt-in local checks (see below).
SQLite integration tests use a local file database and need no Docker:
moon run server:test-sqlite
# or: go test -v -tags sqlite_integration -timeout=5m ./...Postgres and Spanner integration tests use testcontainers to start their databases (a Postgres container and the Cloud Spanner emulator image), so a running Docker daemon is required — see Using the devcontainer for the Docker-outside-of-Docker setup. Local zero-config runs use SQLite and need no Docker.
# Postgres (testcontainer, or ZITADEL_TEST_POSTGRES_URL)
moon run server:test-postgres
# or: go test -v -tags postgres_integration -timeout=10m ./...
# Spanner (prefer the Moon task — see emulator-testcontainer note below)
moon run server:test-spannerTo run the Postgres or Spanner suites against a database you manage instead of
testcontainers, set ZITADEL_TEST_POSTGRES_URL (Postgres DSN) or
ZITADEL_TEST_SPANNER_URL (Spanner DSN); those suites honor the env vars and
connect instead of starting a container, so go test -tags … ./... needs no
Docker. Point them at a throwaway database — the suites run migrations that
create the zitadel_nextgen schema.
The Spanner emulator only supports one transaction at a time, so concurrent
integration tests are flaky against it. moon run server:test-spanner
therefore passes -parallel 1 -p 1 whenever ZITADEL_TEST_SPANNER_INSTANCE
is unset (the default for local/OSS contributors, which starts the emulator
via testcontainers). To run against a real, long-lived Spanner test instance
instead, set ZITADEL_TEST_SPANNER_INSTANCE to an instance path
(projects/<project>/instances/<instance>). The suites then provision a
uniquely named database on that instance before the run and drop it
afterwards, and the Moon task keeps normal go test parallelism.
Authentication uses Application Default Credentials — locally run
gcloud auth application-default login. CI authenticates via Workload
Identity Federation when SPANNER_TEST_INSTANCE is set, and labels the job
step as emulator vs test instance accordingly. Precedence when multiple are
set: ZITADEL_TEST_SPANNER_INSTANCE > ZITADEL_TEST_SPANNER_URL > emulator
testcontainer.
These tests start real servers and require a browser install, so they are opt-in locally. The demo suites exercise the checked-in framework demos:
corepack pnpm --filter @zitadel/demo-next-e2e exec playwright install
moon run demo-next-e2e:e2e
moon run demo-nuxt-e2e:e2eThe journey test creates one fresh app directory per selected framework outside
the repo, runs the full CLI setup flow against local workspace packages, starts
each generated app, and runs Playwright to verify the result. By default it runs
the full framework matrix; use moon run workspace:journey -- --framework next
for a single Next.js run:
moon run workspace:journeyTo exercise the Docker fallback path, provide a local backend image tag:
moon run workspace:journey -- --runtime docker --image nextgen:localUse --keep to preserve the temporary work directory after success. On failure
the runner keeps diagnostics automatically and prints the path.
To build the versioned release binary (the same artifact published to GitHub Releases):
moon run release:snapshotThe release task builds the embedded UI surfaces (console and login-ui) automatically.
To cut or recover a release, follow the
release runbook. Moon builds the artifacts
and the draft GitHub Release; Changesets owns versions, npm publishing, and
release notes — see
ADR 002 and
.changeset/README.md.
By default, moon run workspace:cli -- start uses the npm binary runtime and
does not build a Docker image. To build and start a local Docker image instead,
pass --runtime docker:
moon run workspace:cli -- start --runtime dockerUse an existing image explicitly when you do not want to rebuild:
moon run workspace:cli -- start --runtime docker --image custom:tag
ZITADEL_LOCAL_IMAGE=custom:tag moon run workspace:cli -- start --runtime dockerBefore making a significant change, read the workspace conventions and any relevant architecture decision records:
AGENTS.md— workspace conventionsdocs/adrs/— architecture decisions
Use the conventional format <type>(optional-scope): <summary>. Allowed types
and scopes live in .github/semantic.yml — that file is
the source of truth for the lists, and CI rejects a title that does not match it.
Scopes are optional; omit the scope instead of inventing one.
The type is the first thing that decides whether a change reaches our release
notes, and those notes are read by people who want to use our SDKs and products
— not by people who work on this repo. A large, hard PR that only moves the
build graph is still build. Work through the ladder in order:
- Can someone using Zitadel — the SDKs, the CLI, the API, the console — do
something new because of this PR? →
feat - Could that person have hit the broken behavior this PR corrects? →
fix - Neither, but the change reaches shipped code (server, SDKs, CLI, console,
login UI)? →
refactor/perf - The change only touches this repo — CI, build wiring, tests, docs, scripts,
tooling? →
ci/build/test/docs/chore
Two invariants follow, and CI enforces the first:
- Needs no changeset ⇒ not
feat, notfix. If nothing ships, it is not a customer feature or a customer bug fix. The changeset decision table answers this question already — the type follows from the same answer. - Changes what a user receives ⇒ not
docs, notchore.docsmeans documentation in this repo. Content generated into a customer's project, or shipped inside a package, is product.
The reverse of the first invariant does not hold: a changeset does not force
feat or fix. The Go server ships as one bundle, so an internal restructure
under internal/ correctly carries a @zitadel/server changeset while staying
refactor.
What these got wrong, from this repo's own history:
| Shipped title | What it actually did | Should have been |
|---|---|---|
feat: add withZitadel() Playwright orchestration to @zitadel/testing (#680) |
@zitadel/testing was journey-only and unpublished at the time (since #692 it ships on the release train, so kit API changes are feat today) |
test: (then) |
feat: add Figma export sync pipeline for design tokens (#494) |
A sync script and a workflow; nothing in a release | build: |
chore: set argon2id as default password hashing algorithm (#526) |
Changed a shipped security default, with a @zitadel/server changeset |
feat: |
docs(config): improve schema README guidance (#482) |
Rewrote a README that @zitadel/config generates into the user's project |
feat(config): |
- Imperative present tense —
add, notaddedorimplemented. - Name the surface the reader touches (
zitadel setup,<zitadel-login>,@zitadel/sdk-react, the console), not the layer that changed.feat: project storageandfeat: add name to project domain modelname our internals; neither tells a reader what they can now do. - Keep internal references — ADR numbers, PR numbers, file paths, "foundation", "POC" — in the description, not the title.
The same rules apply to the changeset summary, which matters more: that text is
rendered verbatim into CHANGELOG.md and the GitHub Release, while the title
never appears there. See .changeset/README.md.
Check a title before opening the PR:
node scripts/check-pr-title.mjs --title "fix(login): keep the passkey prompt after a failed attempt"Include a concise PR description before handing work off for review. Use these sections:
Summary— what changed and why.Validation— exact commands run. If validation was not run, say so explicitly.Release notes / changeset— changeset status for user-visible package changes.Notes— reviewer context, follow-ups, risks, orNone.