Skip to content

Get started ​

This page takes the README quickstart one command at a time: what each command does, what to expect in each UI, and the two steps people get stuck on (the Zitadel paste and the first login). At the end you have the whole stack running on your machine with a seeded tenant; the tutorial then drives a decision through it.

Prerequisites ​

  • Node 22 (nvm use reads .nvmrc) and pnpm 10 (corepack enable or npm i -g pnpm).
  • Docker with the compose plugin. The local stack runs Postgres, Temporal and its UI, Zitadel and Garage (S3-compatible object storage); Zitadel alone takes about 512 MB of memory.
  • macOS, or Linux with glibc 2.39 or newer (Ubuntu 24.04, Debian 13) to build the platform's apps: the extism-js compiler they are built with needs it.
  • Free ports: 5432 (Postgres), 7233 (Temporal), 8233 (Temporal UI), 8090 (Zitadel), 3900 (Garage), 4000 and 4001 (API), 4100 (app runner), 5176 (admin console), 5177 (collection terminal), 5178 (landing page).
  • curl and jq for the tutorial; neither is needed to run the stack.

Step by step ​

1. Install and configure ​

bash
pnpm install
cp .env.example .env

pnpm install resolves the workspace (the services under apps/, the packages and the catalogue's apps under catalog/). .env.example holds the development defaults: the two database URLs (the owner DATABASE_URL for migrations and seeds, the non-owner DATABASE_APP_URL the API and worker run with so row-level security applies), Temporal, the API port, COLLECTION_FLOW_URL and a placeholder SUBMISSION_TOKEN_SECRET. COLLECTION_FLOW_URL is the base of the links applicants open, so it must be the collection terminal's address, http://localhost:5177. The Zitadel lines are empty until step 5. Root scripts such as pnpm dev and pnpm smoke load .env for you; the file is gitignored.

The seeded workflows call apps (mock-sanctions, doc-verify-mock), which run on the app runner and keep their modules in object storage. Make sure .env has these lines, adding the ones it lacks: the Garage values (Documents), the runner's address and token, the API's internal listener the runner reaches, the secret that signs document reads, the built catalogue and the secret store key.

STORAGE_ENDPOINT=http://localhost:3900
STORAGE_PUBLIC_ENDPOINT=http://localhost:3900
STORAGE_REGION=garage
STORAGE_BUCKET=aletheia-documents
STORAGE_ACCESS_KEY=GKaletheiadev0000000000000
STORAGE_SECRET_KEY=aletheiadevsecret0000000000000000000000000000000000000000000000ab
STORAGE_CORS_ORIGINS=http://localhost:5176,http://localhost:5177
APP_RUNNER_URL=http://localhost:4100
APP_RUNNER_API_URL=http://localhost:4001
APP_RUNNER_TOKEN=<32 characters or more: openssl rand -hex 32>
APP_CALL_TOKEN_SECRET=<32 characters or more: openssl rand -hex 32>
APP_CATALOG_DIR=<the absolute path of your checkout>/catalog/dist
SECRET_STORE_KEY=<openssl rand -base64 32>

2. Build the platform apps ​

bash
pnpm tools:extism                              # extism-js and binaryen, pinned, into .tools/
pnpm turbo run build --filter='./catalog/*'    # the four apps and the SDK they are built with
pnpm catalog:index                             # catalog/dist: the modules and index.json

The platform's apps are WebAssembly modules built from catalog/ with the pinned compiler; pnpm catalog:index gathers them into catalog/dist, the catalogue the seed records and the API publishes when it starts (App catalogue). Run the last two again after changing an app.

3. Start the infrastructure ​

bash
pnpm infra:up      # docker compose up -d

Starts Postgres (:5432), Temporal (:7233, UI on :8233), Zitadel (:8090) and Garage (:3900). Zitadel initialises its first instance on an empty database: the aletheia-dev organization, a human admin and a bootstrap machine user whose personal access token it writes to docker/zitadel/bootstrap/bootstrap.pat (the folder is mounted into the container and gitignored). Wait for docker compose ps to show every service healthy; Zitadel takes the longest, up to a few minutes on first start.

Linux

The Zitadel container runs as an unprivileged user and must write into the mounted folder. On Linux hosts make it writable before the first start, as CI does (.github/workflows/ci.yml): chmod 0777 docker/zitadel/bootstrap. Docker Desktop on macOS and Windows hides this.

4. Migrate and seed the database ​

bash
pnpm db:migrate && pnpm db:seed

db:migrate applies the migrations under packages/db/drizzle, including the aletheia_app role and the row-level security policies. db:seed fills the development tenant (00000000-0000-0000-0000-000000000001) with the definitions the tutorial uses: the blocked_countries list, nine rules (country_allowed, submitted_country_allowed, sanctions_hit, pep_hit, country_blocklisted, high_ownership_foreign_ubo, identity_not_verified, too_many_signups_per_ip, txn_amount_24h), the kyb-onboarding rule set, the kyb-basic collection flow, the workflows hello-world, kyb-onboarding and transaction-monitoring, and four case queues. It also records the catalogue from APP_CATALOG_DIR and installs the two mocks for the tenant (mock-sanctions with blocklist: ["Evil Corp"], and doc-verify-mock); OpenSanctions and Sumsub stay in the catalogue uninstalled until someone stores their secrets. The seed is idempotent: a definition that already matches is left alone, one that differs gets a new published version.

5. Seed Zitadel and paste its values ​

bash
pnpm auth:seed

Using the bootstrap PAT, the seed converges Zitadel to what the API and the admin console expect: the project aletheia with the roles admin, analyst and integration; the OIDC application admin-console (sign-in for http://localhost:5176) and the API application the API uses for token introspection; the human user analyst; and four machine users with PATs under docker/zitadel/bootstrap/: aletheia-smoke (smoke.pat, role admin), aletheia-smoke-integration (smoke-integration.pat, role integration), aletheia-directory (directory.pat, reads the user directory for the reviewer picker) and aletheia-management (management.pat, manages service users for Connect › API keys). It also brands Zitadel's login page like the admin console: its colours, the Archivo font, the Aletheia wordmark, and no Zitadel watermark. Every organization inherits this branding. With DATABASE_URL set (it is, through .env) it also links the organization to the development tenant.

Progress goes to stderr. Stdout is a ready-to-paste block, and this is the step to get right: copy these lines into .env, replacing the empty ones from the example (add any the example lacks):

ZITADEL_ISSUER=http://localhost:8090
ZITADEL_PROJECT_ID=...
ZITADEL_API_CLIENT_ID=...
ZITADEL_API_CLIENT_SECRET=...
ZITADEL_DIRECTORY_PAT=...
ZITADEL_MANAGEMENT_PAT=...
VITE_ZITADEL_ISSUER=http://localhost:8090
VITE_ZITADEL_PROJECT_ID=...
VITE_ZITADEL_CLIENT_ID_ADMIN_CONSOLE=...
VITE_ZITADEL_ORG_DOMAIN=aletheia-dev.localhost

The ZITADEL_* lines are for the API (it refuses to start without them); the VITE_* lines are for the admin console's sign-in. The collection terminal needs none: applicants never sign in. Two trailing comment lines name the PAT files. The seed is idempotent and prints the same block on every run, so pnpm auth:seed > zitadel.env is a safe way to keep it; the secrets exist only in Zitadel and these files, so do not delete seed.json or the .pat files unless you also reset Zitadel (below).

6. Run everything ​

bash
pnpm dev

Turbo builds every library once, then watches: the API on :4000 (and its internal listener on :4001), the Temporal worker, the app runner on :4100, the admin console on :5176, the collection terminal on :5177 and the landing page on :5178. The UIs forward /api/* on their own origin to the API on :4000 (ADMIN_CONSOLE_API_TARGET, COLLECTION_TERMINAL_API_TARGET and LANDING_API_TARGET point them elsewhere), so the browser never calls the API directly. At start-up the API logs catalogue reconciled once it has published the catalogue's apps.

bash
pnpm smoke

Optional, in a second terminal: the end-to-end smoke drives every seeded workflow through the running stack with the PATs from step 5 and prints SMOKE OK. It is the same script CI runs, and the tutorial's commands are taken from it.

What to expect in each UI ​

URLWhat you see
http://localhost:5176Admin console. A sign-in page asks for the organization domain, then Home with the case figures. Operate › Cases is the workbench: the queues on the left (the system queues from All open to All cases, then the four seeded team queues), the filters and the table, empty until a run opens a case. Define lists the seeded definitions with their version and status. Connect › Apps lists the catalogue, the two mocks installed.
http://localhost:5177Collection terminal. A "Secure forms" page that tells visitors to open the link they were sent; links look like /flow/<submissionId>#token=…, render one submission at a time and need no sign-in. You reach it through a link a workflow run or POST /collection-submissions produces.
http://localhost:5178Landing page. The product with interactive examples, a page per industry under /solutions/<industry>, and the signup form. Signup answers signup_unavailable on the development stack unless the API has ZITADEL_SIGNUP_PAT, RESEND_API_KEY, SIGNUP_EMAIL_FROM and SIGNUP_CONSOLE_URL (Environment).
http://localhost:4000/docsAPI reference (swagger-ui) for the running instance; /health/ready reports db and temporal.
http://localhost:8233Temporal UI. One workflow per run, named wf:<tenantId>:<runId>; useful to see where a run is waiting.
http://localhost:8090/ui/consoleZitadel console. Users, the aletheia project and its roles.

First login ​

On the sign-in page enter the organization domain aletheia-dev.localhost. Zitadel then asks for a login name and password: admin@aletheia-dev.localhost / Password1! for the admin role, or analyst@aletheia-dev.localhost with the same password for a role that can read and decide cases but not edit definitions. On the first login Zitadel offers to set up a second factor; "Skip" is fine in development. You land back in the console signed in.

Common failures ​

  • A port is already in use. docker compose up fails with "port is already allocated", or a UI exits because its port is taken (the Vite configs set strictPort: the console's OIDC redirect URI names 5176 and collection links name 5177). Stop whatever holds the port; do not move the UIs.
  • pnpm auth:seed says bootstrap PAT not found at .../bootstrap.pat. Zitadel has not finished starting (docker compose ps), or on Linux it could not write into the folder: run the chmod from step 3 and restart the container.
  • pnpm tools:extism or the app build fails. extism-js needs macOS or glibc 2.39; on an older Linux, build in a container from node:22-trixie-slim, as the image build does.
  • pnpm db:seed logs catalogue not built; no apps seeded. APP_CATALOG_DIR is unset or holds no index.json: run step 2, check the path is absolute, and seed again.
  • A run fails at its screen step with apps_unavailable. The API or the worker runs without apps: APP_RUNNER_URL or the STORAGE_* values are missing from .env, or the app runner is not running (curl localhost:4100/health). A call_app step that fails with unknown_app names an app the tenant has not installed: seed again once the catalogue is built.
  • The API exits at start-up complaining about environment variables. The ZITADEL_* block was not pasted, or .env still has the empty lines from the example. Paste it and run pnpm dev again.
  • A run stays in waiting_collection with no submissionUrl. The worker signs collection links with SUBMISSION_TOKEN_SECRET (32 characters or more) and builds them from COLLECTION_FLOW_URL; both must be set in .env. The smoke reports this as no context.submissionUrl (is SUBMISSION_TOKEN_SECRET set for the worker?).
  • A collection link does not load. Links are built from COLLECTION_FLOW_URL, which on the development stack must be http://localhost:5177, where pnpm dev serves the collection terminal. Fix it in .env and restart pnpm dev; links already issued keep the old address, so start a new run.
  • Sign-in works but every API call answers 403 forbidden. The organization was not linked to a tenant, so the API rejects tokens issued for it. Run pnpm tenant:add --org <org id> --name dev --id 00000000-0000-0000-0000-000000000001; the seed prints this exact command when it cannot reach the database.
  • Starting over. pnpm infra:down, then docker compose down -v to drop the volumes, and delete docker/zitadel/bootstrap/seed.json and the .pat files together with them: Zitadel's state and the bootstrap files must match.

See the environment reference for every variable the processes read.

Next ​

You have a running stack and a seeded tenant. Go to Tutorial: a first decision to create a subject, run kyb-onboarding, decide the case it opens and change a rule to see the outcome change.

Released under the Apache-2.0 License.