ZITADEL
Overview
ZITADEL is a cloud-native identity and access management platform. It provides single sign-on over OpenID Connect, OAuth2 and SAML, passwordless and passkey login, multi-factor authentication, and multi-tenancy with organizations, projects, roles and self-service, all managed from a full web Console.
The SecureAnyCloud image ships the free and open source, AGPL-3.0 self-hosted ZITADEL. It runs the officially supported way, as the upstream containers alongside a bundled PostgreSQL, orchestrated by Docker Compose under systemd and fronted by nginx with TLS. All images are pinned by digest and captured into the VM, so your instance starts in seconds.
Because ZITADEL is itself the identity provider, nothing ships with a known secret. A unique 32-character masterkey, a unique initial admin password, a unique database password and unique API tokens are generated for each VM on first boot, before the port is reachable.
ZITADEL is a trademark of its respective owner. SecureAnyCloud produces this image, which is not affiliated with, endorsed by or sponsored by ZITADEL. It ships the free and open source AGPL-3.0 self-hosted software, unmodified.
What you get:
- ZITADEL v4.16.1 (the AGPL-3.0 self-hosted platform), with the API and Console pinned by image digest
- The required ZITADEL Login v2 UI container, pinned by image digest and served at
/ui/v2/login - A bundled PostgreSQL 17 database, pinned by image digest and reachable only inside a private Docker network (never published to a host port)
- Docker Engine with the API and login published to the loopback interface only, fronted by nginx with TLS on port
443 zitadel.service,zitadel-firstboot.service,zitadel-postboot.serviceandnginx.serviceas systemd units, enabled and active on boot- A unique 32-character masterkey, a unique admin password, a unique PostgreSQL password and unique API tokens, generated per VM on first boot and never baked into the image
- A clean, empty database on first boot, with no default login, no shipped secret and no prior data
- 24/7 SecureAnyCloud support
Prerequisites
You need an active Azure subscription, an SSH key pair, and a VNet with a subnet in the target region.
Standard_B2s (2 vCPU / 4 GiB RAM) is a sensible starting point. Increase the size for heavier workloads.
Configure the network security group (NSG) to allow inbound 22/tcp from your management network and 443/tcp for the Console and the identity endpoints. Port 80/tcp is also served and redirects to 443.
ZITADEL binds the OIDC issuer to the address you reach it on, so the image resolves your VM public IP on first boot. For production, move to your own domain with a real certificate (see Step 9).
Step 1 - Deploy from the Azure Marketplace
Sign in to the Azure Portal, choose Create a resource, search the Marketplace for ZITADEL by SecureAnyCloud, and select Create.
On the Basics tab, pick your subscription, resource group, region and size. Under Administrator account, choose SSH public key and paste your key. Under Inbound port rules, allow SSH (22) and HTTPS (443).
Select Review + create, then Create.
Step 2 - Deploy from the Azure CLI
# Accept the Marketplace terms once per subscription (required before the first CLI deploy)
az vm image terms accept --urn sac1647283583153:zitadel:default:latest
az vm create \
--resource-group <your-rg> \
--name zitadel \
--image sac1647283583153:zitadel:default:latest \
--size Standard_B2s \
--admin-username azureuser \
--ssh-key-values ~/.ssh/id_ed25519.pub \
--vnet-name <your-vnet> --subnet <your-subnet> \
--public-ip-sku Standard
az vm open-port --resource-group <your-rg> --name zitadel --port 443 --priority 1010Step 3 - Connect to your VM
ssh azureuser@<vm-public-ip>Step 4 - Confirm the services are running
On first boot, the image generates a unique masterkey, admin password and database password, initialises a clean PostgreSQL database, runs ZITADEL's setup, and starts the platform. Confirm that the systemd services are active and that all three containers (the API, the Login v2 UI and the bundled PostgreSQL) are up:
systemctl is-active docker zitadel nginx
sudo docker ps --format 'table {{.Names}}\t{{.Status}}'All three services report active, and zitadel-zitadel-api-1, zitadel-zitadel-login-1 and zitadel-postgres-1 are running.
The API and login are published to the loopback interface only and fronted by nginx. The bundled PostgreSQL is never exposed on a host port. The public liveness endpoint returns 200 with no credentials:
curl -sk -o /dev/null -w 'health: HTTP %{http_code}\n' https://127.0.0.1/debug/healthzStep 5 - Secure by default: no login ships with the image
ZITADEL is the identity provider for your other applications, so it must never ship with a known credential. Every API request without a valid token is rejected, and the initial admin exists only with the password generated for this VM.
Confirm that the API is locked down. This request sends no token and uses the address ZITADEL was configured with on first boot:
D=$(sudo grep '^ZITADEL_EXTERNALDOMAIN=' /etc/zitadel/zitadel.env | cut -d= -f2-)
curl -sk --resolve "$D:443:127.0.0.1" -o /dev/null -w 'no token: HTTP %{http_code}\n' "https://$D/auth/v1/users/me"The request returns HTTP 401. A valid token is accepted, while the public health endpoint stays open.
First boot writes your unique secrets to /root/zitadel-credentials.txt, which only root can read:
sudo stat -c '%a %U:%G' /root/zitadel-credentials.txtThe output reports 600 root:root. View the notes, which contain the masterkey, the admin login name and password, an administrator API token, the database password and the URLs, with:
sudo cat /root/zitadel-credentials.txtStep 6 - Sign in to the Console
Browse to https://<vm-public-ip>/ui/console. The VM uses a per-VM self-signed certificate, so accept the browser warning. For production, replace it with your own certificate (see Step 9).
ZITADEL redirects you to its Login v2 screen, which asks for your login name first, then your password.
Sign in with the administrator login name and password from /root/zitadel-credentials.txt:
Login name: <ADMIN_LOGINNAME> (for example zitadel-admin@zitadel.<vm-public-ip>) Password: <ADMIN_PASSWORD>The Console opens on the home dashboard. The top navigation offers Organization, Projects, Users, Role Assignments, Actions and Settings.
Step 7 - Manage users and projects
Open the Users tab to see the accounts in your organization. On a fresh VM, the only account is the administrator created on first boot. From here, you add human users and service accounts, and manage their login policies and multi-factor settings.
The Projects tab hosts the applications that your users authenticate against. Each project holds one or more applications (a web frontend, a mobile app or a backend service), and each application has its own OIDC or SAML configuration.
Step 8 - The OIDC issuer your applications integrate with
Applications integrate with ZITADEL through its OpenID Connect issuer. The discovery document advertises the authorization, token, userinfo and JWKS endpoints. All of them are bound to the address this VM was configured with on first boot:
D=$(sudo grep '^ZITADEL_EXTERNALDOMAIN=' /etc/zitadel/zitadel.env | cut -d= -f2-)
curl -sk --resolve "$D:443:127.0.0.1" "https://$D/.well-known/openid-configuration" | python3 -m json.tool | grep -E '"issuer"|_endpoint'You can also call ZITADEL's management and auth APIs with the administrator API token from your notes. From your own machine, substitute your VM public IP and the token:
curl https://<vm-public-ip>/auth/v1/users/me \
-H 'Authorization: Bearer <ADMIN_PAT>'Step 9 - Production: your own domain with TLS
For production, move ZITADEL to your own domain with a real certificate. Point a DNS record at the VM public IP. Then set ZITADEL_EXTERNALDOMAIN in /etc/zitadel/zitadel.env to that domain, replace the certificate under /etc/zitadel/certs, and re-apply the stack:
sudo docker compose --env-file /etc/zitadel/zitadel.env -f /etc/zitadel/compose.yaml up -dRestrict inbound access in your NSG to 443/tcp (and 22/tcp from your management network).
Keep /root/zitadel-credentials.txt confidential. The masterkey encrypts ZITADEL's data at rest, and the admin credentials grant full control of your identity platform.
Support
This image is supported 24/7 by SecureAnyCloud. For help with deployment or configuration, contact support through secureanycloud.com.
Deploy on Azure
Launch ZITADEL on Ubuntu 24.04 LTS on Azure with 24/7 support from SecureAnyCloud.
View this image
Need Help?
Our support team is available 24/7.
sales@secureanycloud.com
Overview
This guide covers the deployment and configuration of ZITADEL on AWS, using the SecureAnyCloud AWS Marketplace AMI. ZITADEL is an open source, self-hosted identity and access management platform. It is a complete authentication and authorization backend for your applications.
ZITADEL issues and validates tokens over OpenID Connect and OAuth 2.0, speaks SAML, and supports passwordless and passkey login and multi-factor authentication. It is multi-tenant through organizations, projects, roles and self-service. A full web console manages organizations, users, projects, applications, login policies and branding.
The image runs the official upstream ZITADEL containers, unmodified and pinned by digest. These are the ZITADEL server (API and console) and the Login v2 user interface that ZITADEL v4 requires, alongside a bundled PostgreSQL 17 database that holds the identity store. All three run under a single systemd unit on a private container network. Host nginx terminates TLS on port 443 and reverse proxies to both.
Because ZITADEL is itself the security boundary, this image ships with no known secret of any kind. Every application that trusts this identity provider would be compromised by a shared masterkey or a known administrator password, so nothing is baked in.
On the first boot of every instance, before the service is reachable, the image generates the following:
- A unique 32-character masterkey, which is the key that encrypts ZITADEL's secrets at rest
- A unique PostgreSQL password
- A unique initial administrator password
- The per-instance API tokens
- A fresh TLS certificate
The captured image contains no database, no masterkey, no administrator password and no private key.
What you get:
- The official ZITADEL server container (API plus administration console), pinned by digest and run unmodified
- The official ZITADEL Login v2 user interface container, pinned by digest and served at
/ui/v2/login - A bundled PostgreSQL 17 database that holds the identity store. It is not published on any host port and is reachable only over the private container network
- nginx terminating TLS on :443 and reverse proxying to the console and the login interface
- No plaintext listener at all. Single sign-on flows carry tokens, so nothing listens on port 80 and the security group publishes only
22and443 - A self-signed TLS certificate regenerated per instance on first boot, whose subject alternative name is the instance's own address. Replace it with your own certificate from a trusted authority for production
- A dedicated EBS data volume at
/var/lib/zitadelthat holds the identity store and the container image layers, and that you can resize without rebuilding the instance - The verbatim AGPL-3.0 license text and a written source offer baked into the image
Connecting to your instance
Connect over SSH on port 22 using the key pair you selected at launch. The login user depends on the operating system variant you launched:
- AMI variant ZITADEL 4.16.1 on Ubuntu 24.04: operating system Ubuntu 24.04 LTS, SSH login user ubuntu
ssh -i /path/to/your-key.pem ubuntu@<public-ip>Confirm the platform is running
Every service should report active, and the listening sockets should show only SSH and nginx reachable from the network. The ZITADEL API and the Login v2 interface are bound to loopback, and PostgreSQL is not published on a host port at all. Run the following checks:
systemctl is-active docker.service zitadel-firstboot.service zitadel.service zitadel-postboot.service nginx.service echo "--- listening sockets ---" sudo ss -tlnp | awk 'NR==1 || /:443 |:8080 |:3000 |:5432 |:22 /'active active active active active --- listening sockets --- State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess LISTEN 0 511 0.0.0.0:443 0.0.0.0:* users:(("nginx",pid=20466,fd=5),("nginx",pid=20465,fd=5),("nginx",pid=20464,fd=5)) LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1153,fd=3),("systemd",pid=1,fd=78)) LISTEN 0 4096 127.0.0.1:8080 0.0.0.0:* users:(("docker-proxy",pid=6606,fd=8)) LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("docker-proxy",pid=6812,fd=8)) LISTEN 0 511 [::]:443 [::]:* users:(("nginx",pid=20466,fd=6),("nginx",pid=20465,fd=6),("nginx",pid=20464,fd=6)) LISTEN 0 4096 [::]:22 [::]:* users:(("sshd",pid=1153,fd=4),("systemd",pid=1,fd=79))Port 5432 does not appear, because the database is reachable only from the ZITADEL container over the private container network. Ports 8080 and 3000 are bound to 127.0.0.1, so you can reach them only through the TLS front door.
Check health and the OpenID Connect discovery document
ZITADEL binds its OIDC issuer to the instance's own external address, which it resolves from instance metadata on first boot. This block reads that address from the instance and asserts that the service is healthy.
DOMAIN=$(sudo grep -E '^ZITADEL_EXTERNALDOMAIN=' /etc/zitadel/zitadel.env | cut -d= -f2-) code=$(curl -sk --resolve "$DOMAIN:443:127.0.0.1" -o /dev/null -w '%{http_code}' "https://$DOMAIN/debug/healthz") [ "$code" = "200" ] || { echo "healthz returned $code"; exit 1; } echo "healthz: HTTP $code" curl -sk --resolve "$DOMAIN:443:127.0.0.1" "https://$DOMAIN/.well-known/openid-configuration" \
| python3 -c 'import json,sys;d=json.load(sys.stdin);print("issuer: ",d["issuer"]);print("authorization_endpoint:",d["authorization_endpoint"]);print("token_endpoint: ",d["token_endpoint"]);print("jwks_uri: ",d["jwks_uri"])'healthz: HTTP 200 issuer: https://54.158.48.92 authorization_endpoint: https://54.158.48.92/oauth/v2/authorize token_endpoint: https://54.158.48.92/oauth/v2/token jwks_uri: https://54.158.48.92/oauth/v2/keysThe issuer is this instance's own address, not localhost. Applications you register validate tokens against this issuer and the jwks_uri above.
Retrieve your per-instance credentials
First boot writes the credentials generated for this instance to a file that only root can read. Listing the field names confirms what the file holds without printing any secret value.
sudo stat -c '%n mode=%a owner=%U:%G' /root/zitadel-credentials.txt
sudo grep -oE '^[A-Z_]+=' /root/zitadel-credentials.txt/root/zitadel-credentials.txt mode=600 owner=root:root ZITADEL_MASTERKEY= ADMIN_LOGINNAME= ADMIN_PASSWORD= <set-at-first-boot>= POSTGRES_PASSWORD= <set-at-first-boot>= CONSOLE_URL=To read the full file, including the administrator password you need to sign in, run the following on the instance. The output contains live secrets, so this guide does not reproduce it:
sudo cat /root/zitadel-credentials.txtKeep the masterkey safe. ZITADEL_MASTERKEY encrypts ZITADEL's secrets at rest. If you lose it, you cannot decrypt the contents of the identity store. Back it up in a secure location, separate from the instance.
Confirm the secrets are unique to this instance
First boot generates these values, and they are never baked into the image. Printing only their lengths shows that they are populated without disclosing them.
sudo awk -F= '/^ZITADEL_MASTERKEY=/{print "masterkey length: " length($2) " characters"}' /etc/zitadel/zitadel.env
sudo awk -F= '/^ZITADEL_ADMIN_PASSWORD=/{print "admin password length: " length($2) " characters"}' /etc/zitadel/zitadel.envmasterkey length: 32 characters admin password length: 44 charactersIf you launch a second instance from the same AMI, both values differ. Nothing is shared between instances.
Sign in to the console
Browse to the console over HTTPS. There is no plaintext listener, so use https, not http.
The certificate is self-signed per instance, so your browser warns you on the first visit. Accept the warning, or install your own certificate as described later in this guide.
echo "https://<public-ip>/ui/console"ZITADEL redirects you to the Login v2 sign-in page. Enter the ADMIN_LOGINNAME from the credentials file, then the ADMIN_PASSWORD.
After you sign in, you land on the console home. It offers integration quick starts for common application frameworks and a short checklist for registering your first application and creating your first project.
Manage users
The Users view lists the human users and service accounts in the organization. A fresh instance contains only the administrator created on first boot.
Select New to add a human user or a service account. Service accounts authenticate machine to machine with a token rather than an interactive login, which is how the management API examples below are driven.
Create projects and applications
A project groups the applications that authenticate your users. You register each application inside a project as a web, native, single-page, API or SAML application, and each one gets its own client configuration.
To connect your first application, open Projects and create a project. Then add an application to it and choose the application type that matches your stack. ZITADEL issues a client ID and, for confidential clients, a client secret.
Point your application at the issuer shown earlier. The application then discovers the authorization, token and JWKS endpoints automatically.
Treat the client secret like a password. ZITADEL shows it once, when you create the application. Store it in your application's secret manager, never in source control.
Review instance settings
Instance settings hold the defaults that every organization inherits: login behavior, identity providers, password complexity, lockout, password expiry, notification providers and branding. The header confirms the running version and the domains this instance answers on.
Before you go to production, review Password complexity, Lockout and Login Behavior and Security. Also configure an SMTP Provider so that ZITADEL can send verification and password-reset messages.
Drive the management API with the per-instance token
The credentials file contains ADMIN_PAT, a personal access token for a machine service account with the IAM_OWNER role. It authenticates the management and auth APIs for automation.
The image ships a verification script that exercises the whole security model end to end. The script checks the public health endpoint, confirms that the OIDC issuer matches this instance, and authenticates the management API with the per-instance token. It also creates and reads back a project, and confirms that no token, a wrong token, a blank password and a common default password are all rejected.
sudo /usr/local/sbin/zitadel-roundtrip.shOK round-trip: issuer=https://54.158.48.92 project=sac-smoke-1785081851-23796 (PAT gates mgmt API; per-instance admin password gates login; no/blank/wrong/default rejected)To call the API yourself from a workstation, send the token as a bearer credential:
curl -sk -H "Authorization: Bearer <your-token>" https://<public-ip>/auth/v1/users/meConfirm unauthenticated access is refused
The console and the APIs are not reachable without a credential. This block asserts that an unauthenticated call and a call that carries an invalid token are both rejected. It succeeds only when ZITADEL refuses both.
DOMAIN=$(sudo grep -E '^ZITADEL_EXTERNALDOMAIN=' /etc/zitadel/zitadel.env | cut -d= -f2-) code=$(curl -sk --resolve "$DOMAIN:443:127.0.0.1" -o /dev/null -w '%{http_code}' "https://$DOMAIN/auth/v1/users/me") [ "$code" = "401" ] && echo "unauthenticated API call correctly refused (HTTP $code)" || { echo "expected 401, got $code"; exit 1; } code=$(curl -sk --resolve "$DOMAIN:443:127.0.0.1" -o /dev/null -w '%{http_code}' -H 'Authorization: Bearer not-a-real-token' "https://$DOMAIN/auth/v1/users/me") [ "$code" = "401" ] && echo "wrong bearer token correctly refused (HTTP $code)" || { echo "expected 401, got $code"; exit 1; }unauthenticated API call correctly refused (HTTP 401) wrong bearer token correctly refused (HTTP 401)ZITADEL's own login is the credential surface for this product. The image deliberately does not put an extra HTTP Basic Auth gate in front of it, because that gate would collide with the Authorization header that OAuth 2.0 and OpenID Connect bearer flows depend on.
Storage layout
The identity store and the container image layers live on a dedicated EBS volume, not the operating system disk, so you can resize them independently. The containerd layer store is bind-mounted into the same volume. This matters because Docker keeps image layers under /var/lib/containerd rather than the Docker data root.
findmnt -no SOURCE,TARGET,FSTYPE /var/lib/zitadel
findmnt -no SOURCE,TARGET /var/lib/containerd df -h --output=source,size,used,avail /var/lib/zitadel | tail -1/dev/nvme1n1 /var/lib/zitadel ext4 /dev/nvme1n1[/containerd-store] /var/lib/containerd /dev/nvme1n1 49G 986M 46GBoth entries are recorded in /etc/fstab by filesystem UUID, so the layout reproduces on every instance launched from this AMI. To grow the identity store, expand the EBS volume in the AWS console, then run sudo resize2fs /dev/nvme1n1.
Verify what is running
The three containers are pinned by digest, so the image bytes running here are exactly the bytes that were verified at build time.
sudo docker image ls --digests --format '{{.Repository}} {{.Digest}}' | sortghcr.io/zitadel/zitadel sha256:fea7ea2e4648d2dfe6a22a446252f084e4a370d8caedf08d3af4632cf517d4ef ghcr.io/zitadel/zitadel-login sha256:4414b6b03826d6f1bec1584143efe2d2c441b2b86c7dba97ecbf833dbcceeebc postgres sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193Use your own domain and a trusted certificate
Do this before you run ZITADEL in production. Single sign-on flows carry tokens, so the certificate matters more here than on an ordinary web application. ZITADEL binds its OIDC issuer to the configured external domain, so changing the domain also changes the issuer that your applications must trust.
Point a DNS name at the instance. Then, on the instance:
- Install your certificate and private key as
/etc/zitadel/certs/zitadel.crtand/etc/zitadel/certs/zitadel.key. Keep the key readable only byroot(chmod 0600). - Edit
/etc/zitadel/zitadel.envand setZITADEL_EXTERNALDOMAINto your domain. Then update the fourZITADEL_*URLV2values in the same file so that they use your domain instead of the instance address. - Apply the change and restart the front door:
sudo docker compose --env-file /etc/zitadel/zitadel.env -f /etc/zitadel/compose.yaml up -d sudo systemctl restart nginx- Re-check the discovery document and confirm that the issuer is now your domain:
curl -s https://<your-domain>/.well-known/openid-configuration | python3 -m json.tool | grep issuerThen update every registered application's redirect URIs to the new domain. Restrict the security group so that port 443 is reachable only from the networks that need it.
License
ZITADEL is licensed under the GNU Affero General Public License v3.0. The verbatim license text and a written offer for the corresponding source are baked into the image.
head -2 /usr/local/share/sac-zitadel/LICENSE.AGPL-3.0.txt grep -m1 'git clone' /usr/local/share/sac-zitadel/SOURCE_OFFER.txtGNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 git clone --branch v4.16.1 https://github.com/zitadel/zitadel.gitThe image ships the upstream containers unmodified. Within the ZITADEL repository, proto/ and apps/docs/ are Apache-2.0 licensed. apps/login/ (the Login v2 interface shipped in this image), packages/zitadel-client/ and packages/zitadel-proto/ are MIT licensed.
SecureAnyCloud is not affiliated with or endorsed by ZITADEL. This guide uses the ZITADEL name to identify the open source software shipped in this image.
Support
SecureAnyCloud provides 24/7 technical support for this image by email and live chat. Support covers:
- Deployment and initial configuration
- OpenID Connect, OAuth 2.0 and SAML application setup
- Passkey and multi-factor enrollment
- Organizations, projects, roles and grants
- TLS certificate installation and external domain swap
- Masterkey handling and backup of the identity store
- Growing the dedicated data volume and driving the management API
- Sizing, performance tuning and patching guidance
Deploy on AWS
Launch ZITADEL on AWS with 24/7 support from SecureAnyCloud.
View on Marketplace
Need Help?
Our support team is available 24/7.
sales@secureanycloud.com