Book of Minimal, Vol. III
Identity and the Gateway
Zitadel for identity, APISIX as the boundary, Minimal behind it, one Postgres for all three — from bare hosts to the first signed-in app, every step run before it was written.
1What We Are Building
Volumes I and II ended at the same place: Minimal trusts five headers, and something in front of it has to turn a real person into those five values before a request is allowed through. Volume II's closing chapter described that boundary in general terms and named three identity providers it could be built against. This volume builds it, with one of them, to the point where an app on a phone can sign in, get an identity, and call an API — and where the next app can be added the same way in an afternoon.
The three pieces are Zitadel for identity, Apache APISIX for the boundary, and Minimal for the API. They share one Postgres instance. Every step in this book was run before it was written down, on a Mac for the development environment and on a Debian 12 virtual machine standing in for the production hosts; where a step could not be run — anything that needs a real domain, a real Apple developer account, or a real Google project — the text says so at the step.
The apps, and what identity means to them
The apps this book serves are consumer apps: someone installs one from an app store, opens it, and starts using it without signing in. On first launch the app mints its own identity — a ULID for the device and a key pair whose private half never leaves the device. Everything the app does alone, it does with that local identity and nothing else.
Registration happens later, and only when the person reaches for a feature that needs a server: a shared list, a backup, a second device. At that point the app sends the person to sign in with Google, GitHub or Apple. Zitadel handles that sign-in and hands the app a token. That token is the whole of what the app carries to the API from then on: no password, no API key, no Minimal access token.
Three rules about identity hold everywhere in this book, and each has a chapter that enforces it:
- The server assigns the user id. Minimal's
X-User-Idis a ULID the server minted the first time it issued a token for that person. It is never the Zitadel subject, never the device's own ULID, and never anything the app chose. The device's ULID and public key are data the app registers under the server's id, if the app needs them server-side at all. Chapter 4 mints it; Chapter 5 copies it into the header; Chapter 7 shows the whole path. - One organisation per app in Zitadel. A person who registers in two of your apps has two accounts, one in each app's organisation, even with the same email. Signing in to one never signs them in to the other. Chapter 4 sets this up and Chapter 8 proves it with a second app.
- Nothing reaches Minimal without a token the gateway verified. There is no anonymous route.
A request with no token, a forged token, a token for another app, or a token from another issuer
is refused at the gateway with
401or403and never reaches Minimal. Chapter 5 builds this and shows each refusal.
The shape of it
graph LR
subgraph Device["Phone, tablet or Mac"]
App["App<br/>local ULID + key pair"]
end
subgraph AppHost["app-1 — the application host"]
GW["APISIX<br/>api.example.com · auth.example.com<br/>the only public listener"]
ZL["Zitadel Login V2<br/>127.0.0.1:3000"]
ZT["Zitadel<br/>127.0.0.1:8081"]
MN["Minimal<br/>127.0.0.1:3045"]
end
subgraph DBHost["db-1 — the database host"]
PG["Postgres 18<br/>zitadel · minimal · healthyme<br/>one role per database"]
end
App -->|"1. sign in (OIDC, PKCE)"| GW
GW -->|"/ui/v2/login"| ZL
GW -->|"everything else on auth."| ZT
ZL -.->|"session API"| ZT
App -->|"2. Authorization: Bearer JWT"| GW
GW -->|"3. X-Org-Id · X-Project-Id · X-Space-Id<br/>X-User-Id · X-User-Roles"| MN
ZT -->|TLS| PG
MN -->|TLS| PGTwo hosts. The app talks only to APISIX. APISIX is the only process anyone outside can reach; Zitadel, its login, and Minimal all bind to loopback on the same host and are reachable only through it. Postgres sits on its own host and accepts each service under its own role, over TLS, into its own database.
Read the numbered arrows as one person's first day:
- The app opens the system browser at
https://auth.example.com/oauth/v2/authorizewith a PKCE challenge. APISIX sends that to Zitadel, which redirects to its own login page, which APISIX sends to the Login V2 process. The person taps "Continue with Google". When they come back, the app has an authorization code and exchanges it — still through APISIX — for a JWT access token. - The app calls
https://api.example.com/minimal/...with that JWT in theAuthorizationheader. - APISIX checks the signature against Zitadel's published keys, checks the issuer and the audience,
deletes every identity header the app may have sent, and sets the five headers itself from the
token's claims. Minimal sees a request from
X-User-Id: 01M2F0KNNC4M7Z1HEJHHG80JGEholdingX-User-Roles: member, and does what Volumes I and II describe.
The token in step 1 carries the server-assigned id because Zitadel minted it: an action that runs
every time Zitadel issues an access token looks the person up, assigns a ULID the first time, stores it
against the account, and puts it on the token as a claim named minimal_user_id. Chapter 4 writes that
action; it is twenty lines.
Names used throughout
One fictional app, Healthy Me, carries the whole book; Chapter 8 adds a second, Sleep Well, to show that nothing about the first was special. The names below recur in every chapter and every configuration file. Replace the placeholders once, consistently, and the files in Appendix D work as printed.
| Thing | Name in this book | Replace with |
|---|---|---|
| Public API host | api.example.com |
your API domain |
| Public identity host | auth.example.com |
your login domain |
| Application host | app-1, private address 10.0.0.20 |
your host |
| Database host | db-1, private address 10.0.0.10 |
your host |
| Postgres roles | zitadel, minimalist, healthyme_app |
keep, or rename per app |
| Postgres databases | zitadel, minimal, healthyme |
keep, or rename per app |
| Zitadel organisation | Healthy Me | one per app |
| Zitadel project and native app | Healthy Me · Healthy Me iOS | one project per app, one native app per platform if you want separate client ids |
| Zitadel project roles | member, premium |
your product's roles |
| Zitadel service accounts | provisioner (instance owner, PAT), login-client (login UI) |
keep |
| App redirect scheme | com.littlebit.healthyme://auth/callback |
your bundle id |
| Minimal organisation | lbl |
your company's short name |
| Minimal project · space | healthyme · live |
one project per app, one space per release channel |
| Minimal admin identity | X-User-Id: appctl, X-User-Roles: admin |
the identity your provisioning tool uses |
Three of those deserve a sentence each.
The Minimal organisation is yours, not the customer's. Every person using Healthy Me is a row in Healthy Me's tables, identified by their server-assigned ULID; none of them is a Minimal organisation, project or space. The Minimal hierarchy here is your deployment structure — one organisation for the company, one project per app, one space per release channel — exactly as Volume I, Chapter 3 describes it for Acme.
The Zitadel organisation is the app. Zitadel's own hierarchy is instance → organisation → project → application, and a user belongs to one organisation. Putting each app in its own organisation is what gives you separate user bases: the same email address registering in Healthy Me and in Sleep Well creates two unrelated Zitadel users.
The roles are Zitadel's, copied by the gateway. A person holds member or premium because
Zitadel says so on the token, and APISIX writes exactly those names into X-User-Roles. Minimal's
permission templates then name the same strings. Nothing on the Minimal side stores who holds which
role; Chapter 6 sets the templates so the three systems agree on the vocabulary.
Two environments, and only two
dev |
production |
|
|---|---|---|
| Where | the Mac you work on | two Debian 12 hosts |
| Postgres | the Homebrew Postgres 18 already on the Mac, port 5433 | Postgres 18 from the PGDG repository on db-1 |
| Zitadel and Login V2 | the release binaries, run from a terminal | the same binaries, as systemd units |
| Minimal | your working build, from a terminal | a release build, as a systemd unit |
| APISIX | the Debian package inside a small Linux VM (Lima), because APISIX does not run on macOS | the Debian package, as a systemd unit |
| TLS | none; everything is http://localhost:8080 |
Let's Encrypt certificates on APISIX; TLS from every service to Postgres |
| Sign-in providers | username and password on the dev organisation, because Apple refuses localhost and Google and GitHub need a public callback for anything but your own account |
Google, GitHub and Apple; username and password switched off |
There is no staging. A change is tried on the Mac and then applied to production, and the Minimal
space is the release channel: Volume II's definition-copy route promotes a definition from a next
space to live without touching anything else. Chapter 9 builds dev; Chapters 2 to 7 build
production, and say where dev differs.
What is fixed before you start
These were current on the day this book was verified, 14 September 2026, and every command below is written against them:
| Piece | Version | Why this one |
|---|---|---|
| Postgres | 18 | the owner's floor; Zitadel supports 14 to 18, Minimal's schema needs 13 or later for pgvector |
| Zitadel | v4.17.3 (4 September 2026) | current stable; ships a Linux and macOS binary and a Node bundle for Login V2, so nothing needs Docker |
| Zitadel Login V2 | the zitadel-login.tar.gz from the same release |
Login V2 is required by default on a v4 instance; the old built-in login is on its way out |
| APISIX | 3.18.0 (20 August 2026) | current stable in the vendor's Debian 12 repository |
| Node | 22 LTS | what the Login V2 bundle runs on |
| Minimal | the tag you are deploying | built as Volume II, Chapter 9 describes |
Two of those choices were made against alternatives, and the reasons matter later:
- APISIX rather than a Go gateway. KrakenD is a single Go binary that validates JWTs against a
JWKS and, unlike most gateways, forwards no client header unless a route lists it — a genuinely good
fit for Minimal's five-header contract. It was rejected because it is an API gateway for JSON
backends and cannot front Zitadel's console and login, which need a general reverse proxy with
HTTP/2. One gateway that does both beat two gateways. The cost is that APISIX is Lua on OpenResty
and, as its own build files state, runs only on Linux — which is why
devkeeps it in a VM. - Binaries and systemd rather than containers. Every piece ships a Linux binary or a Node bundle,
so the production host runs four units —
apisix,zitadel,zitadel-login,minimal— under their own unprivileged users, with the hardening options systemd offers, and nothing else in between. The Docker Compose files Zitadel publishes are a fine reference for environment variable names; the ones this book uses are transcribed from them and from the bundle's own startup script, and each was tested.
What you need before Chapter 2
- Two Debian 12 hosts with a private network between them,
sudoon both, and the two public DNS names pointing atapp-1. Ubuntu 22.04 or 24.04 works with the same commands; the vendor repository lines in Chapters 2 and 5 name the distribution and you change those. - A Mac with Homebrew, Postgres 18 already running, Node 22 or later, and Go, for Chapter 9.
- Developer accounts for the sign-in providers you will offer — a Google Cloud project, a GitHub OAuth app, and an Apple Developer team with a Services ID — for Chapter 4. The rest of the book does not wait on them: the dev organisation signs in with a password until they exist.
- A Minimal release build, and Volumes I and II within reach. This volume does not re-explain organisations, permission templates, lock masks or row-level security; it uses them.
How each chapter is written
Every chapter is a sequence of steps, each of which ends in something you can check: a command whose output is shown, a query whose rows are shown, a request whose status code is shown. The checks are the point. When a step has a failure mode that was actually hit while writing this book — a configuration key in the wrong file, a Lua pattern that silently never matches, a port forwarded from the wrong place — the step says what the failure looks like so you recognise it in a minute rather than an hour.
Configuration files are printed in full once, in Appendix D, and shown in the chapters as the fragments each step changes. Secrets in printed files are placeholders in angle brackets; a value generated by a command, such as a masterkey or a personal access token, is shown as the command that generated it and never as a literal.
Exercises
Draw the request path for one person opening Healthy Me for the first time and, a week later, turning on a shared list. Mark on it the moment the server-assigned ULID comes into existence, and the moment Minimal first learns of this person. They are not the same moment; say what happens between them.
Using the names table, write the same table for your own first app: the domains, the Postgres role and database it gets, its Zitadel organisation and project, its Minimal project and space, and its role names. Keep it; Chapters 2 to 7 will ask for each entry in turn.
This chapter says a person who registers in two of your apps has two accounts. Write down what would have to change — in Zitadel, in the gateway, and in the apps — if you later wanted one account shared across apps, and which chapter each change lands in. Chapter 8 gives one answer; compare yours to it.
3Zitadel as a Service
Zitadel v4 is two processes. The first is a single Go binary that serves every API, the OpenID
Connect endpoints and the management console. The second is the login: a Next.js application that
renders every screen a person sees while signing in, talks to the first process over its API, and is
required on a v4 instance unless you switch it off. The release page ships both — a Linux binary and
a zitadel-login.tar.gz bundle that runs on Node — so neither needs a container. This chapter
installs both on app-1 as systemd units, bound to loopback, under their own users, with the identity
provider's database role and the two service accounts everything later depends on.
The reverse proxy that puts a public name in front of them is Chapter 5. Until then Zitadel is reached
only from app-1 itself, and only with a Host header that names its public domain, because Zitadel
resolves which instance a request belongs to from that header and answers 404 to any other.
How Zitadel starts, and why the order matters
The binary has three phases, each its own subcommand, and the flags they take decide what runs as whom:
| Phase | Command | Needs | Does |
|---|---|---|---|
| init | zitadel init |
a Postgres superuser | creates the role and database, then the schema |
| init, schema only | zitadel init zitadel |
the zitadel role |
creates the eventstore, projections and system schemas in an existing database |
| setup | zitadel setup |
the zitadel role, the masterkey, a steps file |
runs migrations and creates the first instance, organisation, admin and service accounts |
| start | zitadel start |
the zitadel role, the masterkey |
serves |
Chapter 2 already created the role and the empty database, so the superuser phase is never run. The
docs call the schema-only step init schema; in v4.17.3 the subcommand is init zitadel, and its
help text says "initialize ZITADEL internals". It is the one this chapter uses.
Two facts about setup cost time when they are not known in advance. The first instance — its name,
its organisation, its admin user, the service accounts and where their tokens are written — is
configured in a steps file passed with --steps, not in the configuration file passed with
--config. A FirstInstance: block in the config file is read as nothing, and setup silently creates
the default instance named ZITADEL with zitadel-admin@zitadel.localhost and the password
Password1!. And the token files setup writes are written by the user setup runs as, into the path
you name, so that path has to be writable by that user — a directory owned by root is not.
Step 1 — Users, directories, binaries
sudo useradd --system --home-dir /var/lib/zitadel --create-home --shell /usr/sbin/nologin zitadel
sudo useradd --system --home-dir /var/lib/zitadel-login --create-home --shell /usr/sbin/nologin zitadel-login
sudo install -d -m 0750 -o root -g zitadel /etc/zitadel
sudo install -d -m 0750 -o zitadel -g zitadel /var/lib/zitadel
sudo install -d -m 0755 /opt/zitadel-loginFetch the release, verify it against the release's own checksum file, and install:
V=v4.17.3; ARCH=amd64 # arm64 on an ARM host
cd /tmp
curl -fsSLO https://github.com/zitadel/zitadel/releases/download/$V/zitadel-linux-$ARCH.tar.gz
curl -fsSLO https://github.com/zitadel/zitadel/releases/download/$V/zitadel-login.tar.gz
curl -fsSLO https://github.com/zitadel/zitadel/releases/download/$V/checksums.txt
grep -E "zitadel-linux-$ARCH.tar.gz|zitadel-login.tar.gz" checksums.txt | sed 's#.artifacts/pack/##' | sha256sum -c -
tar xzf zitadel-linux-$ARCH.tar.gz
sudo install -m 0755 zitadel-linux-$ARCH/zitadel /usr/local/bin/zitadel
sudo tar xzf zitadel-login.tar.gz -C /opt/zitadel-login
sudo chown -R root:root /opt/zitadel-login
zitadel --versionzitadel-linux-amd64.tar.gz: OK
zitadel-login.tar.gz: OK
zitadel version v4.17.3The checksum file lists paths under .artifacts/pack/, which is why the sed strips that prefix
before sha256sum looks for the files. The login bundle unpacks to apps/login/server.js, a
node_modules directory, public/, and an entrypoint.sh that reads the token file — it is the
standalone output of a Next.js build, and it runs with nothing but Node.
Node 22 from the NodeSource repository, since Debian 12's own Node is too old for the bundle:
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash -
sudo apt install -y nodejs
node -vv22.23.2Step 2 — The masterkey and the configuration file
The masterkey is a 32-character secret Zitadel uses to encrypt what it stores — keys, secrets, the personal access tokens it issues. It cannot be changed once the instance exists, so generate it once, keep it in the backup set with the database dumps, and never print it:
sudo sh -c "head -c 32 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 32 > /etc/zitadel/masterkey"
sudo chown root:zitadel /etc/zitadel/masterkey && sudo chmod 0640 /etc/zitadel/masterkeyThe configuration file names the public domain, the internal port, the database, and switches the
login requirement on with the public URL of the login. Every key here has an environment-variable
twin, but a file the zitadel group can read and nobody else is the better place for a password:
sudo tee /etc/zitadel/config.yaml >/dev/null <<'EOF'
Log:
Level: info
Port: 8081 # loopback; APISIX is the only thing that connects
ExternalDomain: auth.example.com
ExternalPort: 443
ExternalSecure: true # the public side is HTTPS, terminated by APISIX
TLS:
Enabled: false # ... and the hop from APISIX to this port is plain h2c
Database:
postgres:
Host: 10.0.0.10
Port: 5432
Database: zitadel
MaxOpenConns: 10
MaxIdleConns: 5
User:
Username: zitadel
Password: <zitadel-password>
SSL:
Mode: verify-full
RootCert: /etc/ssl/certs/internal-ca.pem
DefaultInstance:
Features:
LoginV2:
Required: true
BaseURI: "https://auth.example.com/ui/v2/login"
OIDCSettings:
AccessTokenLifetime: 1h
IdTokenLifetime: 1h
RefreshTokenIdleExpiration: 720h
RefreshTokenExpiration: 2160h
EOF
sudo chown root:zitadel /etc/zitadel/config.yaml && sudo chmod 0640 /etc/zitadel/config.yamlExternalDomain, ExternalPort and ExternalSecure are recorded into the instance at setup. They
are the issuer every token carries and the host every request must name; changing them later is an
instance-domain migration, not a config edit. Get them right here. SSL.Mode: verify-full is the
Chapter 2 server certificate doing its job; with the snakeoil pair it has to be require, and a
disable here fails at start against Chapter 2's hostssl-only pg_hba.conf with no pg_hba.conf entry ... no encryption.
The OIDCSettings block shortens the access token from Zitadel's default of twelve hours to one.
The gateway in Chapter 5 validates tokens locally against the signing keys and never asks Zitadel
whether a given token has been revoked, so a token's lifetime is the longest a revoked account keeps
working. One hour with a ninety-day refresh token is the usual trade for a phone app.
Step 3 — The steps file: first instance, admin, two service accounts
sudo tee /etc/zitadel/steps.yaml >/dev/null <<'EOF'
FirstInstance:
InstanceName: littlebit
DefaultLanguage: en
# Written by the setup step, which runs as the zitadel user; moved to /etc/zitadel in Step 4.
PatPath: /var/lib/zitadel/provisioner.pat
LoginClientPatPath: /var/lib/zitadel/login-client.pat
Org:
Name: Littlebit
Human:
UserName: admin
FirstName: Instance
LastName: Admin
Email:
Address: admin@example.com
Verified: true
Password: "<a strong initial password>"
PasswordChangeRequired: true
Machine:
Machine:
Username: provisioner
Name: Provisioning service account
Pat:
ExpirationDate: "2030-01-01T00:00:00Z"
LoginClient:
Machine:
Username: login-client
Name: Login V2 client
Pat:
ExpirationDate: "2030-01-01T00:00:00Z"
EOF
sudo chown root:zitadel /etc/zitadel/steps.yaml && sudo chmod 0640 /etc/zitadel/steps.yamlThree accounts come out of this, and the rest of the book uses each for one thing:
admin, a human in the first organisation with the instance-owner role. It is for the console, by a person, and for nothing automated.PasswordChangeRequired: truemakes the first console login set a real password.provisioner, a machine account with the instance-owner role and a personal access token written toprovisioner.pat. Chapter 4 uses it for every API call that creates an organisation, a project, an app; Chapter 8's command-line tool is built on it. It can do anything on the instance, which is why its token lives in a root-only file and is never given to a running service.login-client, a machine account whose token the Login V2 process uses to drive the session API on people's behalf. The steps file'sLoginClientblock is the documented way to create it, and the login works with the token it writes; Zitadel's own compose file creates it the same way.
The first organisation, Littlebit, is where these accounts live. It is not an app's organisation
and no app user is ever created in it.
Step 4 — Schema, then setup
Both as the zitadel user, so that nothing about this ever needed the superuser:
sudo -u zitadel zitadel init zitadel --config /etc/zitadel/config.yaml
sudo -u zitadel zitadel setup --config /etc/zitadel/config.yaml --steps /etc/zitadel/steps.yaml \
--masterkeyFile /etc/zitadel/masterkey --tlsMode external --init-projectionsThe first command ends on verify unique constraints; the second, after a page of projection starts prefilling lines, on setup completed. --tlsMode external is the combination the config
file already states — HTTPS outside, plain inside — and the flag overrides the file, so say it on
every phase and never disagree with yourself. --init-projections fills the read models during setup
rather than on first start, which makes the first start fast.
If setup fails part-way and you run it again, it fails again with Errors.Instance.Domain.AlreadyExists,
because the instance was created before whatever failed. The clean recovery at this stage, before the
instance holds anything, is to drop and recreate the database as Chapter 2 did and run both commands
again. Do not reach for setup cleanup here; it is for stuck migrations on a live instance.
Now move the two tokens where they belong. The login process needs to read its own; the provisioner's is for a person or a tool with root on this host:
sudo install -o root -g zitadel-login -m 0640 /var/lib/zitadel/login-client.pat /etc/zitadel/login-client.pat
sudo install -o root -g root -m 0600 /var/lib/zitadel/provisioner.pat /etc/zitadel/provisioner.pat
sudo rm /var/lib/zitadel/*.pat
sudo ls -la /etc/zitadel-rw-r----- 1 root zitadel 467 config.yaml
-rw-r----- 1 root zitadel-login 278 login-client.pat
-rw-r----- 1 root zitadel 32 masterkey
-rw------- 1 root root 278 provisioner.pat
-rw-r----- 1 root zitadel 828 steps.yamlAnd confirm what setup created, straight from the projections, before starting anything:
sudo -u postgres psql -h 10.0.0.10 -d zitadel -Atc "select name from projections.instances;
select username, type from projections.users14;"littlebit
provisioner|2
admin@example.com|1
login-client|2Type 1 is a human, type 2 a machine. Three users, one instance, none of them the default.
Step 5 — Two units
sudo tee /etc/systemd/system/zitadel.service >/dev/null <<'EOF'
[Unit]
Description=Zitadel identity server
After=network-online.target
Wants=network-online.target
[Service]
User=zitadel
Group=zitadel
WorkingDirectory=/var/lib/zitadel
ExecStart=/usr/local/bin/zitadel start --config /etc/zitadel/config.yaml --masterkeyFile /etc/zitadel/masterkey --tlsMode external
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/zitadel
[Install]
WantedBy=multi-user.target
EOFThe login reads its settings from an environment file. The names below are the ones the bundle's
server.js and entrypoint.sh actually read: ZITADEL_API_URL for where the API is, and
ZITADEL_SERVICE_USER_TOKEN_FILE for the token. The second is read by entrypoint.sh, which
exports the file's contents as ZITADEL_SERVICE_USER_TOKEN and then runs whatever command follows
it — so the unit starts the login through that script. Started as a bare node apps/login/server.js,
the process comes up, answers its health route, and fails every sign-in with Flow initiation failed, because it never had a token and never tried to reach the API; that cost an hour in writing
this chapter.
sudo tee /etc/zitadel/login.env >/dev/null <<'EOF'
NODE_ENV=production
PORT=3000
HOSTNAME=127.0.0.1
ZITADEL_API_URL=http://127.0.0.1:8081
ZITADEL_SERVICE_USER_TOKEN_FILE=/etc/zitadel/login-client.pat
EOF
sudo chown root:zitadel-login /etc/zitadel/login.env && sudo chmod 0640 /etc/zitadel/login.env
sudo tee /etc/systemd/system/zitadel-login.service >/dev/null <<'EOF'
[Unit]
Description=Zitadel Login V2
After=network-online.target zitadel.service
Wants=network-online.target
[Service]
User=zitadel-login
Group=zitadel-login
WorkingDirectory=/opt/zitadel-login
EnvironmentFile=/etc/zitadel/login.env
ExecStart=/opt/zitadel-login/entrypoint.sh /usr/bin/node apps/login/server.js
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now zitadel zitadel-login
sleep 5; systemctl is-active zitadel zitadel-loginactive
activeHOSTNAME=127.0.0.1 is what makes the login bind loopback; without it Next.js listens on every
interface. The bundle's own prod script sets the same variable, and the Debian firewall is not what
should be keeping port 3000 private.
How the login tells Zitadel which instance it is calling about is worth one paragraph, because it is
not the Host header. On every API call the login sends x-zitadel-instance-host and
x-zitadel-public-host, both copied from the Host of the browser request it is serving — which,
through Chapter 5's gateway, is auth.example.com. Zitadel's configuration lists exactly those two
header names, under InstanceHostHeaders and PublicHostHeaders, as the ones it reads first when
resolving an instance. So the login needs no Host override of its own; it needs the gateway to
deliver the public Host to it, which pass_host: pass in Chapter 5 does. (The bundle also honours a
CUSTOM_REQUEST_HEADERS variable for adding headers to its API calls. Do not use it to set Host:
Node's HTTP client replaces that header with the connection's own address, and the setting is
silently ineffective.)
Step 6 — Prove it answers, and only for its own name
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8081/.well-known/openid-configuration
curl -s -H "Host: auth.example.com" http://127.0.0.1:8081/.well-known/openid-configuration | python3 -c "import sys,json; print(json.load(sys.stdin)['issuer'])"
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3000/ui/v2/login/healthy404
https://auth.example.com
200The first line is the fact this chapter opened with: without a Host that names the instance,
Zitadel has no instance to serve and says so with 404, logging unable to set instance beside it.
Chapter 5's gateway passes the public Host through unchanged, which is what turns that 404 into a
working identity provider. The second line is the issuer every token will carry, and it is the string
Chapter 5's gateway will demand. The third is the login process's own health route, which the gateway
can also use.
On db-1, Chapter 2's standing check now has its first row:
zitadel|zitadel|tThe console, once the gateway exists
The management console is served by the same process at /ui/console and reached, from Chapter 5
onward, at https://auth.example.com/ui/console. Sign in there once as admin, set the real
password, and turn on two things the API cannot: the SMTP settings under the instance's
notification settings, so that verification and recovery emails can be sent, and the instance's
branding if you want any. Nothing in Chapter 4 requires the console — every step there is an API call
with the provisioner's token — but a person who wants to see what those calls made will find it all
there.
What dev does differently
Chapter 9 runs both binaries from a terminal on the Mac against the Homebrew cluster, with
ExternalDomain: localhost, ExternalPort: 8080, ExternalSecure: false and --tlsMode disabled,
and the login started as node apps/login/server.js with the same six variables exported. The steps
file is the same shape, and its PatPath values point somewhere the terminal user can write. The one
trap that is the same on both: a config file with a FirstInstance: block and no --steps gives you
the ZITADEL instance with Password1!, quietly.
Is Zitadel ready? A closing checklist
- Setup created your instance, not
ZITADEL, shown byprojections.instances, and the three users areadmin,provisionerandlogin-client. - The masterkey is backed up with the database dumps and nowhere else.
provisioner.patis root-only and has never been pasted into a service's environment.- Both units are
activeafter a reboot, and both bind loopback:ss -ltnshows127.0.0.1:3000and[::]:8081or127.0.0.1:8081, and no public address. - The database session is TLS —
pg_stat_sslsaystforzitadel. - The issuer is exactly the public URL,
https://auth.example.com, with no port and no path. - Access token lifetime is deliberate, not the twelve-hour default.
Exercises
Stop
zitadel-loginand run through Step 6 again. The API still answers; what does a person see if they start a sign-in? Then stopzitadeland start onlyzitadel-login; read its journal. Write down which of the two a monitoring check has to watch to know that sign-in works.Run
zitadel setupa second time on the finished instance, with the same flags. Record what it prints and whether anything changed inprojections.users14. Then read the help forsetup cleanupand say in one sentence when you would use it.Change
AccessTokenLifetimein the config file to5m, restart, and check whether tokens issued afterwards actually carry a five-minuteexp— the setting is underDefaultInstance, and the instance already exists. If they do not, find the instance-level API or console setting that does apply to a running instance, and note which of the two places is the one that matters after day one.
4One App in Zitadel: Healthy Me
Everything an app needs from the identity provider is created in this chapter, with the provisioner's token, as API calls — eleven of them, in an order that matters. By the end, a person can register in Healthy Me with Google, GitHub or Apple and nothing else, gets a server-assigned ULID on their very first token, and cannot use that account to sign in to any other app you add later. The console shows every object afterwards; none of it needs the console to be made.
The calls below are shown against production, https://auth.example.com, through the gateway that
Chapter 5 builds. If you are reading straight through, run them from app-1 against
http://127.0.0.1:8081 with -H "Host: auth.example.com" added to every one; that is exactly how
they were verified, and it is what Chapter 9 does on the Mac with Host: localhost:8080.
Zitadel's API comes in two generations and this chapter uses both. Organisations, users and their
tokens are v2 resources: JSON under /v2/..., or, for the newest services, a Connect-RPC style
POST /zitadel.<service>.v2.<Service>/<Method> that takes the same JSON. Login policies, identity
providers, project roles, role grants and actions are still v1 management resources under
/management/v1/..., and every v1 call needs an x-zitadel-orgid header naming the organisation it
acts in. The v2 calls carry the organisation in the body instead.
Z=https://auth.example.com
PAT=$(sudo cat /etc/zitadel/provisioner.pat)
H=(-H "Authorization: Bearer $PAT" -H "Content-Type: application/json")Step 1 — The organisation
curl -s "${H[@]}" -X POST $Z/v2/organizations -d '{"name":"Healthy Me"}'{
"details": {
"sequence": "4",
"changeDate": "2026-09-14T03:47:03.862275Z",
"resourceOwner": "390676976144220428"
},
"organizationId": "390676976144220428"
}Keep the id; every later call needs it. ORG=390676976144220428 below.
This organisation is the app. Its users are the app's users; its login policy decides how they sign in; its identity providers are the ones the app offers. A person who registers here does not exist in any other organisation on the instance, which is what "one organisation per app" buys and what Chapter 8 demonstrates with a second one.
Step 2 — The project, and the two settings that keep other apps' users out
curl -s "${H[@]}" -X POST $Z/zitadel.project.v2.ProjectService/CreateProject \
-d "{\"organizationId\":\"$ORG\",\"name\":\"Healthy Me\",\"projectRoleAssertion\":true,
\"authorizationRequired\":true,\"projectAccessRequired\":true}"{
"projectId": "390677090732605708",
"creationDate": "2026-09-14T03:48:12.162217Z"
}PROJ=390677090732605708. The three booleans are the whole reason to read this step rather than
skim it:
projectRoleAssertionputs the person's roles on the token, under two claims namedurn:zitadel:iam:org:project:rolesandurn:zitadel:iam:org:project:<PROJ>:roles. Chapter 5's gateway reads the first.authorizationRequiredrefuses to issue a token to a person who holds no role in this project.projectAccessRequiredrefuses to issue a token to a person whose organisation has not been granted this project.
Without the last two, any user on the instance can complete a sign-in against this app. That was
tested: with both off, a Healthy Me user obtained a valid token for a second app in a different
organisation; with both on, the same attempt is refused at the moment the login finishes, with
Errors.User.ProjectRequired. A project created earlier without them is fixed with an update:
curl -s "${H[@]}" -X POST $Z/zitadel.project.v2.ProjectService/UpdateProject \
-d "{\"projectId\":\"$PROJ\",\"authorizationRequired\":true,\"projectAccessRequired\":true}"Step 3 — Roles
Roles are strings on a project. Minimal's permission templates in Chapter 6 name the same strings,
and APISIX in Chapter 5 copies them verbatim into X-User-Roles, so choose them once, here:
for r in member:Member premium:Premium; do
curl -s "${H[@]}" -H "x-zitadel-orgid: $ORG" -X POST $Z/management/v1/projects/$PROJ/roles \
-d "{\"roleKey\":\"${r%%:*}\",\"displayName\":\"${r##*:}\",\"group\":\"healthyme\"}"
doneEach answers with a details object and a rising sequence. A person with no role at all is
refused by authorizationRequired, so every registered person has to hold at least member; Step 8
arranges that automatically.
Step 4 — The native application
One application per platform gives each its own client id and its own redirect list, which is worth having the day one platform's build is compromised and you want to revoke it alone. The book creates one, for iOS, and Android and Mac are the same call with a different name and scheme:
curl -s "${H[@]}" -H "x-zitadel-orgid: $ORG" -X POST $Z/zitadel.application.v2.ApplicationService/CreateApplication -d "{
\"projectId\": \"$PROJ\",
\"name\": \"Healthy Me iOS\",
\"oidcConfiguration\": {
\"redirectUris\": [\"com.littlebit.healthyme://auth/callback\"],
\"postLogoutRedirectUris\": [\"com.littlebit.healthyme://auth/signedout\"],
\"responseTypes\": [\"OIDC_RESPONSE_TYPE_CODE\"],
\"grantTypes\": [\"OIDC_GRANT_TYPE_AUTHORIZATION_CODE\", \"OIDC_GRANT_TYPE_REFRESH_TOKEN\"],
\"applicationType\": \"OIDC_APP_TYPE_NATIVE\",
\"authMethodType\": \"OIDC_AUTH_METHOD_TYPE_NONE\",
\"version\": \"OIDC_VERSION_1_0\",
\"developmentMode\": false,
\"accessTokenType\": \"OIDC_TOKEN_TYPE_JWT\",
\"accessTokenRoleAssertion\": true,
\"idTokenRoleAssertion\": false,
\"idTokenUserinfoAssertion\": false,
\"skipNativeAppSuccessPage\": true,
\"loginVersion\": {\"loginV2\": {\"baseUri\": \"https://auth.example.com/ui/v2/login\"}}
}}"{
"applicationId": "390677214766563596",
"creationDate": "2026-09-14T03:49:26.090323Z",
"oidcConfiguration": {
"clientId": "390677214766629132"
}
}CLIENT=390677214766629132. There is no client secret in the answer and there should not be: a
native app is a public client, authMethodType NONE says so, and PKCE is what protects the code
exchange. Three settings are load-bearing:
accessTokenType: OIDC_TOKEN_TYPE_JWT. Zitadel's default access token is an opaque string that only Zitadel's introspection endpoint can read. A JWT is what lets the gateway verify every request locally against the published keys, with no call to Zitadel on the request path. Leave this at the default and Chapter 5 fails on every request withtoken is signed by unexpected algorithm.accessTokenRoleAssertion: trueputs the roles on the access token — the token the gateway sees — not only on the id token the app keeps.developmentMode: falserefuseshttp://redirect URIs. Chapter 9's dev app turns it on so that a local callback can behttp://localhost:4200/auth/callback; production never does.
The redirect scheme is the app's bundle identifier reversed, which is what iOS, Android and macOS all
accept for a custom scheme. skipNativeAppSuccessPage sends the person straight back to the app
after sign-in instead of showing a "you may close this window" page first.
Step 5 — The login policy: sign-in only through a provider
A new organisation inherits the instance's default policy, which allows usernames and passwords. Give Healthy Me its own:
curl -s "${H[@]}" -H "x-zitadel-orgid: $ORG" -X POST $Z/management/v1/policies/login -d '{
"allowUsernamePassword": false,
"allowRegister": true,
"allowExternalIdp": true,
"forceMfa": false,
"passwordlessType": "PASSWORDLESS_TYPE_NOT_ALLOWED",
"hidePasswordReset": true,
"ignoreUnknownUsernames": true,
"allowDomainDiscovery": false,
"disableLoginWithEmail": false,
"disableLoginWithPhone": true,
"passwordCheckLifetime": "864000s",
"externalLoginCheckLifetime": "864000s",
"mfaInitSkipLifetime": "2592000s",
"secondFactorCheckLifetime": "64800s",
"multiFactorCheckLifetime": "43200s"
}'The five lifetimes are protobuf durations and have to be written in seconds with an s — "240h"
is refused with invalid google.protobuf.Duration value. allowRegister: true is what lets a
person who has never been seen create an account by signing in with a provider; with
allowUsernamePassword: false there is no other way in. Chapter 9's dev organisation sets that one
true so that a password user can exercise the whole path without a provider account.
Read it back with GET $Z/management/v1/policies/login and check isDefault is now false.
Step 6 — Identity providers
Each provider is created on the organisation, then switched on in the login policy. The options
block is the same for all three and it is where the "register by signing in" behaviour actually
lives: isCreationAllowed and isAutoCreation together create the Zitadel user on first sign-in
from the provider's claims, isAutoUpdate refreshes name and email on later sign-ins, and
isLinkingAllowed: false refuses to attach a provider identity to an existing account — which, with
passwords off, cannot happen anyway, but say so.
Google. In the Google Cloud console, create an OAuth client of type Web application — the
sign-in happens in a browser, so from Google's side this is a web client even though your app is
native — and give it the authorized redirect URI https://auth.example.com/idps/callback. That is
Zitadel's own callback handler, served by the API process under /idps, and it is the same URL for
every provider on the instance; the console shows it on each provider's settings page, and it is the
one to copy rather than the older /ui/login/login/externalidp/callback form that belongs to the
retired login. Then:
curl -s "${H[@]}" -H "x-zitadel-orgid: $ORG" -X POST $Z/management/v1/idps/google -d '{
"name": "Google",
"clientId": "<client-id>.apps.googleusercontent.com",
"clientSecret": "<client-secret>",
"scopes": ["openid", "profile", "email"],
"providerOptions": {"isLinkingAllowed": false, "isCreationAllowed": true,
"isAutoCreation": true, "isAutoUpdate": true,
"autoLinking": "AUTO_LINKING_OPTION_UNSPECIFIED"}}'GitHub. Under Developer settings → OAuth Apps, register an app whose authorization callback
URL is the same Zitadel callback, and generate a client secret. GitHub does not standardise the
profile fields it returns, so first and last name may arrive empty; email arrives if the person has
a public one or the user:email scope is granted.
curl -s "${H[@]}" -H "x-zitadel-orgid: $ORG" -X POST $Z/management/v1/idps/github -d '{
"name": "GitHub",
"clientId": "<client-id>",
"clientSecret": "<client-secret>",
"scopes": ["openid", "profile", "email"],
"providerOptions": {"isLinkingAllowed": false, "isCreationAllowed": true,
"isAutoCreation": true, "isAutoUpdate": true,
"autoLinking": "AUTO_LINKING_OPTION_UNSPECIFIED"}}'Apple. Three objects in the Apple Developer account: an App ID with Sign in with Apple
enabled; a Services ID that is the client id, configured with your domain and the same Zitadel
callback as its return URL; and a key with Sign in with Apple enabled, downloaded once as a .p8
file. The request carries the key's contents base64-encoded:
curl -s "${H[@]}" -H "x-zitadel-orgid: $ORG" -X POST $Z/management/v1/idps/apple -d "{
\"name\": \"Apple\",
\"clientId\": \"com.littlebit.healthyme.signin\",
\"teamId\": \"<team-id>\",
\"keyId\": \"<key-id>\",
\"privateKey\": \"$(base64 -w0 AuthKey_<key-id>.p8)\",
\"scopes\": [\"name\", \"email\"],
\"providerOptions\": {\"isLinkingAllowed\": false, \"isCreationAllowed\": true,
\"isAutoCreation\": true, \"isAutoUpdate\": true,
\"autoLinking\": \"AUTO_LINKING_OPTION_UNSPECIFIED\"}}"Apple requires HTTPS and a domain it has verified; there is no way to exercise it against
localhost, which is why Chapter 9's dev organisation keeps passwords on. Apple also returns the
person's name only on the very first authorisation; if that sign-in fails part-way, the name is gone
until the person removes the app from their Apple ID and starts again.
Each of the three answers with an id. Switch each on for the organisation:
for ID in <google-id> <github-id> <apple-id>; do
curl -s "${H[@]}" -H "x-zitadel-orgid: $ORG" -X POST $Z/management/v1/policies/login/idps \
-d "{\"idpId\":\"$ID\",\"ownerType\":\"IDP_OWNER_TYPE_ORG\"}"
done
curl -s "${H[@]}" -H "x-zitadel-orgid: $ORG" $Z/management/v1/policies/login \
| python3 -c "import sys,json; p=json.load(sys.stdin)['policy']; print([i['idpName'] for i in p['idps']], p['allowUsernamePassword'])"['Google', 'GitHub', 'Apple'] FalseThe three creation calls and the activation were run with placeholder credentials to confirm the request shapes, and the three buttons then appeared on the login page; Zitadel does not contact the provider when the provider is created, only when a person signs in with it. What was not run in writing this book is a sign-in through a real Google, GitHub or Apple account, because the verification environment has no public domain. The callback path is read from Zitadel's source for this version; check the URL the console shows on the provider's page before you paste it into Google or Apple.
Step 7 — The action that assigns the server's user id
This is the twenty lines Chapter 1 promised. Zitadel runs it every time it is about to mint an access
token for anyone in this organisation. It reads the person's metadata; if there is no
minimal_user_id yet, it mints a ULID and stores one; either way it puts the value on the token as a
claim of that name.
/**
* Complement Token, trigger "Pre access token creation".
* Gives every user a server-assigned ULID the first time a token is minted for
* them, keeps it in user metadata, and asserts it as a claim on every token.
*/
function minimalUserId(ctx, api) {
var KEY = 'minimal_user_id';
var existing = null;
var md = ctx.v1.user.getMetadata();
if (md && md.metadata) {
for (var i = 0; i < md.metadata.length; i++) {
if (md.metadata[i].key === KEY) { existing = md.metadata[i].value; break; }
}
}
if (!existing) {
existing = ulid();
api.v1.user.setMetadata(KEY, existing);
}
api.v1.claims.setClaim(KEY, existing);
}
function ulid() {
var A = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
var t = Date.now();
var out = '';
for (var i = 9; i >= 0; i--) { out = A.charAt(t % 32) + out; t = Math.floor(t / 32); }
for (var j = 0; j < 16; j++) { out += A.charAt(Math.floor(Math.random() * 32)); }
return out;
}Save it as minimal-user-id.js and register it, then bind it to the trigger. Actions are per
organisation; flow type 2 is "Complement Token" and trigger type 5 is "Pre access token
creation", the two numbers Zitadel's own flow listing gives back:
python3 -c "import json; print(json.dumps({'name':'minimalUserId','script':open('minimal-user-id.js').read(),'timeout':'5s','allowedToFail':False}))" > action.json
curl -s "${H[@]}" -H "x-zitadel-orgid: $ORG" -X POST $Z/management/v1/actions -d @action.json{
"details": {
"sequence": "1",
"creationDate": "2026-09-14T03:50:02.513180Z",
"resourceOwner": "390676976144220428"
},
"id": "390677275885961484"
}curl -s "${H[@]}" -H "x-zitadel-orgid: $ORG" -X POST $Z/management/v1/flows/2/trigger/5 \
-d '{"actionIds":["390677275885961484"]}'
curl -s "${H[@]}" -H "x-zitadel-orgid: $ORG" $Z/management/v1/flows/2 | head -c 300{"flow":{"type":{"id":"2","name":{"key":"Action.Flow.Type.CustomiseToken","localizedMessage":"Complement Token"}},...,"triggerActions":[{"triggerType":{"id":"5","name":{"key":"Action.TriggerType.PreAccessTokenCreation","localizedMessage":"Pre access token creation"}},"actions":[{"id":"390677275885961484",...,"name":"minimalUserId"Four things the script's shape settles, because each was the alternative:
- It runs at token time, not at registration. Zitadel has a "post creation" trigger on the external-authentication flow, but that flow belongs to the old login and does not fire under Login V2. Token creation fires for every login on every version. Minting lazily also means a user created any other way — by an administrator, by an import — gets an id the first time they sign in.
allowedToFail: false. If the action throws, no token is issued. A token without the claim would reach the gateway, which refuses it with403anyway; failing at the source is clearer.- The metadata value is JSON-encoded.
api.v1.user.setMetadatastores the string with its quotes, so reading it back through the metadata API returns"01M2..."base64-encoded with the quotes inside. The claim on the token is the bare 26 characters, which is what the gateway reads; the metadata is the durable copy and is never parsed by anything but this script. Math.randomis enough. A ULID's random half exists to make ids unique, not secret; the server-assigned id is not a credential, it is a name. The 48-bit timestamp half makes ids from the same millisecond collide only if their 80 random bits match.
Actions V1 is the mechanism that runs JavaScript inside Zitadel. It still works in v4 and is on a
deprecation path for v5, where Actions V2 — an HTTP call to an endpoint you host, with the same
preaccesstoken trigger — replaces it. The migration is the same script behind an HTTP endpoint;
nothing in the gateway or in Minimal changes.
Step 8 — Every new person gets member
authorizationRequired refuses a token to anyone with no role, so someone has to grant member to
each person the provider just created. In this book that is a grant per user, made by whatever
creates the user:
curl -s "${H[@]}" -H "x-zitadel-orgid: $ORG" -X POST $Z/management/v1/users/<user-id>/grants \
-d "{\"projectId\":\"$PROJ\",\"roleKeys\":[\"member\"]}"For provider sign-ins nothing of yours creates the user, so the grant has to be automatic. Zitadel
offers two hooks for it, and neither is used here: Actions V1's post-creation trigger belongs to the
external-authentication flow, which does not run under Login V2, and Actions V2's user.human.added
event needs an HTTP target you host. This book does not ship either, and says so plainly: the
verification environment made its grants by hand, and the right place to put the automatic one is
Chapter 8's provisioning tool, which already holds the provisioner's token. Until then, a person who registers and holds no role is refused at login with
Errors.User.ProjectRequired, which is a loud failure rather than a quiet one.
Step 9 — The first token, by hand
Nothing above has been proven until a token comes out of it with the claim on. Zitadel's session API
lets a script do what the login page does, which is how every token in this book was obtained and how
Chapter 8's smoke test works. With a dev user that has a password — Chapter 9's alice — the flow is
four calls: start the authorization request, create a session with the login-client's token, finish
the request with that session, exchange the code.
LOGIN_PAT=$(sudo cat /etc/zitadel/login-client.pat)
VERIFIER=$(head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 64)
CHALLENGE=$(printf %s "$VERIFIER" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=')
SCOPE="openid profile email offline_access urn:zitadel:iam:org:id:$ORG urn:zitadel:iam:org:project:id:$PROJ:aud urn:zitadel:iam:org:project:roles"
# 1. the authorization request, which redirects to the login with an id
AUTH=$(curl -s -o /dev/null -w '%{redirect_url}' "$Z/oauth/v2/authorize?client_id=$CLIENT&redirect_uri=com.littlebit.healthyme://auth/callback&response_type=code&scope=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$SCOPE")&code_challenge=$CHALLENGE&code_challenge_method=S256&prompt=login" | sed 's/.*authRequest=//')
# 2. a session for the person, as the login client
SESSION=$(curl -s -H "Authorization: Bearer $LOGIN_PAT" -H "Content-Type: application/json" -X POST $Z/v2/sessions \
-d '{"checks":{"user":{"loginName":"alice@healthyme.localhost"},"password":{"password":"<her password>"}}}')
SID=$(echo "$SESSION" | python3 -c "import sys,json;print(json.load(sys.stdin)['sessionId'])")
STOK=$(echo "$SESSION" | python3 -c "import sys,json;print(json.load(sys.stdin)['sessionToken'])")
# 3. finish the request with that session; the answer is the app's callback URL carrying the code
CODE=$(curl -s -H "Authorization: Bearer $LOGIN_PAT" -H "Content-Type: application/json" -X POST $Z/v2/oidc/auth_requests/$AUTH \
-d "{\"session\":{\"sessionId\":\"$SID\",\"sessionToken\":\"$STOK\"}}" | sed 's/.*code=//; s/".*//')
# 4. the exchange, exactly as the app does it
curl -s -X POST $Z/oauth/v2/token -d "grant_type=authorization_code&code=$CODE&redirect_uri=com.littlebit.healthyme://auth/callback&client_id=$CLIENT&code_verifier=$VERIFIER" \
| python3 -c "import sys,json,base64; t=json.load(sys.stdin); p=t['access_token'].split('.')[1]; print(json.dumps(json.loads(base64.urlsafe_b64decode(p+'='*(-len(p)%4))), indent=1))"{
"aud": ["390677214766629132", "390677090732605708"],
"client_id": "390677214766629132",
"exp": 1789401040,
"iat": 1789357840,
"iss": "https://auth.example.com",
"jti": "V2_390677338834075916-at_390677338834141452",
"minimal_user_id": "01M2F0KNNC4M7Z1HEJHHG80JGE",
"nbf": 1789357840,
"sub": "390677090900377868",
"urn:zitadel:iam:org:project:390677090732605708:roles": {"member": {"390676976144220428": "healthy-me.example.com"}},
"urn:zitadel:iam:org:project:roles": {"member": {"390676976144220428": "healthy-me.example.com"}}
}Read it against the three rules of Chapter 1. minimal_user_id is the server's ULID, and running the
flow again returns the same value: the action found the metadata the second time. sub is Zitadel's
own user id, present and unused. The roles are there as a claim keyed by role name. aud carries the
client id and the project id, and iss is the public URL — the two things Chapter 5 checks before it
believes any of the rest.
The scope string asked for three Zitadel-specific scopes, and the app's scope list carries all
three, always. urn:zitadel:iam:org:id:<ORG> names the organisation: it makes the login page show
this organisation's policy and providers — without it the hosted login shows the instance's default
organisation, which has no providers and a password field, and that is what you will see the first
time you forget it — and it makes Zitadel refuse the sign-in unless the person belongs to that
organisation, which is a second lock on the door Step 2 closed. urn:zitadel:iam:org:project:id:<PROJ>:aud
adds the project to the audience. urn:zitadel:iam:org:project:roles asks for the roles claim; an
app that forgets it gets a token with no roles, which the gateway turns into an empty X-User-Roles
and Minimal refuses everywhere.
What the app does with all this
The app's side is a standard OpenID Connect authorization-code flow with PKCE, in the system browser
— ASWebAuthenticationSession on Apple platforms, Custom Tabs on Android — against
https://auth.example.com, with the client id from Step 4, the redirect scheme from Step 4, and the
scope string from Step 9, organisation scope included. Through the gateway, that request lands on a
page headed "Welcome back!" with a username field, "Register new user", and three buttons — Google,
GitHub, Apple — under "or sign in with"; on production the username field is absent because Step 5
turned passwords off. Two settings on the app's side are part of the "one app, one sign-in"
rule, because the login domain is shared by every app on the instance and its cookie would otherwise
sign a person into the second app silently:
- send
prompt=loginon every authorization request, which makes Zitadel ask for credentials even when its cookie holds a session, and - open the browser session as ephemeral (
prefersEphemeralWebBrowserSessionon Apple platforms), so the login domain's cookie is not shared with the other app's session in the first place.
The app keeps the refresh token in the platform keychain, refreshes the access token silently before
it expires, and sends only the access token to the API. It never sees a Minimal access token and
never sets an X- header.
Is the app ready in Zitadel? A closing checklist
- The project has
authorizationRequiredandprojectAccessRequiredon, and a user from another organisation is refused withErrors.User.ProjectRequired— tested, not assumed. - The native app issues JWT access tokens with roles asserted, and its redirect list holds only the app's scheme.
- The login policy has passwords off, registration on, and the three providers active, and
isDefaultisfalse. - The action is bound to flow 2, trigger 5, and two consecutive tokens for the same person carry
the same
minimal_user_id. - The app's scope string carries the audience and roles scopes, and
prompt=loginis on every request. - Whatever grants
memberto a new person exists, or you have written down that it does not yet.
Exercises
Remove
urn:zitadel:iam:org:project:rolesfrom the scope in Step 9 and decode the token again. Then put it back and removeprojectRoleAssertionfrom the project instead. Which of the two claims survives each change, and which one does the gateway in Chapter 5 actually read?Create a second native application in the same project, "Healthy Me Android", with its own scheme. Obtain a token with its client id and compare
audandclient_idwith the iOS token. Say what the gateway would need to know to accept both, and what it would need to revoke one.Deactivate the action and obtain a token. Confirm the claim is gone, then confirm what the gateway does with that token once Chapter 5 is built. Reactivate the action and check that the next token carries the same ULID as before — the metadata outlived the action.
5APISIX: The Boundary
APISIX is the only process on app-1 with a public address. It terminates TLS for two names,
auth.example.com and api.example.com; sends the first to Zitadel and its login; and, for the
second, verifies the JWT on every request, throws away every identity header the caller sent, sets
the five headers Minimal trusts from the verified claims, and forwards. This chapter installs it as
a systemd unit, configures it from one file with no etcd and no admin API, and then proves each
of Volume II, Chapter 10's four requirements with a request that tries to break it.
The whole gateway is about a hundred lines of YAML and forty of Lua. It is printed in full in Appendix D; the sections below show it as it was built, one route at a time.
Step 1 — Install from the vendor's repository
The Apache APISIX project publishes Debian packages through the repos.apiseven.com repository, with
separate paths for amd64 and arm64. The package carries its own OpenResty under
/usr/local/openresty, the gateway under /usr/local/apisix, and a systemd unit:
curl -fsSL http://repos.apiseven.com/pubkey.gpg | sudo gpg --dearmor -o /usr/share/keyrings/apisix.gpg
ARCH=$(dpkg --print-architecture)
if [ "$ARCH" = arm64 ]; then REPO=http://repos.apiseven.com/packages/arm64/debian; else REPO=http://repos.apiseven.com/packages/debian; fi
echo "deb [signed-by=/usr/share/keyrings/apisix.gpg] $REPO debian12 main" | sudo tee /etc/apt/sources.list.d/apisix.list
sudo apt update
sudo apt install -y apisix
apisix version
systemctl is-enabled apisix3.18.0
disabledInstalled but not enabled, and with a default configuration that expects etcd. Do not start it yet.
The unit it installed is Type=forking, runs /usr/bin/apisix start from /usr/local/apisix, and
reloads with apisix reload; it needs no change.
Step 2 — Standalone mode: one file of rules, no etcd, no admin API
Two files under /usr/local/apisix/conf/. The first, config.yaml, is the process configuration:
which ports to listen on and where the rules come from. Replace the package's copy:
# /usr/local/apisix/conf/config.yaml
apisix:
node_listen:
- 80
enable_http2: true
ssl:
enable: true
listen:
- port: 443
enable_admin: false
deployment:
role: data_plane
role_data_plane:
config_provider: yaml
nginx_config:
error_log_level: warnconfig_provider: yaml is standalone mode: every route, upstream and certificate is read from
apisix.yaml beside this file, re-read within a second of any change, and applied without restarting
a worker. The Admin API does not exist in this mode, which is exactly right for a gateway whose
configuration should be a file under version control and nothing else. enable_http2 has to be at
this level; the older per-listener form is refused at apisix test with a message saying so.
The second file, apisix.yaml, holds the rules and must end with a line reading #END, which is
how APISIX knows the file was written completely before it reads it. Start with the upstreams and
nothing else, and check that the process comes up at all:
# /usr/local/apisix/conf/apisix.yaml
upstreams:
- id: zitadel
nodes:
"127.0.0.1:8081": 1
type: roundrobin
pass_host: pass
- id: zitadel-grpc
nodes:
"127.0.0.1:8081": 1
type: roundrobin
scheme: grpc
pass_host: pass
- id: zitadel-login
nodes:
"127.0.0.1:3000": 1
type: roundrobin
pass_host: pass
- id: minimal
nodes:
"127.0.0.1:3045": 1
type: roundrobin
pass_host: node
routes: []
#ENDcd /usr/local/apisix && sudo apisix test
sudo systemctl enable --now apisix
systemctl is-active apisixconfiguration test is successful
activepass_host: pass on the three Zitadel upstreams is Chapter 3's fact at work: Zitadel resolves the
instance from the Host header, so the gateway must hand it the public name unchanged. Minimal does
not care, and node sends it the upstream address instead.
Step 3 — TLS for two names
APISIX takes certificates as inline PEM in the ssls section, matched to requests by SNI. That is
inconvenient for files certbot renews on disk, so the arrangement is: certbot obtains and renews
into /etc/letsencrypt, and a deploy hook renders the PEM into apisix.yaml, which APISIX picks up
without a restart.
certbot's HTTP challenge needs port 80, which APISIX holds. Give certbot its own port and let APISIX proxy the challenge path to it — one more upstream and one route, permanent:
upstreams:
# ... the four above, then:
- id: acme
nodes:
"127.0.0.1:8402": 1
type: roundrobin
routes:
- id: acme-challenge
uri: /.well-known/acme-challenge/*
priority: 100
upstream_id: acmesudo apt install -y certbot
sudo certbot certonly --standalone --http-01-port 8402 --non-interactive --agree-tos -m ops@example.com \
-d auth.example.com -d api.example.comOne certificate for both names, under /etc/letsencrypt/live/auth.example.com/. The deploy hook,
which certbot runs after every successful renewal, replaces the ssls block:
sudo tee /etc/letsencrypt/renewal-hooks/deploy/apisix.sh >/dev/null <<'EOF'
#!/bin/bash
# Render the live certificate into APISIX's rules file. APISIX re-reads the file on its own.
set -euo pipefail
LIVE=/etc/letsencrypt/live/auth.example.com
RULES=/usr/local/apisix/conf/apisix.yaml
python3 - "$LIVE" "$RULES" <<'PY'
import re, sys
live, rules = sys.argv[1], sys.argv[2]
ind = lambda t: "\n".join(" " + l for l in t.strip().splitlines())
cert = open(f"{live}/fullchain.pem").read(); key = open(f"{live}/privkey.pem").read()
block = ("ssls:\n - id: public\n snis:\n - auth.example.com\n - api.example.com\n"
" cert: |\n" + ind(cert) + "\n key: |\n" + ind(key) + "\n")
s = open(rules).read()
s = re.sub(r"(?ms)^ssls:\n.*?(?=^upstreams:)", "", s) # drop the old block, if any
s = s.replace("upstreams:", block + "\nupstreams:", 1)
open(rules, "w").write(s)
PY
EOF
sudo chmod 0755 /etc/letsencrypt/renewal-hooks/deploy/apisix.sh
sudo /etc/letsencrypt/renewal-hooks/deploy/apisix.shThe key ends up inside a file readable by the APISIX process, which is the same exposure the
Nginx it is built on has always had. Keep apisix.yaml at mode 0640, owned by root and the
process's group.
What was verified in writing this: the ssls shape above, with a self-signed certificate for the two
test names, served over HTTP/2 with the right certificate chosen by SNI. What was not: a real
Let's Encrypt issuance, since the verification hosts have no public name. The certbot line and the
hook directory are from certbot's own documentation; run the issuance once by hand and read what it
prints before trusting the timer.
Step 4 — The identity host: Zitadel and its login
Three routes, all matched on the auth.example.com host. The login is a path prefix; native gRPC
callers — Zitadel's own SDKs and tools, not the console — need an h2c upstream, which APISIX selects
by the request's content type; everything else is plain HTTP/1.1 to the same port, which is how the
console, the OIDC endpoints and the REST APIs are served:
routes:
- id: zitadel-login
host: auth.example.com
uri: /ui/v2/login/*
upstream_id: zitadel-login
- id: zitadel-grpc
host: auth.example.com
uri: /*
vars: [["http_content_type", "~~", "^application/grpc"]]
priority: 10
upstream_id: zitadel-grpc
- id: zitadel
host: auth.example.com
uri: /*
upstream_id: zitadelNow the 404 from Chapter 3 becomes a working identity provider, because the public Host reaches
it:
curl -s https://auth.example.com/.well-known/openid-configuration | python3 -c "import sys,json; print(json.load(sys.stdin)['issuer'])"
curl -s -o /dev/null -w "%{http_code} http/%{http_version}\n" https://auth.example.com/ui/v2/login/healthy
curl -s -o /dev/null -w "%{http_code}\n" https://auth.example.com/ui/console/https://auth.example.com
200 http/2
200Step 5 — The API host: verify, strip, set, forward
This is the route the volume exists for. It is one route with five plugins, and the order they run in is fixed by APISIX rather than by the order written, so the order is stated here and then relied on:
serverless-pre-function, in the rewrite phase, runs first and deletes every header a caller could use to claim an identity — the five Minimal headers,LB-Access-Token, and the three headers the next plugin will itself set — and drops thelb-access-tokenquery parameter.openid-connect, in the access phase, requires a bearer token, verifies its signature against Zitadel's published keys, checks the issuer, checks that the audience contains this app's client id, and, on success, writes the token's claims into anX-Userinforequest header as base64 JSON.serverless-post-function, also in the access phase but after the previous plugin by priority, reads that header, refuses the request unless it carries a well-formedminimal_user_id, sets the five headers from the claims, and deletesX-UserinfoandAuthorizationso that nothing but the five reaches Minimal.limit-count, keyed on the user id the previous step just set.- The upstream.
- id: minimal-api
host: api.example.com
uri: /minimal/*
upstream_id: minimal
plugins:
serverless-pre-function:
phase: rewrite
functions:
- |
return function(conf, ctx)
-- 1. Nothing a caller sends may ever reach Minimal as identity.
for _, h in ipairs({"X-Org-Id","X-Project-Id","X-Space-Id","X-User-Id","X-User-Roles",
"LB-Access-Token","X-Userinfo","X-Access-Token","X-ID-Token"}) do
ngx.req.clear_header(h)
end
local args = ngx.req.get_uri_args()
if args["lb-access-token"] ~= nil then
args["lb-access-token"] = nil
ngx.req.set_uri_args(args)
end
end
openid-connect:
client_id: "390677214766629132" # the native app's client id, Chapter 4 Step 4
client_secret: "not-used-for-bearer-validation"
discovery: "https://auth.example.com/.well-known/openid-configuration"
bearer_only: true
use_jwks: true
token_signing_alg_values_expected: RS256
claim_validator:
issuer:
valid_issuers: ["https://auth.example.com"]
audience:
required: true
match_with_client_id: true
set_userinfo_header: true
set_access_token_header: false
set_id_token_header: false
set_refresh_token_header: false
unauth_action: deny
serverless-post-function:
phase: access
functions:
- |
return function(conf, ctx)
-- 2. Translate the verified claims into the five headers Minimal trusts.
local core = require("apisix.core")
local raw = ngx.req.get_headers()["X-Userinfo"]
if not raw then return 401, {message = "no verified identity"} end
local claims = core.json.decode(ngx.decode_base64(raw))
local uid = claims and claims["minimal_user_id"]
if type(uid) ~= "string" or #uid ~= 26 or not uid:match("^[0-9A-HJKMNP-TV-Z]+$") then
return 403, {message = "token carries no server-assigned user id"}
end
local roles = {}
local r = claims["urn:zitadel:iam:org:project:roles"]
if type(r) == "table" then
for k, _ in pairs(r) do roles[#roles + 1] = k end
end
table.sort(roles)
ngx.req.clear_header("X-Userinfo")
ngx.req.clear_header("Authorization")
ngx.req.set_header("X-Org-Id", "lbl")
ngx.req.set_header("X-Project-Id", "healthyme")
ngx.req.set_header("X-Space-Id", "live")
ngx.req.set_header("X-User-Id", uid)
ngx.req.set_header("X-User-Roles", table.concat(roles, ","))
end
limit-count:
count: 600
time_window: 60
key_type: var
key: http_x_user_id
rejected_code: 429
policy: localSome of that is not obvious from reading it, and each of these was learned by running it:
client_secretis required by the plugin's schema even in bearer mode, where it is never sent anywhere. Any string satisfies it; a value that says so is kinder to the next reader.use_jwkswithbearer_onlyis the local-verification path. The plugin fetches the discovery document once, caches the JWKS, and checks the signature itself; there is no call to Zitadel per request. The discovery URL is the public one, through this same gateway, which is fine — it is one request at startup and on key rotation.- The audience check is real and it is
403. A token minted for a different application on the same instance is signed by the same key and carries the same issuer; onlyaudtells them apart. Withmatch_with_client_id, a Sleep Well token on this route is refused with{"error":"mismatched audience"}before any Lua runs. Without it, the post-function would refuse it anyway for lacking the claim — unless that app's organisation also runs the action, in which case a Sleep Well member would reach Healthy Me's API as a Healthy Me member. Set it. - Lua patterns have no
{n}quantifier. The first version of the ULID check was written^[0-9A-HJKMNP-TV-Z]{26}$, which is a valid pattern that matches nothing, and every real token was refused with the403above. Check the length separately. X-Org-Id,X-Project-IdandX-Space-Idare constants of the route, because this route is this app. Chapter 8 adds a second route with its own three constants and its own client id; that is the whole per-app configuration on the gateway.- The roles claim is an object keyed by role name, whose values say which organisation granted
the role. The gateway takes the keys, sorts them, and joins with commas — the form Volume I,
Chapter 4 says
X-User-Rolestakes. Zitadel puts the claim on the token only if the app asked for the roles scope, so an app that forgets it arrives with an empty header and is refused by every template. limit-countkeyed on the user id, not the address. By the time it runs,X-User-Idis the verified ULID; six hundred requests a minute per person is generous for a phone app and a wall for a script.
apisix test and the file is live within a second. There is no restart in this book after Step 2.
Step 6 — Prove it, with requests that try to break it
Every claim in Step 5 was checked with the requests below, and the outputs are the ones to expect.
$T is a token from Chapter 4, Step 9.
No token, a garbage token, a token from another issuer:
curl -s -o /dev/null -w "%{http_code}\n" https://api.example.com/minimal/system/api/v1/org
curl -s -o /dev/null -w "%{http_code}\n" https://api.example.com/minimal/system/api/v1/org -H "Authorization: Bearer nope"
curl -s -o /dev/null -w "%{http_code}\n" https://api.example.com/minimal/system/api/v1/org \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IngifQ.eyJpc3MiOiJodHRwczovL290aGVyLmV4YW1wbGUiLCJzdWIiOiIxIn0.c2ln"401
401
401The gateway's error log names each: No bearer token found in request, token is signed by unexpected algorithm "HS256", and a signature failure. None reached Minimal.
A token for the other app: 403 with {"error":"mismatched audience"} — Chapter 8 produces the
token.
A valid token together with every forged header a caller could think of. To see what actually arrives at the upstream, point a copy of the route at a tiny echo server for the duration of the test — APISIX reloads the file in a second, so this is a two-minute exercise:
curl -s "https://api.example.com/minimal/echo/v1?lb-access-token=mus:smuggled&keep=1" \
-H "Authorization: Bearer $T" \
-H "X-Org-Id: evil" -H "X-Project-Id: evil" -H "X-Space-Id: evil" -H "X-User-Id: evil" -H "X-User-Roles: admin" \
-H "LB-Access-Token: mus:smuggled" -H "X-Userinfo: eyJtaW5pbWFsX3VzZXJfaWQiOiJFVklMIn0=" -H "X-Access-Token: evil"What the echo upstream received, every header:
path: /minimal/echo/v1?keep=1
Accept: */*
Host: api.example.com
User-Agent: curl/8.7.1
X-Forwarded-For: ...
X-Forwarded-Host: api.example.com
X-Forwarded-Port: 443
X-Forwarded-Proto: https
X-Org-Id: lbl
X-Project-Id: healthyme
X-Real-IP: ...
X-Space-Id: live
X-User-Id: 01M2F0KNNC4M7Z1HEJHHG80JGE
X-User-Roles: memberThe five headers carry the gateway's values, not the caller's. LB-Access-Token, X-Userinfo,
X-Access-Token and the query parameter are gone. Authorization is gone too; Minimal's own
middleware would have dropped it, and now it never arrives.
The same forged request against Minimal itself. Reading the organisation record needs a role in
org_read_roles, which is admin; the caller claims admin in a header and smuggles a Minimal
admin token minted directly on app-1:
curl -s -o /dev/null -w "%{http_code}\n" "https://api.example.com/minimal/system/api/v1/org?lb-access-token=$ADMIN_TOKEN" \
-H "Authorization: Bearer $T" -H "X-User-Roles: admin" -H "LB-Access-Token: $ADMIN_TOKEN"403Minimal refused it as member, which is what the token says. The same admin token presented to
Minimal's port directly on app-1 answers 200 — it is a real credential, and the only reason it
is harmless from outside is that outside cannot reach that port. That is Volume II's "be the only
path in", and it is the next step.
Step 7 — Be the only path in
Minimal, Zitadel and the login all bind loopback (127.0.0.1:3045, :8081, :3000), which is what
the units in Chapters 3 and 6 do and what ss -ltn should show. The host firewall is the second
layer; with nftables or ufw, allow 22 from your administrative network, 80 and 443 from
anywhere, and nothing else inbound. Postgres on db-1 accepts only app-1's private address, per
Chapter 2.
sudo ss -ltnp | awk 'NR==1 || /LISTEN/' | grep -vE "127.0.0.1|\[::1\]"The only lines left should be APISIX on 80 and 443, and sshd.
What dev does differently
The same two files, with node_listen: 8080, no ssls, http://localhost:8080 in every URL, no
host: on the routes because everything is localhost, and the four upstreams pointing at the Mac
through the VM's host.lima.internal name. Chapter 9 has the file. The Lua is byte-identical.
Is the boundary load-bearing? A closing checklist
Volume II, Chapter 10's list, answered for this gateway:
- It strips the five headers before it sets its own — shown by the echo, not by reading the config. Repeat the echo after any change to the route.
- Minimal's listener has no route from outside —
ss -ltnonapp-1and the firewall rules, read this week. - The channel decision is made from something the identity provider controls. Every caller
through this route is a person on the direct channel; there is no
mcp:orm2m:prefix on any header it sets, andagent_identityis left out of Minimal's configuration in Chapter 6. A machine caller gets its own route, its own Zitadel service account and its own constants, when there is one. - Raw headers, deliberately. This deployment sets the five headers on every request and never mints a Minimal access token for a person. The trade Volume II names is accepted: the gateway's translation is trusted on every request, and in exchange a revoked Zitadel account stops working at the end of its last access token's hour rather than whenever someone remembers to suspend a Minimal token.
mcp.behind_reverse_proxyis irrelevant, because the MCP listener is not fronted by this gateway and is not enabled in Chapter 6.
Exercises
Remove
match_with_client_idfrom the route, obtain a token for Chapter 8's second app, and send it to this route. Record the status and the reason. Then run Chapter 4's action on the second app's organisation too and repeat. Put the setting back before going on.Change the ULID check back to the
{26}form and send a valid token. Watch it fail, read the gateway's error log, and note that the log says only which plugin exited with403. Write the one line you would add to the Lua to make that failure name its cause.The
limit-countplugin counts perX-User-Id. Send six hundred and one requests with one token inside a minute and record the status and headers of the last. Then decide whether the login host needs a limit too, what it would be keyed on before anyone is signed in, and add it.
6Minimal Behind the Boundary
Minimal is the piece this book changes least. It runs as it did in Volume II, Chapter 9, with a
configuration file whose values are chosen for a listener that only the gateway can reach, and it
holds one organisation, one project and one space for the app, set up exactly as Volume I, Chapter 3
set up Acme. What is new is the contract at its front door: every request arrives with the five
headers the gateway wrote, X-User-Id is always a server-assigned ULID, and X-User-Roles is always
a subset of the role names Chapter 4 created in Zitadel. This chapter installs the service, writes
the configuration that fits that contract, bootstraps the app, and puts the first table behind a
template and a row scope so that a person sees only their own rows.
Step 1 — The service
A user, a directory for its state, a directory for its configuration, and the binary:
sudo useradd --system --home-dir /var/lib/minimal --create-home --shell /usr/sbin/nologin minimal
sudo install -d -m 0750 -o root -g minimal /etc/minimal
sudo install -m 0755 minimal /usr/local/bin/minimalThe configuration file is the sample from Volume II, Chapter 9 with the values below changed, and nothing else. Every changed line is a decision this deployment has made:
# /etc/minimal/config.yml -- the lines that differ from sample/config.yml
service:
host: "127.0.0.1" # only APISIX, on this host, ever connects
port: "3045"
environment: "production"
server_key: "<a real secret, generated for this deployment>"
log_level: "info"
debug_mode: false
show_request_details: false
strict_local_host: true
definition_store:
type: postgres
host: 10.0.0.10
port: "5432"
schema: minimal
username: minimalist
password_source: file
password: '<minimalist-password>'
ssl_mode: "verify-full" # Chapter 2's certificate, verified
max_open_connections: 20
encrypt_database: true
encryption_key_source: env # MINIMAL_ENCRYPTION_KEY, from the unit's environment file
encryption_key: ''
auto_api:
permission_fail_open: false # a lookup failure is a refusal, not a pass
access_token:
max_per_user: 5 # nobody mints person-tokens here; keep the ceiling low
default_expiry_days: 30
retain_dead_days: 30
sweep_interval_secs: 86400
# agent_identity: left out entirely. No caller reaches this listener with an mcp: or m2m:
# prefix, because the gateway never writes one; leaving the section out means such a prefix
# would not be recognised even if something did.
mcp:
enabled: falseThree of those deserve the sentence Volume II already gave them. permission_fail_open: false is
the setting the sample ships true and calls temporary; a production deployment whose tables all
carry a template turns it off, and this one's tables will. agent_identity is omitted rather than
configured, per Volume II, Chapter 10: no caller through this gateway is an agent or a machine, and a
section that is absent cannot be matched by accident. mcp.enabled: false because the MCP listener is
not fronted by this gateway and has no place in a consumer app's deployment; when it is wanted, it
gets its own boundary and its own chapter.
sslmode on the store connection is verify-full, with the system's CA bundle carrying Chapter 2's
internal CA. If the cluster still has the snakeoil certificate, this has to be require, and the
closing checklist says to write that down.
The encryption key and the unit:
sudo sh -c 'echo "MINIMAL_ENCRYPTION_KEY=$(openssl rand -hex 16)" > /etc/minimal/minimal.env'
sudo chown root:minimal /etc/minimal/minimal.env /etc/minimal/config.yml
sudo chmod 0640 /etc/minimal/minimal.env /etc/minimal/config.yml
sudo tee /etc/systemd/system/minimal.service >/dev/null <<'EOF'
[Unit]
Description=Minimal API server
After=network-online.target
Wants=network-online.target
[Service]
User=minimal
Group=minimal
WorkingDirectory=/var/lib/minimal
EnvironmentFile=/etc/minimal/minimal.env
ExecStart=/usr/local/bin/minimal -c /etc/minimal/config.yml
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/minimal
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now minimal
sleep 3; systemctl is-active minimal
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3045/minimal/system/api/v1/orgactive
400400 is Volume I, Chapter 4's "no credential at all" — the listener is up and refusing a request
that carries no identity, which is the only kind a caller who reached it directly could send.
The encryption key seals every definition, every registered database password and every cached table
shape from the first write onward; it is part of the backup set with the database dumps, and it is not
a value to regenerate.
On db-1, Chapter 2's standing check now has both rows:
minimalist|minimal|t
zitadel|zitadel|tStep 2 — Bootstrap the app: organisation, project, space
Every call in this step is made on app-1, straight to 127.0.0.1:3045, with the five headers
typed by hand, because the gateway does not carry administrative identities and never will: the
administrator here is X-User-Id: appctl holding X-User-Roles: admin, and the only place that
identity is ever asserted is a shell on this host or Chapter 8's tool running from one. Zitadel does
not know appctl exists, and Minimal does not check it — it is a name in an audit trail.
The organisation is the company, created with the server key as every organisation is:
M=http://127.0.0.1:3045
SK=$(sudo grep -E '^\s*server_key:' /etc/minimal/config.yml | sed -E 's/.*: *"?([^"]+)"?/\1/')
curl -s -X POST $M/minimal/system/api/v1/org -H "X-Server-Key: $SK" -H "X-User-Id: sys:appctl" -H "X-User-Roles: admin" \
-H "Content-Type: application/json" -d '{
"org_id": "lbl", "name": "Littlebit Labs", "abbreviation": "lbl",
"admin_email": "ops@example.com", "admin_name": "Ops", "address": "-", "state": "-", "country": "IN", "pin_code": "000000",
"org_read_roles": "admin", "org_write_roles": "admin", "org_delete_roles": "admin", "project_create_roles": "admin"}'{"org_id": "lbl", "note": "please keep this ID saved"}Every role list on the organisation is admin alone. No app user holds admin — Chapter 4 created
member and premium and nothing else — so no request through the gateway can read, change or
delete the organisation record, create a project, or reach any other organisation-level surface.
The project is the app, and it registers the app's database as minimalist, the second role
Chapter 2 granted on healthyme:
ADM=(-H "X-Org-Id: lbl" -H "X-Project-Id: healthyme" -H "X-Space-Id: live" -H "X-User-Id: appctl" -H "X-User-Roles: admin" -H "Content-Type: application/json")
curl -s -X POST $M/minimal/system/api/v1/project "${ADM[@]}" -d '{
"org_id": "lbl", "project_id": "healthyme", "name": "Healthy Me", "abbreviation": "healthyme",
"description": "Backend of the Healthy Me apps",
"database": [{
"host_details": [{"host": "10.0.0.10", "port": "5432"}],
"username": "minimalist", "password": "<minimalist-password>",
"db_name": "healthyme", "db_type": "postgres", "ssl_mode": "verify-full",
"connect_timeout_secs": 30, "idle_timeout_secs": 600, "query_timeout_secs": 60,
"max_open_connections": 20, "max_idle_connections": 10}],
"project_read_roles": "admin,member,premium",
"project_write_roles": "admin",
"project_delete_roles": "admin"}'{"org_id": "lbl", "project_id": "healthyme"}project_read_roles includes the two app roles, and the two write columns do not. A member can
therefore read the project record and list what they own, and cannot define a permission template,
assign one, re-index a table or change the project — every one of those is a project_write_roles
check, per Volume I, Chapter 4. The database password is stored sealed under the encryption key and
comes back masked on every read.
The space is the release channel:
curl -s -X POST $M/minimal/system/api/v1/space "${ADM[@]}" -d '[{"space_id":"live","org_id":"lbl","project_id":"healthyme","name":"Live","description":"What the apps call","email":"ops@example.com"}]'[
{
"name": "Live",
"owner_id": "appctl",
"project_id": "healthyme",
"space_id": "live"
}
]X-Space-Id: live is one of the three constants the gateway writes on every request. A second space
named next is where Volume II, Chapter 1's definitions are written and tried before the copy route
promotes them to live; the gateway never points at it.
Step 3 — The first table, and who owns it
The app's tables are made by healthyme_app, the owner, so that Chapter 2's default privileges hand
minimalist its read and write grants automatically. The first table is the one Chapter 7 needs: the
device a person registered, keyed by the ULID the app minted, holding the public key the app
generated, and scoped to the person by the server's ULID:
PGPASSWORD='<healthyme-password>' psql "host=10.0.0.10 dbname=healthyme user=healthyme_app sslmode=verify-full" -v ON_ERROR_STOP=1 <<'SQL'
CREATE TABLE device (
device_id CHAR(26) PRIMARY KEY, -- minted by the app, never re-minted here
user_id CHAR(26) NOT NULL, -- the server-assigned ULID, set by the row scope
public_key TEXT NOT NULL, -- the app's public key, base64
platform TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX device_user_idx ON device (user_id);
SQLBoth identifiers are CHAR(26), which is what a ULID is in text and what X-User-Id carries. There
is no email column and no Zitadel subject column: the identity provider holds the email, and the
subject is a Zitadel-internal number that nothing in this database needs to know.
Now tell Minimal about it — Volume II, Chapter 8's sequence, condensed:
curl -s -X POST "$M/minimal/system/api/v1/project/table/index?db_type=postgres&schema=healthyme&table=device" "${ADM[@]}"{
"db_name": "healthyme",
"db_type": "postgres",
"indexed": "refreshed",
"lock_mask": 4091,
"org_id": "lbl",
"project_id": "healthyme",
"table_name": "device",
"table_type": "TABLE"
}lock_mask: 4091 is a freshly indexed table's state: everything locked except direct-channel read.
It stays that way until the template and the lock mask are set on purpose.
Step 4 — Template, lock mask, row scope
The template names Chapter 4's roles and no others, and opens nothing on the agent or machine channels:
curl -s -X POST $M/minimal/system/api/v1/permission/template "${ADM[@]}" -d '{
"name": "member-own-rows",
"description": "A member may create, read and update rows on the direct channel; agents and machines get nothing.",
"auto_api_create_roles": "member,premium,admin",
"auto_api_read_roles": "member,premium,admin",
"auto_api_update_roles": "member,premium,admin",
"auto_api_delete_roles": "admin",
"mcp_create_roles": "", "mcp_read_roles": "", "mcp_update_roles": "", "mcp_delete_roles": "",
"m2m_create_roles": "", "m2m_read_roles": "", "m2m_update_roles": "", "m2m_delete_roles": "",
"override_similar_template_warning": false}'{
"name": "member-own-rows",
"org_id": "lbl",
"project_id": "healthyme",
"template_id": "01M2F0VR0HSV42S9FMCXN3D0GS"
}Assign it, open the direct channel only, and bind the row scope:
TID=01M2F0VR0HSV42S9FMCXN3D0GS
curl -s -X PUT $M/minimal/system/api/v1/project/table/permission "${ADM[@]}" \
-d "{\"schema\":\"healthyme\",\"table\":\"device\",\"db_type\":\"postgres\",\"template_id\":\"$TID\"}"
curl -s -X PUT $M/minimal/system/api/v1/project/table/lock "${ADM[@]}" -d '{
"db_type": "postgres", "schema": "healthyme", "table": "device",
"table_create_locked": false, "table_read_locked": false, "table_update_locked": false, "table_delete_locked": false,
"mcp_create_locked": true, "mcp_read_locked": true, "mcp_update_locked": true, "mcp_delete_locked": true,
"m2m_create_locked": true, "m2m_read_locked": true, "m2m_update_locked": true, "m2m_delete_locked": true}'
curl -s -X PUT $M/minimal/system/api/v1/project/table/rls "${ADM[@]}" \
-d '{"db_type":"postgres","schema":"healthyme","table":"device","rls_column_name":"user_id","rls_header_key":"X-User-Id"}'{
"db_name": "healthyme",
"db_type": "postgres",
"org_id": "lbl",
"project_id": "healthyme",
"rls_column_name": "user_id",
"rls_header_key": "X-User-Id",
"table_name": "device",
"updated_at": "2026-09-14T03:55:04.682745Z"
}The last call is the one that makes the whole design hold. Volume II, Chapter 4's row scope binds
device.user_id to X-User-Id: every read is filtered to the caller's rows, and every insert has
the column set from the header regardless of what the body says. The header is the server's ULID,
written by the gateway from a verified token. So a person can only ever read and write their own
devices, not because the app remembers to filter, and not because a definition's SQL says WHERE user_id = ?:x-user-id, but because the table itself is scoped to whoever the gateway says is
calling. Every user-scoped table gets this binding, at the moment it is indexed.
Step 5 — The first request through the front door
With Chapter 4's token for alice, through the gateway, the app registers its device. The body
carries a user_id on purpose, and it is wrong on purpose:
curl -s -X POST "https://api.example.com/minimal/api/rest/auto/v1/lbl/healthyme/pg/healthyme/device" \
-H "Authorization: Bearer $T" -H "Content-Type: application/json" -w " [%{http_code}]" \
-d '[{"device_id":"01M2F0000000000000DEV1CE01","user_id":"SPOOFED_VALUE_IGNORED_BY_RLS",
"public_key":"MCowBQYDK2VuAyEAyT9pQ2rJ0xTf4NcB8HvKdXw3PsRe0aQfLmZ1u7Yg2kM=","platform":"ios",
"created_at":null,"last_seen":null}]'
curl -s "https://api.example.com/minimal/api/rest/auto/v1/lbl/healthyme/pg/healthyme/device?ps=10&pg=0" \
-H "Authorization: Bearer $T" -w " [%{http_code}]"{"rows_affected": 1} [201]
[{"created_at":"2026-09-14T09:25:04.707274+05:30","device_id":"01M2F0000000000000DEV1CE01","last_seen":"2026-09-14T09:25:04.707274+05:30","platform":"ios","public_key":"MCowBQYDK2VuAyEAyT9pQ2rJ0xTf4NcB8HvKdXw3PsRe0aQfLmZ1u7Yg2kM=","user_id":"01M2F0KNNC4M7Z1HEJHHG80JGE"}] [200]And on db-1, what the row holds:
01M2F0000000000000DEV1CE01|01M2F0KNNC4M7Z1HEJHHG80JGE|iosThe spoofed value never reached the table. user_id is the ULID from the token, the same one
X-User-Id carried, the same one the metadata in Zitadel holds. That is the chain, end to end, and
Chapter 7 walks it from the app's side.
The identity contract, stated once
For anyone writing a definition or a template against this deployment from now on:
| Header | Value | Set by |
|---|---|---|
X-Org-Id |
lbl |
the route, as a constant |
X-Project-Id |
healthyme |
the route, as a constant |
X-Space-Id |
live |
the route, as a constant |
X-User-Id |
the person's server-assigned ULID, 26 characters | the route, from the minimal_user_id claim |
X-User-Roles |
a sorted, comma-separated subset of member,premium |
the route, from the roles claim |
No request through the gateway ever carries admin, an mcp: or m2m: prefix, or an
LB-Access-Token. A definition that binds ?:x-user-id gets the ULID. A template that names
member and premium covers every person; one that names admin covers only the shell on app-1.
A row scope on user_id bound to X-User-Id is the way a user-scoped table is made, and this
deployment has no user-scoped table without one.
What dev does differently
The same bootstrap, against a Minimal started from a terminal on the Mac with the sample
configuration's port and a store of its own; Chapter 9 has the file and says why the store is
separate from the one your Postman runs use. permission_fail_open stays false there too — a dev
environment that passes where production refuses is not a dev environment.
Is Minimal ready? A closing checklist
- The listener is loopback,
environmentisproduction,debug_modeandshow_request_detailsare off, and the server key is not the sample's. permission_fail_openisfalse, and every table reachable through the gateway carries a template that names only Chapter 4's roles on the direct channel.- Every user-scoped table has a row scope bound to
X-User-Id, checked by an insert with a wronguser_idin the body, as above. agent_identityis absent andmcp.enabledisfalse.- The store connection is TLS —
pg_stat_sslshowsminimalistwitht— and the app database is registered asminimalist, not as its owner. - The encryption key and the server key are in the backup set, apart from the dumps.
Exercises
Add a second table,
preference, owned byhealthyme_app, with auser_idcolumn. Index it and, before assigning a template or a row scope, read it through the gateway asalice. Explain the status you get from the lock mask alone. Then assign the template but not the row scope, insert two rows as two different users, and read as one of them.Send a request through the gateway whose token carries
premiumas well asmember, and one that carries neither. Confirm which template columns each is checked against, and what an emptyX-User-Rolesdoes on a table whose template names no empty role.Register a second database on the project as
healthyme_appinstead ofminimalistand run aDROP TABLEthrough Volume II, Chapter 3's DDL route with theadminidentity. Then say, in one sentence, why this book registered the database as the role that cannot do that.
7Registration, End to End
Chapters 2 to 6 each proved their own piece. This chapter walks one person through all of them in order, from the app's side, and names at each step which system is doing the work and which is idle. It then answers the question Chapter 1 left open — where the app's own ULID and public key go, and whether Minimal is involved in registration at all — and closes with the smoke test that exercises the whole path from a shell, with no phone.
Day one: the app alone
A person installs Healthy Me and opens it. The app generates a ULID — call it the device id — and a key pair, stores both in the platform keychain, and gets on with being an app. Nothing has been sent anywhere. Zitadel has never heard of this person; neither has Minimal; the gateway has seen no request. The device id is the app's name for this installation, and the private key is what will later prove that a message came from it.
This state can last forever. A person who never touches a shared feature never registers, and that is by design: there is no anonymous route on the gateway and no beacon on first launch, so a person who has not chosen to register has left nothing on the server.
Day nine: the person reaches for a shared feature
The app needs a server now. It starts a sign-in: an authorization-code request with PKCE, opened in
the system browser as an ephemeral session, with prompt=login, against https://auth.example.com.
sequenceDiagram
participant A as App
participant G as APISIX
participant Z as Zitadel
participant L as Login V2
participant P as Google / GitHub / Apple
A->>G: GET /oauth/v2/authorize (PKCE challenge, scopes)
G->>Z: same, Host: auth.example.com
Z-->>A: 302 → /ui/v2/login/login?authRequest=…
A->>G: GET /ui/v2/login/login?authRequest=…
G->>L: same
L-->>A: sign-in page: Google · GitHub · Apple
A->>P: the person chooses a provider and signs in there
P-->>L: callback with the provider's identity
L->>Z: create the user (first time) and a session; finish the auth request
Z-->>A: 302 → com.littlebit.healthyme://auth/callback?code=…
A->>G: POST /oauth/v2/token (code, PKCE verifier)
G->>Z: same
Z->>Z: action: minimal_user_id — mint on first token, store, assert
Z-->>A: access token (JWT, 1h) + refresh token (90d)Registration is the first sign-in. Zitadel creates the account from the provider's claims, Chapter 4's action mints the server-assigned ULID as it issues the first token, and the app has everything it needs before it has spoken to the API once.
Three things happened inside Zitadel during that exchange, and only inside Zitadel:
- The account was created, because the organisation's Google, GitHub or Apple provider has
isAutoCreationon and the login policy hasallowRegisteron. Zitadel now holds the person's email, their display name, and the link to the provider's identity. This is the only place any of that is stored. - The server-assigned ULID was minted. The action on "pre access token creation" found no
minimal_user_idin the new account's metadata, made one, wrote it to the metadata, and put it on the token. Every token this person ever gets carries that same value. - A role check ran. With
authorizationRequiredon the project, a person holding no role is refused at the last step — so whatever grantsmember(Chapter 4, Step 8) has to have run by the time the token is requested, or the person sees a refusal and no token is issued.
The app now holds a JWT. What it does with it is the whole of the app's job on the identity side:
keep the refresh token in the keychain, refresh the access token quietly before its hour is up, put
the access token in the Authorization header of every API request, and never put anything in an
X- header.
The same day: the first API call
The app's first request is to register its device — the ULID and public key from day one — under the person's account:
POST /minimal/api/rest/auto/v1/lbl/healthyme/pg/healthyme/device HTTP/1.1
Host: api.example.com
Authorization: Bearer <the access token>
Content-Type: application/json
[{"device_id":"01M2F0000000000000DEV1CE01","user_id":null,
"public_key":"MCowBQYDK2VuAyEAyT9pQ2rJ0xTf4NcB8HvKdXw3PsRe0aQfLmZ1u7Yg2kM=",
"platform":"ios","created_at":null,"last_seen":null}]sequenceDiagram
participant A as App
participant G as APISIX
participant M as Minimal
participant D as Postgres (healthyme)
A->>G: POST …/device, Authorization: Bearer JWT
G->>G: strip X-*, LB-Access-Token, ?lb-access-token
G->>G: verify signature (JWKS), iss, aud
G->>G: X-User-Id ← minimal_user_id · X-User-Roles ← roles · 3 constants
G->>M: POST …/device with the five headers only
M->>M: lock mask · template (member may create) · row scope sets user_id
M->>D: INSERT … user_id = '01M2F0KNNC4M7Z1HEJHHG80JGE'
D-->>M: 1 row
M-->>A: 201 {"rows_affected": 1}The gateway is the only party that reads the token. Minimal never sees it, and never sees a
user_id the app chose.
user_id is null in the body, or anything else; Chapter 6's row scope overwrites it with the
X-User-Id the gateway set, which is the ULID from the token. The response is 201 with a row
count, as Volume I, Chapter 2 says every insert answers. A read back —
GET /minimal/api/rest/auto/v1/lbl/healthyme/pg/healthyme/device?ps=10&pg=0 HTTP/1.1
Host: api.example.com
Authorization: Bearer <the access token>— returns exactly this person's devices and nobody else's, because the row scope filters the read
by the same header. A second device registered from a second phone lands under the same user_id,
because the second phone signed in as the same person and got a token with the same ULID.
Where the device id and public key live, and why
Chapter 1 promised an answer to "does registration involve Minimal". It does not, and now the reason can be stated precisely: registration is complete the moment Zitadel issues the first token. The account exists, the server-assigned id exists and is durable, and the person can sign in again on any device and get the same id. Nothing in Minimal has to happen for any of that to be true, and if the API were down on day nine the person would still be registered.
The device id and public key are a different matter, and the answer depends on what the app's own features need:
- If nothing server-side ever uses the key — the app just wanted an account, for backup or
sync of data it encrypts itself — then the key never needs to leave the device at all, and the
device id is a value the app can send as
device_idon rows it writes, so that sync can attribute changes. No registration call, nodevicetable. - If a server-side feature needs to know a person's devices — sending an encrypted item to
another person's devices, approving a new device from an old one, listing and revoking devices —
then the key is data the app stores about itself, and it belongs in the app's own database under
the person's server-assigned id, which is exactly the
devicetable Chapter 6 made and the insert above. That is data the app writes through Minimal like any other row; it is not identity, and Zitadel is the wrong place for it.
The one thing not to do is put the device id or the key into Zitadel as user metadata. Metadata is where Zitadel keeps the server-assigned ULID because that value has to be on every token; a key that another person's device will look up belongs in a table that person's device can query, scoped by Chapter 6's row scope to the owner, and readable by the other party through a definition that says so. Volume II, Chapter 1 is where that definition gets written; this book stops at the table.
If a feature genuinely needs the server to prove something to the app — an acknowledgement the app can verify came from your server and not from the network — that is a signature over the response with a key the server holds, and it is a definition's job, using whatever signing library the deployment adopts. Nothing in this chapter's flow needs it: the token the app holds is Zitadel's signed statement that the person is who they are, and the row that came back is Minimal's answer over TLS.
Day ten onward
Every later launch, the app has a refresh token and gets a fresh access token without the person
seeing anything. Every token carries the same minimal_user_id, because the action finds it in the
metadata now and mints nothing. Every API request is the same shape as the first. When the person
gets a new phone, they sign in with the same provider, Zitadel matches the provider identity to the
existing account, and the new phone's token carries the old ULID — the device table gains a row,
the person's data does not move.
When a person leaves, deleting their Zitadel user ends their ability to get a token within the hour;
their rows in healthyme are still keyed by a ULID that no token will ever carry again, and whether
those rows are deleted, retained or anonymised is a product decision the app enforces with a
definition, not something the identity layer decides.
The smoke test: the whole path from a shell
Chapter 4, Step 9 showed that a script can do what the login page does, through Zitadel's session API, with the login client's token. Put that together with the insert above and you have a test that exercises every piece in this book, from one shell, in a few seconds, with no phone and no provider account — against a user who signs in with a password, which is why the dev organisation in Chapter 9 keeps passwords on for exactly one such user.
The test, in outline (Chapter 9 ships it as smoke.sh):
- Start an authorization request through the gateway; expect the
302to the login and capture the request id. - Create a session for the test user through the gateway with the login client's token; expect
201. - Finish the request with that session; expect
200and a callback URL carrying a code. - Exchange the code; expect
200, a JWT whoseminimal_user_idis 26 characters and whoseissis the gateway's public URL. - Insert a device row through the gateway with a wrong
user_idin the body; expect201. - Read the table back through the gateway; expect
200and rows whoseuser_idis the claim's value, none other. - Read the organisation record through the gateway; expect
403— a member is not an admin. - Send the same read with no token; expect
401.
Each step that fails names the chapter to reopen: 1 and 3 are Chapter 5's identity host and Chapter 3's units, 2 is the login client's token, 4 is Chapter 4's application settings and action, 5 and 6 are Chapter 6's template and row scope, 7 is Chapter 6's organisation roles, 8 is Chapter 5's API route. Run it after every deployment and after every change to any of the three systems.
Exercises
Run the smoke test twice with the same user and once with a second user. Compare the three
minimal_user_idvalues and the threedevicereads. Then delete the second user in Zitadel, run the test with the first user, and read thedevicetable ondb-1directly: whose rows are still there, and what would it take for anyone to ever read them through the API again?Register a device from a second "phone" — a second run of the test with a different
device_idand the same user — and then write, without running it, the definition that would let another person fetch this person's public keys for an item they are sharing. Say which header it binds and which template column the caller needs.The chapter says registration is complete when the first token is issued, even with the API down. Stop
minimalonapp-1, run steps 1 to 4 of the smoke test, and confirm they pass. Then stopzitadelinstead, and confirm which steps a person with an existing, unexpired access token can still complete.
8The Next App, and a Provisioning Tool
Nothing in Chapters 4 to 6 was specific to Healthy Me except the names. This chapter adds a second app, Sleep Well, by listing every object the first one needed and making each again; proves that the two are as separate as Chapter 1 promised; and then writes down the shape of a small Go tool that does the whole list from one description file, so that the third app takes a minute.
What one app is made of
Counted from the chapters, an app is exactly these objects, in this order, because each one's id is an input to the next:
| # | System | Object | Made in | Input from |
|---|---|---|---|---|
| 1 | Postgres | role <app>_app, database <app>, grants to minimalist |
Ch. 2, Steps 4–5 | — |
| 2 | Postgres | one hostssl line per (database, role) |
Ch. 2, Step 3 | 1 |
| 3 | Zitadel | organisation | Ch. 4, Step 1 | — |
| 4 | Zitadel | project, with authorizationRequired and projectAccessRequired |
Ch. 4, Step 2 | 3 |
| 5 | Zitadel | roles on the project | Ch. 4, Step 3 | 4 |
| 6 | Zitadel | one native application per platform | Ch. 4, Step 4 | 4 |
| 7 | Zitadel | custom login policy | Ch. 4, Step 5 | 3 |
| 8 | Zitadel | identity providers, activated on the policy | Ch. 4, Step 6 | 3, 7 |
| 9 | Zitadel | the minimalUserId action, bound to flow 2 trigger 5 |
Ch. 4, Step 7 | 3 |
| 10 | APISIX | one route: host, path, client id, three constants | Ch. 5, Step 5 | 6 |
| 11 | Minimal | project, registering the database as minimalist |
Ch. 6, Step 2 | 1 |
| 12 | Minimal | space | Ch. 6, Step 2 | 11 |
| 13 | Minimal | per table: index, template, lock mask, row scope | Ch. 6, Steps 3–4 | 11 |
Two things are not on the list, because they are made once for the deployment and shared: the
Minimal organisation lbl, and the Zitadel instance with its provisioner and login-client
accounts. Everything else is per app.
Sleep Well, by hand
Rows 1 and 2 are Chapter 2's SQL with sleepwell in place of healthyme, plus one hostssl line
for each of the two roles on the new database. Rows 3 to 9 are Chapter 4's calls with the name
changed; the ids they return are the ones below:
ORG2=390678055808401676 # organisation "Sleep Well"
PROJ2=390678055858733324 # project "Sleep Well", authorizationRequired + projectAccessRequired
APP2=390678055925907724 # native app "Sleep Well iOS", client idRow 10 is a second route in apisix.yaml, which is the Healthy Me route with four values changed:
the id, the client id in openid-connect, and the three constants in the post-function. Everything
else — the Lua, the plugin settings, the rate limit — is identical, and the cleanest way to keep it
so is to keep the two routes next to each other and diff them when either changes:
- id: sleepwell-api
host: api.example.com
uri: /minimal/*
vars: [["http_x_app", "==", "sleepwell"]] # see below
priority: 5
upstream_id: minimal
plugins:
# identical to minimal-api, except:
openid-connect:
client_id: "390678055925907724"
# ...
serverless-post-function:
# ... X-Org-Id lbl · X-Project-Id sleepwell · X-Space-Id liveBoth apps call the same host and the same path prefix, so something has to say which route a
request belongs to. Two honest options: a second API host (api.sleepwell.example.com), which is
one more name on the certificate and no other change; or one host and a header the app sends
(X-App: sleepwell, matched by vars as above). A wrong or missing value is not a security problem
in either case — the token's audience still has to match the route's client id, so a Sleep Well token
on the Healthy Me route is 403 mismatched audience whichever route it lands on — it is only a
routing problem. This book uses the header.
Rows 11 to 13 are Chapter 6's calls with sleepwell in place of healthyme.
Proving the two are separate
Three facts, each one request:
A Healthy Me user cannot sign in to Sleep Well. Chapter 4's headless flow, with Sleep Well's
client id and alice's credentials, is refused at the finishing step:
3. finalize -> 403 {"code":7,"message":"Errors.User.ProjectRequired (OIDC-foSyH49RvL)", ...}That is projectAccessRequired on the Sleep Well project: alice's organisation has not been granted
it. Before that setting was on, the same flow issued a token — which is the reason Chapter 4 puts the
two booleans on the project and says so twice.
A Sleep Well token cannot reach Healthy Me's API. With bob, a user in the Sleep Well
organisation, a valid Sleep Well token on the Healthy Me route:
curl -s -o /dev/null -w "%{http_code}\n" https://api.example.com/minimal/system/api/v1/org -H "Authorization: Bearer $BOB_TOKEN"403{"error":"mismatched audience"} from the gateway; Minimal never saw it.
Two accounts, even for one person. Register the same email in both apps and Zitadel holds two
users with two sub values in two organisations, each with its own minimal_user_id — the action
runs per organisation and mints per user. Nothing links them, and nothing in Minimal can tell they
belong to the same person, because nothing does. If one day the product wants one account across
apps, that is a different Zitadel layout — one organisation, one project per app inside it, and a
project grant model — and it is a rebuild of rows 3 to 9, not a change to the gateway or to Minimal,
whose contract with the world is still five headers.
A tool for the list
Thirteen rows, four systems, three credentials, and every id from one row typed into the next: this is what a small command-line tool is for. The tool described here was not built in writing this book; its shape is fixed by the rows above and by what was verified in running them by hand, and it is specified closely enough to be built as the next piece of work.
Input is one file per app:
# healthyme.app.yaml
name: Healthy Me
slug: healthyme
scheme: com.littlebit.healthyme
platforms: [ios, android, mac]
roles: [member, premium]
providers:
google: {client_id: ..., client_secret: ...}
github: {client_id: ..., client_secret: ...}
apple: {client_id: com.littlebit.healthyme.signin, team_id: ..., key_id: ..., private_key_file: AuthKey.p8}
minimal:
org: lbl
space: live
database: {host: 10.0.0.10, port: 5432, name: healthyme, user: minimalist}Credentials come from the environment or root-only files, never from the input:
ZITADEL_URL and the provisioner's token, Minimal's URL and server key and the appctl identity, the
minimalist and <app>_app database passwords.
Commands, one per group of rows, each idempotent — it looks the object up by name before creating it, and prints the id either way:
| Command | Rows | Calls |
|---|---|---|
appctl db plan <app> |
1, 2 | prints the SQL and the pg_hba lines for a person to apply on db-1; it does not connect as a superuser |
appctl zitadel apply <app> |
3–9 | POST /v2/organizations; CreateProject and UpdateProject; POST /management/v1/projects/{id}/roles; CreateApplication per platform; POST /management/v1/policies/login; POST /management/v1/idps/{google,github,apple} and POST /management/v1/policies/login/idps; POST /management/v1/actions and POST /management/v1/flows/2/trigger/5 |
appctl gateway render <app> |
10 | writes the route block from a template with the client id and the three constants filled in, for a person to paste and diff |
appctl minimal apply <app> |
11, 12 | POST /minimal/system/api/v1/project, POST /minimal/system/api/v1/space |
appctl minimal table <app> <table> |
13 | index, template (created once per app and reused), assign, lock mask, row scope |
appctl grant <app> <user-id> |
Ch. 4, Step 8 | POST /management/v1/users/{id}/grants with member |
appctl smoke <app> |
Ch. 7 | the eight-step smoke test, with a named test user |
Output is the ids, in the same YAML shape, appended to a <app>.state.yaml the next command reads
— which is how the tool stays idempotent and how the Sleep Well ids above would be recorded rather
than remembered.
Three decisions about the tool are worth making now rather than at the keyboard:
- It never holds a database superuser. Rows 1 and 2 are printed, not executed. Chapter 2's whole
argument is that the superuser is used once, by a person, on
db-1; a tool with that credential onapp-1undoes it. - It never edits
apisix.yamlin place. It renders a block; a person puts it in the file that is under version control and watches the diff. The gateway is the security boundary and its configuration is reviewed, not generated. - It is the one place the
membergrant is automated. Chapter 4 left that as the open item; the tool holds the provisioner's token and can watch for new users —POST /v2/userswith a filter by organisation, or an Actions V2 event target when the deployment moves to those — and grantmemberto each one it has not seen.
Where it lives is a decision for its owner; what it must not be is a second copy of any of these systems' logic. Every command above is a thin call onto an API this book already showed, and a command that needs an API none of them offers is a question to raise, not a route to invent.
Exercises
Do Sleep Well by hand, from the table, recording each id in a file as you go. Time it. Then write down which of the thirteen rows you got wrong on the first attempt, and whether the tool as specified would have caught it.
Switch the routing choice: give Sleep Well its own API host instead of the header. List every file that changes, run Chapter 5's echo test on both hosts, and say which choice you would ship.
The tool's
smokecommand needs a test user with a password in each app's organisation, and Chapter 4's production login policy forbids passwords. Decide how the test user signs in on production — a separate organisation, a provider account the tool holds, the session API with a different check — and write the one-paragraph justification that goes in the tool's README.
9dev on the Mac
Every production step in Chapters 2 to 7 has a development twin on the Mac, and this chapter is the
list of them in the order you run them. The point of dev is that the whole path — sign-in page,
token, gateway, Minimal, row — works on one machine with no domain, no certificate and no provider
account, so that a change to any of the three systems can be tried before it goes near production.
Everything here was run on a Mac with Homebrew, an Apple-silicon processor, and the Postgres 18 that
was already serving Minimal's local store.
Two things are different in kind, not just in value. Postgres is the cluster already on the Mac, on
port 5433, and the book adds to it rather than building one. And APISIX does not run on macOS — its
own build files refuse the platform — so the gateway runs inside a small Debian virtual machine
managed by Lima, exactly the Debian package Chapter 5 installs, with the Mac's services reached from
inside it as host.lima.internal. Zitadel, its login, and Minimal run natively from a terminal.
The layout, and the port each piece uses:
| Piece | Where | Listens | Reached by the others as |
|---|---|---|---|
| Postgres 18 | Mac, Homebrew | 127.0.0.1:5433 |
127.0.0.1:5433 |
| Zitadel | Mac, release binary | :8081 |
host.lima.internal:8081 from the VM |
| Login V2 | Mac, Node 22 | 127.0.0.1:3000 |
host.lima.internal:3000 from the VM |
| Minimal | Mac, your build | 127.0.0.1:3047 |
host.lima.internal:3047 from the VM |
| APISIX | Lima VM, Debian package | :8080 in the VM, forwarded to the Mac's localhost:8080 |
http://localhost:8080 by you and by the login page |
3047 rather than 3045 so that the Minimal you already run for Postman keeps its port; change it
if nothing is there. Zitadel's public name is localhost:8080, which is the gateway, and its issuer
is therefore http://localhost:8080.
Step 1 — Postgres: two roles, two databases, on the cluster you have
The Homebrew cluster already preloads pg_cron for the minimal store and already has vector.
Chapter 2, Steps 4 and 5, with the local names:
psql -p 5433 -d postgres -v ON_ERROR_STOP=1 <<'SQL'
CREATE ROLE zitadel LOGIN PASSWORD 'zitadel-dev-pw' NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT CONNECTION LIMIT 20;
CREATE ROLE healthyme_app LOGIN PASSWORD 'healthyme-dev-pw' NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT CONNECTION LIMIT 30;
CREATE DATABASE zitadel OWNER zitadel;
CREATE DATABASE healthyme OWNER healthyme_app;
REVOKE CONNECT ON DATABASE zitadel FROM PUBLIC;
REVOKE CONNECT ON DATABASE healthyme FROM PUBLIC;
GRANT CONNECT ON DATABASE healthyme TO minimalist;
\c healthyme
REVOKE ALL ON SCHEMA public FROM PUBLIC;
GRANT ALL ON SCHEMA public TO healthyme_app;
GRANT USAGE ON SCHEMA public TO minimalist;
ALTER DEFAULT PRIVILEGES FOR ROLE healthyme_app IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO minimalist;
ALTER DEFAULT PRIVILEGES FOR ROLE healthyme_app IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO minimalist;
SQLThe isolation probes from Chapter 2, Step 7 give permission denied for database here rather than
a pg_hba.conf refusal, because the Mac's cluster trusts loopback and only REVOKE CONNECT is
standing between the roles — which is the second layer doing its job on its own.
Minimal's store for dev is a separate database, because the one your Postman runs use should not
gain a lbl organisation. pg_cron can live only in minimal, so the schema file is applied with
its three pg_cron lines removed; the two audit-partition jobs are not part of dev:
psql -p 5433 -d postgres -c "CREATE DATABASE minimal_book OWNER minimalist"
psql -p 5433 -d minimal_book -c "CREATE EXTENSION IF NOT EXISTS vector; CREATE EXTENSION IF NOT EXISTS pg_trgm;"
grep -vE "CREATE EXTENSION IF NOT EXISTS pg_cron|SELECT cron\.schedule" database/minimal/postgres.sql > /tmp/postgres-no-cron.sql
psql -p 5433 -U minimalist -d minimal_book -v ON_ERROR_STOP=1 -q -f /tmp/postgres-no-cron.sql
psql -p 5433 -d minimal_book -Atc "select count(*) from information_schema.tables where table_schema='public'"42Step 2 — Zitadel and its login, from a terminal
The macOS binary and the login bundle from the same release as Chapter 3, into a working directory
of your choosing (~/dev/zitadel below):
V=v4.17.3; mkdir -p ~/dev/zitadel && cd ~/dev/zitadel
curl -fsSLO https://github.com/zitadel/zitadel/releases/download/$V/zitadel-darwin-arm64.tar.gz
curl -fsSLO https://github.com/zitadel/zitadel/releases/download/$V/zitadel-login.tar.gz
tar xzf zitadel-darwin-arm64.tar.gz && mkdir -p login && tar xzf zitadel-login.tar.gz -C login
./zitadel-darwin-arm64/zitadel --version
head -c 32 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 32 > masterkey
brew install node@22The configuration is Chapter 3's with the dev values — no TLS anywhere, the gateway's name and port as the public ones, the login's base URI through the gateway:
# ~/dev/zitadel/config.yaml
Log:
Level: info
Port: 8081
ExternalDomain: localhost
ExternalPort: 8080
ExternalSecure: false
TLS:
Enabled: false
Database:
postgres:
Host: 127.0.0.1
Port: 5433
Database: zitadel
MaxOpenConns: 10
MaxIdleConns: 5
User:
Username: zitadel
Password: zitadel-dev-pw
SSL:
Mode: disable
DefaultInstance:
Features:
LoginV2:
Required: true
BaseURI: "http://localhost:8080/ui/v2/login"The steps file is Chapter 3's with the token paths in the working directory and a dev password that does not have to be changed on first login:
# ~/dev/zitadel/steps.yaml
FirstInstance:
InstanceName: littlebit-dev
DefaultLanguage: en
PatPath: /Users/<you>/dev/zitadel/provisioner.pat
LoginClientPatPath: /Users/<you>/dev/zitadel/login-client.pat
Org:
Name: Littlebit
Human:
UserName: admin
FirstName: Instance
LastName: Admin
Email:
Address: admin@littlebit.localhost
Verified: true
Password: "Dev-Admin-Passw0rd!"
PasswordChangeRequired: false
Machine:
Machine:
Username: provisioner
Name: Provisioning service account
Pat:
ExpirationDate: "2030-01-01T00:00:00Z"
LoginClient:
Machine:
Username: login-client
Name: Login V2 client
Pat:
ExpirationDate: "2030-01-01T00:00:00Z"Schema, setup, start — the same three commands, and the same trap if FirstInstance is in the
wrong file:
Z=./zitadel-darwin-arm64/zitadel
$Z init zitadel --config config.yaml
$Z setup --config config.yaml --steps steps.yaml --masterkeyFile masterkey --tlsMode disabled --init-projections
ls *.pat
$Z start --config config.yaml --masterkeyFile masterkey --tlsMode disabledlogin-client.pat provisioner.pat
... level=INFO msg="server is listening" ... address=[::]:8081In a second terminal, the login, through its own entrypoint so that the token file is read. Started
as a bare node apps/login/server.js it comes up, answers its health route, and every sign-in page
says only Internal server error while its log says Flow initiation failed: it has no token and
never tries to reach the API. Proxy variables in the shell are unset as well, since Node's HTTP
client would otherwise consult them for 127.0.0.1:
cd ~/dev/zitadel/login
env -u HTTP_PROXY -u HTTPS_PROXY -u http_proxy -u https_proxy \
HOSTNAME=127.0.0.1 PORT=3000 ZITADEL_API_URL=http://127.0.0.1:8081 \
ZITADEL_SERVICE_USER_TOKEN_FILE=$HOME/dev/zitadel/login-client.pat NODE_ENV=production \
./entrypoint.sh $(brew --prefix node@22)/bin/node apps/login/server.js▲ Next.js 16.2.11
- Local: http://127.0.0.1:3000
✓ Ready in 0msThe MetadataLookupWarning the login prints on a Mac is its OpenTelemetry resource detector
looking for a Google Cloud metadata server; it is harmless. Chapter 3's checks apply: 8081 without a
Host is 404; with Host: localhost:8080 the issuer is http://localhost:8080; 3000 answers
/ui/v2/login/healthy with 200.
Step 3 — The app in Zitadel, dev variant
Chapter 4, with Z=http://127.0.0.1:8081 and -H "Host: localhost:8080" on every call until the
gateway exists, and three differences:
- The native app has
developmentMode: trueand a second redirect URI,http://localhost:4200/auth/callback, so that a local web build can use it. The custom scheme stays. - The login policy has
allowUsernamePassword: true, so that one test user can sign in without a provider. Everything else about the policy is the same. - One human user with a password,
alice@healthyme.localhost, is created withPOST /v2/users/newand grantedmember— she is the user the smoke test signs in as, and the reason passwords are on. - The three providers are created with placeholder client ids and secrets. They appear on the login
page, which is what you want to see; signing in with one against
localhostcannot work, and Apple cannot even be configured for it.
Step 4 — The gateway in a VM
Lima runs a Linux VM from a template with one command and forwards every port a guest process
listens on to the Mac's localhost, which is exactly the two things the gateway needs:
brew install lima
limactl start --name=book --tty=false --cpus=2 --memory=4 --disk=20 template://debian-12
limactl shell book -- getent hosts host.lima.internal192.168.5.2 host.lima.internalInside the VM, Chapter 5, Step 1 verbatim — the arm64 repository path on an Apple-silicon Mac —
then the two files, dev variant. config.yaml listens on 8080 and is otherwise Chapter 5's:
# /usr/local/apisix/conf/config.yaml (dev)
apisix:
node_listen:
- 8080
enable_admin: false
deployment:
role: data_plane
role_data_plane:
config_provider: yaml
nginx_config:
error_log_level: warnapisix.yaml is Chapter 5's with no ssls, no host: on the routes, host.lima.internal in every
upstream, http://localhost:8080 in the discovery URL and the issuer, and the dev app's client id.
The Lua is byte-identical. The full file is in Appendix D; the delta is four upstream addresses and
three strings.
limactl copy config.yaml book:/tmp/config.yaml
limactl copy apisix.yaml book:/tmp/apisix.yaml
limactl shell book -- sudo bash -c 'install -m 0644 /tmp/config.yaml /tmp/apisix.yaml /usr/local/apisix/conf/ && cd /usr/local/apisix && apisix test && systemctl enable --now apisix'
curl -s http://localhost:8080/.well-known/openid-configuration | python3 -c "import sys,json; print(json.load(sys.stdin)['issuer'])"http://localhost:8080That line is the whole dev gateway working: the Mac's localhost:8080 is the VM's APISIX, which sent
the request to the Mac's Zitadel with the Host it needs. Open http://localhost:8080/ui/console in
a browser and sign in as admin to see the instance.
One trap with Lima's port forwarding, met while writing this: it forwards every guest port. If
you use the same VM to rehearse the production units — Zitadel, the login, Minimal on Debian — those
processes take over the Mac's 8081, 3000 and 3047 and quietly answer instead of the native
ones, with a different instance and a different store. Keep the gateway VM to the gateway, or stop
the rehearsal units before you go back to dev.
Step 5 — Minimal, from your working tree
The sample configuration with the store, the port and the key changed, run from a scratch
directory so that the config.yml it reads is this one:
mkdir -p ~/dev/minimal && cd ~/dev/minimal
cp <repo>/sample/config.yml config.yml
# service.port 3047, mcp.service.port 3048, definition_store.schema minimal_book,
# definition_store.password '<the minimalist password>', auto_api.permission_fail_open false
openssl rand -hex 16 > enc.key
MINIMAL_ENCRYPTION_KEY=$(cat enc.key) <repo>/minimal -c config.yml
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3047/minimal/system/api/v1/org400Then Chapter 6, Steps 2 to 4, against M=http://127.0.0.1:3047, with the database registered at
127.0.0.1:5433 as minimalist with ssl_mode: "" — the Mac's cluster has no certificate to
verify — and the device table created as healthyme_app.
Step 6 — The smoke test
smoke.sh is Chapter 7's eight steps as a script. It reads the two things that differ per
environment from its environment — the gateway URL and where the login client's token is — and
everything else from the app's ids:
#!/bin/bash
# smoke.sh -- the whole path: sign-in, token, gateway, Minimal, row scope.
set -euo pipefail
G=${G:-http://localhost:8080}
LOGIN_PAT=$(cat ${LOGIN_PAT_FILE:-$HOME/dev/zitadel/login-client.pat})
ORG=390676976144220428; PROJ=390677090732605708; CLIENT=390677214766629132
USER_LOGIN=alice@healthyme.localhost; USER_PASS='Alice-Test-Passw0rd!'
REDIRECT=com.littlebit.healthyme://auth/callback
TABLE=$G/minimal/api/rest/auto/v1/lbl/healthyme/pg/healthyme/device
VERIFIER=$(head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 64)
CHALLENGE=$(printf %s "$VERIFIER" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=')
SCOPE=$(python3 -c "import urllib.parse;print(urllib.parse.quote('openid profile email offline_access urn:zitadel:iam:org:id:$ORG urn:zitadel:iam:org:project:id:$PROJ:aud urn:zitadel:iam:org:project:roles'))")
step() { printf '%-4s %-44s ' "$1" "$2"; }
ok() { echo "ok $1"; }
fail() { echo "FAIL $1"; exit 1; }
step 1 "authorize redirects to the login"
AUTH=$(curl -s -o /dev/null -w '%{redirect_url}' "$G/oauth/v2/authorize?client_id=$CLIENT&redirect_uri=$REDIRECT&response_type=code&scope=$SCOPE&code_challenge=$CHALLENGE&code_challenge_method=S256&prompt=login")
[[ "$AUTH" == *"/ui/v2/login/login?authRequest="* ]] && ok "${AUTH##*=}" || fail "$AUTH"
AUTH=${AUTH##*authRequest=}
step 2 "session for the test user"
S=$(curl -s -H "Authorization: Bearer $LOGIN_PAT" -H "Content-Type: application/json" -X POST $G/v2/sessions \
-d "{\"checks\":{\"user\":{\"loginName\":\"$USER_LOGIN\"},\"password\":{\"password\":\"$USER_PASS\"}}}")
SID=$(echo "$S" | python3 -c "import sys,json;print(json.load(sys.stdin)['sessionId'])") && ok "$SID" || fail "$S"
STOK=$(echo "$S" | python3 -c "import sys,json;print(json.load(sys.stdin)['sessionToken'])")
step 3 "finish the auth request"
CB=$(curl -s -H "Authorization: Bearer $LOGIN_PAT" -H "Content-Type: application/json" -X POST $G/v2/oidc/auth_requests/$AUTH \
-d "{\"session\":{\"sessionId\":\"$SID\",\"sessionToken\":\"$STOK\"}}")
CODE=$(echo "$CB" | python3 -c "import sys,json,urllib.parse;u=json.load(sys.stdin)['callbackUrl'];print(urllib.parse.parse_qs(urllib.parse.urlparse(u).query)['code'][0])") && ok "code" || fail "$CB"
step 4 "exchange the code; claim present, issuer right"
T=$(curl -s -X POST $G/oauth/v2/token -d "grant_type=authorization_code&code=$CODE&redirect_uri=$REDIRECT&client_id=$CLIENT&code_verifier=$VERIFIER" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
MUID=$(python3 -c "import sys,json,base64;p='$T'.split('.')[1];d=json.loads(base64.urlsafe_b64decode(p+'='*(-len(p)%4)));assert d['iss']=='$G';print(d['minimal_user_id'])") && ok "$MUID" || fail "bad token"
[ ${#MUID} -eq 26 ] || fail "claim is not a ULID"
step 5 "insert a device with a wrong user_id in the body"
DEV=$(python3 -c "import secrets;print('01SMOKE'+secrets.token_hex(10).upper()[:19])")
R=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$TABLE" -H "Authorization: Bearer $T" -H "Content-Type: application/json" \
-d "[{\"device_id\":\"$DEV\",\"user_id\":\"SPOOFED\",\"public_key\":\"smoke\",\"platform\":\"test\",\"created_at\":null,\"last_seen\":null}]")
[ "$R" = 201 ] && ok 201 || fail "$R"
step 6 "read back: every row is mine"
ROWS=$(curl -s "$TABLE?ps=100&pg=0" -H "Authorization: Bearer $T")
python3 -c "import sys,json;r=json.loads('''$ROWS''');assert r and all(x['user_id']=='$MUID' for x in r);print(len(r))" >/dev/null && ok "$(echo "$ROWS" | python3 -c "import sys,json;print(len(json.load(sys.stdin)),'rows')")" || fail "$ROWS"
step 7 "org record refused to a member"
R=$(curl -s -o /dev/null -w '%{http_code}' $G/minimal/system/api/v1/org -H "Authorization: Bearer $T"); [ "$R" = 403 ] && ok 403 || fail "$R"
step 8 "no token refused at the gateway"
R=$(curl -s -o /dev/null -w '%{http_code}' $G/minimal/system/api/v1/org); [ "$R" = 401 ] && ok 401 || fail "$R"1 authorize redirects to the login ok V2_390680877182550284
2 session for the test user ok 390680877216039180
3 finish the auth request ok code
4 exchange the code; claim present, issuer right ok 01M2F0KNNC4M7Z1HEJHHG80JGE
5 insert a device with a wrong user_id in the body ok 201
6 read back: every row is mine ok 2 rows
7 org record refused to a member ok 403
8 no token refused at the gateway ok 401Against production, G=https://api.example.com for the API steps and the login client's token
from app-1 — with the caveat Chapter 8's third exercise raises, that the production login policy has
no password user to sign in as, which is a decision to make before the test can run there.
Sign in like a person, once
The smoke test proves the machinery; a browser proves the page. Open, in the Mac's browser:
http://localhost:8080/oauth/v2/authorize?client_id=390677214766629132&redirect_uri=com.littlebit.healthyme%3A%2F%2Fauth%2Fcallback&response_type=code&scope=openid%20profile%20email%20urn%3Azitadel%3Aiam%3Aorg%3Aid%3A390676976144220428&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&prompt=loginThe page is the hosted login, through the gateway: "Welcome back!", a username field, "Register new
user", and Google, GitHub and Apple under "or sign in with". Leave the organisation scope out of the
URL and the three buttons disappear, because the page is then the instance's default organisation —
which is the mistake to recognise, not the page to debug. Sign in as alice — username, then
password — and the flow ends on the login's own page, "Welcome Alice Tester! You are signed in."
The callback to com.littlebit.healthyme://auth/callback?code=... is what a device with the app
installed receives at that moment; a browser with nothing registered for the scheme has nowhere to
deliver it, and the smoke test is the way to see the code and exchange it from a Mac.
Is dev complete? A closing checklist
- The smoke test passes, all eight steps, from a fresh shell.
psql -p 5433 -lshowszitadel,healthymeandminimal_book, and your Postman store is untouched.- The VM runs APISIX and nothing else, and
limactl shell book -- ss -ltnshows only8080. - The three native processes are started the way this chapter says — the login through
entrypoint.sh, with no proxy variables; Minimal withpermission_fail_open: false. - The dev app's
developmentModeand the dev organisation's password login are the only two settings that differ from Chapter 4, and both are listed here so that nobody carries them to production.
Exercises
Stop the VM (
limactl stop book) and run the smoke test. Read which step fails and what it prints, and compare it with stopping Zitadel or Minimal instead. Three different first failures for three different outages.Point the dev gateway's
minimalupstream at your Postman Minimal on3045instead of3047, without changing anything else, and run the smoke test. Explain the failure from the store the two instances use — and then say what would have happened ifpermission_fail_openhad beentrueon that instance.Write the Lima template that forwards only port
8080from the VM, start a second VM from it, and confirm the rehearsal trap in Step 4 can no longer happen. Decide whether the book's one-commandlimactl startwas the right default.
10Running It
Three services, one database cluster, and a boundary that is only as good as its configuration on the day someone asks. This chapter is what changes after the first deployment: the secrets and how each is rotated, what each system tells you when something is wrong, what breaks when one piece is down, how each piece is upgraded, and the one list to read before saying the deployment is done.
The secrets, and what each one guards
| Secret | Where it lives | Who reads it | If it leaks | Rotation |
|---|---|---|---|---|
| Postgres role passwords (3) | pg_hba.conf side: nowhere; client side: Zitadel's config, Minimal's config, the registered database in Minimal's store |
the three services | the leaking role's one database, from app-1's address only |
ALTER ROLE ... PASSWORD, update the one config, restart the one unit |
| Zitadel masterkey | /etc/zitadel/masterkey |
zitadel |
every secret Zitadel stores | cannot be rotated in place; it is the reason the file is root-only and backed up |
provisioner.pat |
/etc/zitadel/provisioner.pat, root-only |
a person with root, or Chapter 8's tool | full control of the instance | delete the PAT in the console or with the user API, mint another |
login-client.pat |
/etc/zitadel/login-client.pat |
zitadel-login |
ability to create sessions for any user — this is the login's whole power | same; restart zitadel-login |
| Provider client secrets and Apple key | inside Zitadel, encrypted under the masterkey | Zitadel | sign-ins could be impersonated at the provider | rotate at the provider, update the IdP in Zitadel |
| Minimal server key | /etc/minimal/config.yml |
minimal; a person on app-1 for organisation lifecycle |
creation and deletion of organisations | change the file, restart minimal |
| Minimal encryption key | /etc/minimal/minimal.env |
minimal |
every definition, registered password and cached shape in the store | Volume II, Chapter 9: re-encrypt everything it sealed; not a routine operation |
| TLS private key | /etc/letsencrypt/live/..., and rendered into apisix.yaml |
APISIX | impersonation of both public names until revoked | revoke at Let's Encrypt, re-issue, re-render |
| Zitadel signing keys | inside Zitadel | Zitadel | forged tokens the gateway would accept | Zitadel rotates them itself; the gateway refreshes the JWKS on a miss |
Nothing in that table is a person's password or a person's token. People authenticate at the provider; their tokens live an hour on their own devices; the servers never store either. That is the property to protect when the next change is proposed.
What each piece says when it is unhappy
APISIX writes /usr/local/apisix/logs/error.log. Every refusal at the boundary is one line
there naming the plugin and the reason — No bearer token found in request, token is signed by unexpected algorithm, audience list does not contain the client id, serverless-post-function exits with http status code 403 — and a rise in any of them is the earliest sign of a client build
that is wrong or a token that is not what the gateway expects. Whether access.log is written is nginx_config.http.enable_access_log in config.yaml, which the
book's file leaves at the package default; before relying on it, set it explicitly and give it a
format that omits the Authorization header.
Zitadel logs to its journal, and one line matters more than the rest: unable to set instance
means a request arrived with a Host that names no instance — a gateway misconfiguration, a health
check hitting the port directly, or a new domain not yet added. Its metrics are at /debug/metrics
on the internal port, OpenTelemetry format, never routed through the gateway. The login's journal
shows Flow initiation failed when it cannot reach the API with the token it has, and its
/ui/v2/login/healthy says only that the process is up.
Minimal logs to its journal as Volume II, Chapter 6 describes, and its audit trail is the record
of who did what: every request through the gateway lands there with the ULID as the actor, and the
appctl identity is the only non-ULID actor that should ever appear. A sys: or admin actor
in the trail that is not a shell on app-1 is the alarm.
Postgres has log_connections on from Chapter 2, so every session start is a line in its log
with the role and database, and pg_stat_ssl is the standing check that each one was encrypted.
Two health checks, from outside, cover the whole thing: GET https://auth.example.com/.well-known/openid-configuration
answers 200 when the gateway, Zitadel and the instance domain are all right; GET https://api.example.com/minimal/system/api/v1/org with no token answers 401 when the gateway and
its route are up, and 502 when Minimal is not. Chapter 9's smoke test is the deeper check, on a
timer, from a host that is not app-1.
What breaks when one piece is down
| Down | Lost | Still works |
|---|---|---|
| APISIX | everything public | nothing; this is the single point, by design, and its restart is seconds |
| Zitadel | new sign-ins, token refresh, the console | every request from an app holding an unexpired access token — the gateway verifies against a cached JWKS and never calls Zitadel per request |
| Login V2 | new sign-ins | token refresh, and every API request; a person already signed in notices nothing for up to ninety days |
| Minimal | every API request | sign-in and token refresh; registration completes, as Chapter 7 says |
| Postgres | Zitadel and Minimal, entirely; the login within seconds | requests from apps with unexpired tokens fail at Minimal, not at the gateway |
db-1's private network |
the same as Postgres down | the same |
The second row is the one to remember. The gateway's local verification is what makes an identity provider outage a "no new sign-ins for a while" event rather than an "app is down" event, and the one-hour access token is the price — a revoked account keeps working for up to an hour either way.
Upgrading each piece
Zitadel releases carry migrations. The order is: back up the database; stop zitadel-login
and zitadel; replace the binary; run zitadel setup --config ... --steps ... --masterkeyFile ... --tlsMode external as the zitadel user, which applies the migrations and creates nothing new;
start both units; check the issuer and the login's health route. Replace the login bundle with the
same release's, always — the two are built together. Read the release notes for a change to
InstanceHostHeaders, to the Actions V1 sunset, or to Login V2's environment, because those are the
three things this book depends on that Zitadel has said it will change.
APISIX upgrades through apt. The package replaces /usr/local/apisix, and the two files under
conf/ are yours: keep them in version control, and after the upgrade diff the package's new
config-default.yaml against the previous one for a renamed key, then apisix test before
systemctl restart apisix. The enable_http2 placement in Chapter 5 was one such rename.
Minimal is Volume II, Chapter 9's process: a new binary, the same configuration, a restart, and
the schema file's changes applied to the store as minimalist as Chapter 2 did. Run the smoke test
after.
Postgres minor versions are apt upgrades with a restart; a major version is a pg_upgradecluster
with the extensions reinstalled for the new major first, and it is the one upgrade to rehearse on
the Mac before doing on db-1.
Node, for the login, follows NodeSource's current LTS; the bundle states which major it was built
for in its package.json.
Adding a platform, adding a provider, adding a role
Three routine changes, each smaller than it sounds:
- A platform is Chapter 4, Step 4 again — a second native application in the same project with
its own scheme — and then either the same route (both client ids are in the same audience family:
add the second to a
claim_validator.audiencecheck that accepts either, or run two routes) or a second route keyed on a header. The gateway'sclient_idis the setting that changes. - A provider is Chapter 4, Step 6 again, and nothing else: the app already shows whatever the organisation's login policy activates.
- A role is Chapter 4, Step 3 plus Chapter 6's templates that should name it. The gateway copies whatever roles the token carries; it does not have a list.
The closing checklist for the whole deployment
Each chapter had its own; this is the one to read once, at the end, against the live system.
- Only APISIX has a public port on
app-1, and onlyapp-1's private address can reach Postgres ondb-1. - Every application session to Postgres is TLS and each role can reach exactly one database —
pg_stat_ssland the three refused connections from Chapter 2. - The issuer is
https://auth.example.comand the gateway'svalid_issuerssays the same. - The Healthy Me project has
authorizationRequiredandprojectAccessRequired, its login policy has passwords off, and a user from another organisation is refused. - The action is bound, and a fresh user's first token carries a 26-character
minimal_user_id. - The echo test from Chapter 5 shows the five headers with the gateway's values and nothing the caller sent.
- Minimal runs with
permission_fail_open: false,agent_identityabsent,mcp.enabled: false, and every user-scoped table has a row scope onX-User-Id. - The smoke test passes on production, from a host that is not
app-1. - The backup set holds the dumps, the masterkey, the encryption key and the server key, in two places, and one restore has been rehearsed.
- The two
.patfiles have never been copied offapp-1, and the provisioner's has never been given to a service.
Exercises
Rotate the
minimalistpassword end to end:ALTER ROLE, the store connection in Minimal's configuration, and the registered database on the Healthy Me project — which is aPUTon the project carrying the whole database list, per Volume I, Chapter 3. Time it, and note which of the three you forgot on the first pass.Take
zitadeldown for ten minutes while the smoke test runs on a one-minute timer. Record which step fails and when, then bring it back and record when the test passes again. Compare with the table above.Upgrade Zitadel to the next patch release on the Mac first, following the order given, and write down every line the release notes contain about the three things this book depends on. If there are none, write that down too — it is what makes the production upgrade a fifteen-minute job.
DThe Files
Every configuration file the chapters showed in fragments, complete, for production first and
then the dev variants where they differ. Secrets are placeholders in angle brackets. Paths are
the ones the chapters install to. Where a file is Volume II's sample with a few values changed,
the changed values are listed and the sample is not reprinted.
db-1 — Postgres (Chapter 2)
/etc/postgresql/18/main/conf.d/90-minimal.conf
listen_addresses = 'localhost,10.0.0.10'
shared_preload_libraries = 'pg_cron'
cron.database_name = 'minimal'
password_encryption = 'scram-sha-256'
ssl = on
ssl_cert_file = '/etc/postgresql/18/main/server.crt'
ssl_key_file = '/etc/postgresql/18/main/server.key'
log_connections = on
log_disconnections = on/etc/postgresql/18/main/pg_hba.conf
# TYPE DATABASE USER ADDRESS METHOD
local all postgres peer
hostssl zitadel zitadel 10.0.0.20/32 scram-sha-256
hostssl minimal minimalist 10.0.0.20/32 scram-sha-256
hostssl healthyme minimalist 10.0.0.20/32 scram-sha-256
hostssl healthyme healthyme_app 10.0.0.20/32 scram-sha-256Roles, databases and grants — run once as postgres on the local socket:
CREATE ROLE zitadel LOGIN PASSWORD '<zitadel-password>' NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT CONNECTION LIMIT 20;
CREATE ROLE minimalist LOGIN PASSWORD '<minimalist-password>' NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT CONNECTION LIMIT 60;
CREATE ROLE healthyme_app LOGIN PASSWORD '<healthyme-password>' NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT CONNECTION LIMIT 10;
CREATE DATABASE zitadel OWNER zitadel;
CREATE DATABASE minimal OWNER minimalist;
CREATE DATABASE healthyme OWNER healthyme_app;
REVOKE CONNECT ON DATABASE zitadel FROM PUBLIC;
REVOKE CONNECT ON DATABASE minimal FROM PUBLIC;
REVOKE CONNECT ON DATABASE healthyme FROM PUBLIC;
GRANT CONNECT ON DATABASE healthyme TO minimalist;
\c minimal
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS pg_cron;
GRANT USAGE ON SCHEMA cron TO minimalist;
REVOKE ALL ON SCHEMA public FROM PUBLIC;
GRANT ALL ON SCHEMA public TO minimalist;
\c healthyme
REVOKE ALL ON SCHEMA public FROM PUBLIC;
GRANT ALL ON SCHEMA public TO healthyme_app;
GRANT USAGE ON SCHEMA public TO minimalist;
ALTER DEFAULT PRIVILEGES FOR ROLE healthyme_app IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO minimalist;
ALTER DEFAULT PRIVILEGES FOR ROLE healthyme_app IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO minimalist;Then Minimal's postgres.sql from the database repository, applied as minimalist to minimal.
app-1 — Zitadel (Chapter 3)
/etc/zitadel/config.yaml — root:zitadel, 0640
Log:
Level: info
Port: 8081
ExternalDomain: auth.example.com
ExternalPort: 443
ExternalSecure: true
TLS:
Enabled: false
Database:
postgres:
Host: 10.0.0.10
Port: 5432
Database: zitadel
MaxOpenConns: 10
MaxIdleConns: 5
User:
Username: zitadel
Password: <zitadel-password>
SSL:
Mode: verify-full
RootCert: /etc/ssl/certs/internal-ca.pem
DefaultInstance:
Features:
LoginV2:
Required: true
BaseURI: "https://auth.example.com/ui/v2/login"
OIDCSettings:
AccessTokenLifetime: 1h
IdTokenLifetime: 1h
RefreshTokenIdleExpiration: 720h
RefreshTokenExpiration: 2160h/etc/zitadel/steps.yaml — root:zitadel, 0640; used once, by zitadel setup --steps
FirstInstance:
InstanceName: littlebit
DefaultLanguage: en
PatPath: /var/lib/zitadel/provisioner.pat
LoginClientPatPath: /var/lib/zitadel/login-client.pat
Org:
Name: Littlebit
Human:
UserName: admin
FirstName: Instance
LastName: Admin
Email:
Address: admin@example.com
Verified: true
Password: "<a strong initial password>"
PasswordChangeRequired: true
Machine:
Machine:
Username: provisioner
Name: Provisioning service account
Pat:
ExpirationDate: "2030-01-01T00:00:00Z"
LoginClient:
Machine:
Username: login-client
Name: Login V2 client
Pat:
ExpirationDate: "2030-01-01T00:00:00Z"/etc/zitadel/masterkey — root:zitadel, 0640, 32 characters from /dev/urandom.
/etc/zitadel/provisioner.pat — root:root, 0600. /etc/zitadel/login-client.pat —
root:zitadel-login, 0640. Both written by setup to /var/lib/zitadel/ and moved.
/etc/zitadel/login.env — root:zitadel-login, 0640
NODE_ENV=production
PORT=3000
HOSTNAME=127.0.0.1
ZITADEL_API_URL=http://127.0.0.1:8081
ZITADEL_SERVICE_USER_TOKEN_FILE=/etc/zitadel/login-client.pat/etc/systemd/system/zitadel.service
[Unit]
Description=Zitadel identity server
After=network-online.target
Wants=network-online.target
[Service]
User=zitadel
Group=zitadel
WorkingDirectory=/var/lib/zitadel
ExecStart=/usr/local/bin/zitadel start --config /etc/zitadel/config.yaml --masterkeyFile /etc/zitadel/masterkey --tlsMode external
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/zitadel
[Install]
WantedBy=multi-user.target/etc/systemd/system/zitadel-login.service
[Unit]
Description=Zitadel Login V2
After=network-online.target zitadel.service
Wants=network-online.target
[Service]
User=zitadel-login
Group=zitadel-login
WorkingDirectory=/opt/zitadel-login
EnvironmentFile=/etc/zitadel/login.env
ExecStart=/opt/zitadel-login/entrypoint.sh /usr/bin/node apps/login/server.js
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.targetZitadel — the action (Chapter 4)
minimal-user-id.js, registered on the app's organisation and bound to flow 2, trigger 5
/**
* Complement Token, trigger "Pre access token creation".
* Gives every user a server-assigned ULID the first time a token is minted for
* them, keeps it in user metadata, and asserts it as a claim on every token.
*/
function minimalUserId(ctx, api) {
var KEY = 'minimal_user_id';
var existing = null;
var md = ctx.v1.user.getMetadata();
if (md && md.metadata) {
for (var i = 0; i < md.metadata.length; i++) {
if (md.metadata[i].key === KEY) { existing = md.metadata[i].value; break; }
}
}
if (!existing) {
existing = ulid();
api.v1.user.setMetadata(KEY, existing);
}
api.v1.claims.setClaim(KEY, existing);
}
function ulid() {
var A = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
var t = Date.now();
var out = '';
for (var i = 9; i >= 0; i--) { out = A.charAt(t % 32) + out; t = Math.floor(t / 32); }
for (var j = 0; j < 16; j++) { out += A.charAt(Math.floor(Math.random() * 32)); }
return out;
}The app's scope list, verbatim, with the two ids from Chapter 4:
openid profile email offline_access urn:zitadel:iam:org:id:<ORG> urn:zitadel:iam:org:project:id:<PROJ>:aud urn:zitadel:iam:org:project:rolesapp-1 — APISIX (Chapter 5)
/usr/local/apisix/conf/config.yaml
apisix:
node_listen:
- 80
enable_http2: true
ssl:
enable: true
listen:
- port: 443
enable_admin: false
deployment:
role: data_plane
role_data_plane:
config_provider: yaml
nginx_config:
error_log_level: warn/usr/local/apisix/conf/apisix.yaml — root:root, 0640; the ssls block is rendered by the
certbot deploy hook and shown here with placeholders
ssls:
- id: public
snis:
- auth.example.com
- api.example.com
cert: |
<fullchain.pem>
key: |
<privkey.pem>
upstreams:
- id: zitadel
nodes:
"127.0.0.1:8081": 1
type: roundrobin
pass_host: pass
- id: zitadel-grpc
nodes:
"127.0.0.1:8081": 1
type: roundrobin
scheme: grpc
pass_host: pass
- id: zitadel-login
nodes:
"127.0.0.1:3000": 1
type: roundrobin
pass_host: pass
- id: minimal
nodes:
"127.0.0.1:3045": 1
type: roundrobin
pass_host: node
- id: acme
nodes:
"127.0.0.1:8402": 1
type: roundrobin
routes:
- id: acme-challenge
uri: /.well-known/acme-challenge/*
priority: 100
upstream_id: acme
- id: zitadel-login
host: auth.example.com
uri: /ui/v2/login/*
upstream_id: zitadel-login
- id: zitadel-grpc
host: auth.example.com
uri: /*
vars: [["http_content_type", "~~", "^application/grpc"]]
priority: 10
upstream_id: zitadel-grpc
- id: zitadel
host: auth.example.com
uri: /*
upstream_id: zitadel
- id: minimal-api
host: api.example.com
uri: /minimal/*
upstream_id: minimal
plugins:
serverless-pre-function:
phase: rewrite
functions:
- |
return function(conf, ctx)
for _, h in ipairs({"X-Org-Id","X-Project-Id","X-Space-Id","X-User-Id","X-User-Roles",
"LB-Access-Token","X-Userinfo","X-Access-Token","X-ID-Token"}) do
ngx.req.clear_header(h)
end
local args = ngx.req.get_uri_args()
if args["lb-access-token"] ~= nil then
args["lb-access-token"] = nil
ngx.req.set_uri_args(args)
end
end
openid-connect:
client_id: "<the native app's client id>"
client_secret: "not-used-for-bearer-validation"
discovery: "https://auth.example.com/.well-known/openid-configuration"
bearer_only: true
use_jwks: true
token_signing_alg_values_expected: RS256
claim_validator:
issuer:
valid_issuers: ["https://auth.example.com"]
audience:
required: true
match_with_client_id: true
set_userinfo_header: true
set_access_token_header: false
set_id_token_header: false
set_refresh_token_header: false
unauth_action: deny
serverless-post-function:
phase: access
functions:
- |
return function(conf, ctx)
local core = require("apisix.core")
local raw = ngx.req.get_headers()["X-Userinfo"]
if not raw then return 401, {message = "no verified identity"} end
local claims = core.json.decode(ngx.decode_base64(raw))
local uid = claims and claims["minimal_user_id"]
if type(uid) ~= "string" or #uid ~= 26 or not uid:match("^[0-9A-HJKMNP-TV-Z]+$") then
return 403, {message = "token carries no server-assigned user id"}
end
local roles = {}
local r = claims["urn:zitadel:iam:org:project:roles"]
if type(r) == "table" then
for k, _ in pairs(r) do roles[#roles + 1] = k end
end
table.sort(roles)
ngx.req.clear_header("X-Userinfo")
ngx.req.clear_header("Authorization")
ngx.req.set_header("X-Org-Id", "lbl")
ngx.req.set_header("X-Project-Id", "healthyme")
ngx.req.set_header("X-Space-Id", "live")
ngx.req.set_header("X-User-Id", uid)
ngx.req.set_header("X-User-Roles", table.concat(roles, ","))
end
limit-count:
count: 600
time_window: 60
key_type: var
key: http_x_user_id
rejected_code: 429
policy: local
#END/etc/letsencrypt/renewal-hooks/deploy/apisix.sh — 0755
#!/bin/bash
set -euo pipefail
LIVE=/etc/letsencrypt/live/auth.example.com
RULES=/usr/local/apisix/conf/apisix.yaml
python3 - "$LIVE" "$RULES" <<'PY'
import re, sys
live, rules = sys.argv[1], sys.argv[2]
ind = lambda t: "\n".join(" " + l for l in t.strip().splitlines())
cert = open(f"{live}/fullchain.pem").read(); key = open(f"{live}/privkey.pem").read()
block = ("ssls:\n - id: public\n snis:\n - auth.example.com\n - api.example.com\n"
" cert: |\n" + ind(cert) + "\n key: |\n" + ind(key) + "\n")
s = open(rules).read()
s = re.sub(r"(?ms)^ssls:\n.*?(?=^upstreams:)", "", s)
s = s.replace("upstreams:", block + "\nupstreams:", 1)
open(rules, "w").write(s)
PYapp-1 — Minimal (Chapter 6)
/etc/minimal/config.yml — root:minimal, 0640. Volume II, Chapter 9's sample/config.yml
with these values; every other key is the sample's:
service:
host: "127.0.0.1"
port: "3045"
environment: "production"
server_key: "<a real secret, generated for this deployment>"
log_level: "info"
debug_mode: false
show_request_details: false
strict_local_host: true
definition_store:
type: postgres
host: 10.0.0.10
port: "5432"
schema: minimal
username: minimalist
password_source: file
password: '<minimalist-password>'
ssl_mode: "verify-full"
max_open_connections: 20
encrypt_database: true
encryption_key_source: env
encryption_key: ''
auto_api:
permission_fail_open: false
access_token:
max_per_user: 5
default_expiry_days: 30
retain_dead_days: 30
sweep_interval_secs: 86400
# agent_identity: omitted
mcp:
enabled: false/etc/minimal/minimal.env — root:minimal, 0640
MINIMAL_ENCRYPTION_KEY=<openssl rand -hex 16>/etc/systemd/system/minimal.service
[Unit]
Description=Minimal API server
After=network-online.target
Wants=network-online.target
[Service]
User=minimal
Group=minimal
WorkingDirectory=/var/lib/minimal
EnvironmentFile=/etc/minimal/minimal.env
ExecStart=/usr/local/bin/minimal -c /etc/minimal/config.yml
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/minimal
[Install]
WantedBy=multi-user.targetThe app's first table, created as healthyme_app:
CREATE TABLE device (
device_id CHAR(26) PRIMARY KEY,
user_id CHAR(26) NOT NULL,
public_key TEXT NOT NULL,
platform TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX device_user_idx ON device (user_id);dev — the variants (Chapter 9)
~/dev/zitadel/config.yaml
Log:
Level: info
Port: 8081
ExternalDomain: localhost
ExternalPort: 8080
ExternalSecure: false
TLS:
Enabled: false
Database:
postgres:
Host: 127.0.0.1
Port: 5433
Database: zitadel
MaxOpenConns: 10
MaxIdleConns: 5
User:
Username: zitadel
Password: zitadel-dev-pw
SSL:
Mode: disable
DefaultInstance:
Features:
LoginV2:
Required: true
BaseURI: "http://localhost:8080/ui/v2/login"~/dev/zitadel/steps.yaml — Chapter 3's with InstanceName: littlebit-dev, the two token paths
under ~/dev/zitadel/, and PasswordChangeRequired: false.
The login, from ~/dev/zitadel/login:
env -u HTTP_PROXY -u HTTPS_PROXY -u http_proxy -u https_proxy \
HOSTNAME=127.0.0.1 PORT=3000 ZITADEL_API_URL=http://127.0.0.1:8081 \
ZITADEL_SERVICE_USER_TOKEN_FILE=$HOME/dev/zitadel/login-client.pat NODE_ENV=production \
./entrypoint.sh $(brew --prefix node@22)/bin/node apps/login/server.jsAPISIX in the Lima VM, /usr/local/apisix/conf/config.yaml:
apisix:
node_listen:
- 8080
enable_admin: false
deployment:
role: data_plane
role_data_plane:
config_provider: yaml
nginx_config:
error_log_level: warnand apisix.yaml: the production file with no ssls block and no acme upstream or route, no
host: on any route, these four upstream addresses —
upstreams:
- id: zitadel
nodes:
"host.lima.internal:8081": 1
type: roundrobin
pass_host: pass
- id: zitadel-grpc
nodes:
"host.lima.internal:8081": 1
type: roundrobin
scheme: grpc
pass_host: pass
- id: zitadel-login
nodes:
"host.lima.internal:3000": 1
type: roundrobin
pass_host: pass
- id: minimal
nodes:
"host.lima.internal:3047": 1
type: roundrobin
pass_host: node— and, in the minimal-api route, discovery: "http://localhost:8080/.well-known/openid-configuration",
valid_issuers: ["http://localhost:8080"], and the dev app's client id. The Lua is unchanged.
Minimal, ~/dev/minimal/config.yml: the sample with service.port "3047", mcp.service.port "3048", definition_store.schema minimal_book, the minimalist password, and
auto_api.permission_fail_open false; started with MINIMAL_ENCRYPTION_KEY from a file kept
beside it.
smoke.sh is printed in Chapter 9, Step 6, and is the same file for both environments.