Security AWS

ZITADEL Identity and Access Management on AWS User Guide

| Product: ZITADEL on AWS

Overview

This guide covers the deployment and configuration of ZITADEL on AWS using cloudimg AWS Marketplace AMIs. ZITADEL is an open source, self hosted identity and access management platform. It is a complete authentication and authorization backend for your applications: it issues and validates tokens over OpenID Connect and OAuth 2.0, speaks SAML, supports passwordless and passkey login and multi factor authentication, and 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: 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 a unique 32 character masterkey (the key that encrypts ZITADEL's secrets at rest), a unique PostgreSQL password, a unique initial administrator password, the per instance API tokens and a fresh TLS certificate. The captured image contains no database, no masterkey, no administrator password and no private key.

What is included:

  • 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, served at /ui/v2/login

  • A bundled PostgreSQL 17 database holding the identity store, not published on any host port and 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 22 and 443

  • A self signed TLS certificate regenerated per instance on first boot (the certificate's 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/zitadel holding the identity store and the container image layers, independently resizable without rebuilding the instance

  • The verbatim AGPL-3.0 licence 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 Operating system SSH login user
ZITADEL 4.16.1 on Ubuntu 24.04 Ubuntu 24.04 LTS 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.

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))

Note that 5432 does not appear: the database is only reachable from the ZITADEL container over the private container network, and 8080 and 3000 are bound to 127.0.0.1 so they can only be reached 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 is resolved from instance metadata on first boot. This block reads that address from the instance and asserts 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/keys

The issuer is this instance's own address, not a placeholder and not localhost. Applications you register will validate tokens against this issuer and the jwks_uri above.

Retrieve your per instance credentials

The credentials generated for this instance are written to a file 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=
ADMIN_PAT=
POSTGRES_PASSWORD=
ZITADEL_URL=
CONSOLE_URL=

To read the full file, including the administrator password you need to sign in, run the following on the instance. Its output contains live secrets, so it is never captured or reproduced here:

sudo cat /root/zitadel-credentials.txt

Keep the masterkey safe. ZITADEL_MASTERKEY encrypts ZITADEL's secrets at rest. If you lose it, the contents of the identity store cannot be decrypted. Back it up somewhere secure and separate from the instance.

Confirm the secrets really are unique to this instance

These values are generated on first boot, never baked into the image. Printing only their lengths demonstrates 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.env
masterkey length: 32 characters
admin password length: 44 characters

Launch a second instance from the same AMI and both values will be different. 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 will warn on first visit — accept it, or install your own certificate as described below.

echo "https://<public-ip>/ui/console"

You are redirected to the Login v2 sign in page. Enter the ADMIN_LOGINNAME from the credentials file, then the ADMIN_PASSWORD.

The ZITADEL Login v2 sign in page served over HTTPS, showing a Welcome back heading, an Enter your login details prompt, a Loginname field, a Register new user link, Back and Continue buttons, and language and light or dark theme controls

After signing in you land on the console home, which offers integration quick starts for common application frameworks and a short checklist for registering your first application and creating your first project.

The ZITADEL administration console home showing a Get started with Zitadel heading, an Integrate Zitadel into your application panel with framework icons and a Create Application button, a YOUR NEXT STEPS progress checklist covering registering an application, logging in to your application and creating a project, and top navigation for Home, Organization, Projects, Users, Role Assignments, Actions and Settings

Manage users

The Users view lists the human users and service accounts in the organization. A fresh instance contains just the administrator created for this instance on first boot.

The ZITADEL console Users view listing one user, ZITADEL Admin, with columns for display name, preferred login name, e mail, status Active, created at and last modified, plus Users and Service Accounts tabs and Filter and New buttons

Use 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. Each application inside a project is registered as a web, native, single page, API or SAML application and gets its own client configuration.

The ZITADEL console Projects view explaining that a project hosts one or more applications used to authenticate users, with Owned Projects and Granted Projects tabs, one existing ZITADEL project card showing created and last modified dates, and a Create New Project tile

To connect your first application: open Projects, create a project, then add an application to it, choosing 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 and it will discover the authorization, token and JWKS endpoints automatically.

Treat the client secret like a password. It is shown once when the application is created. Store it in your application's secret manager, never in source control.

Review instance settings

Instance settings hold the defaults every organization inherits: login behaviour, identity providers, password complexity, lockout, password expiry, notification providers and branding. The header confirms the running version and the domains this instance answers on.

The ZITADEL console Instance Settings page showing status Running, the instance ID, name ZITADEL, version v4.16.1, the per instance custom domains, and creation and change dates, with a Feature Settings panel and a left menu covering Notifications, SMTP Provider, SMS Phone Provider, Login Behavior and Security, Identity Providers, Password complexity, Password expiry and Lockout

Before going to production, review Password complexity, Lockout and Login Behavior and Security, and configure an SMTP Provider so 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: it checks the public health endpoint, confirms the OIDC issuer matches this instance, authenticates the management API with the per instance token, 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.sh
OK round-trip: issuer=https://54.158.48.92 project=cloudimg-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/me

Confirm 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 carrying an invalid token are both rejected, and it succeeds only when both are correctly refused.

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 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 they can be resized independently. The containerd layer store is bind mounted into the same volume, which 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   46G

Both 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 and then run sudo resize2fs /dev/nvme1n1.

Verify what is running

The three containers are pinned by digest, so the bytes running here are exactly the bytes that were verified at build time.

sudo docker image ls --digests --format '{{.Repository}}  {{.Digest}}' | sort
ghcr.io/zitadel/zitadel  sha256:fea7ea2e4648d2dfe6a22a446252f084e4a370d8caedf08d3af4632cf517d4ef
ghcr.io/zitadel/zitadel-login  sha256:4414b6b03826d6f1bec1584143efe2d2c441b2b86c7dba97ecbf833dbcceeebc
postgres  sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193

Use your own domain and a trusted certificate

Do this before production use. 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:

  1. Install your certificate and private key as /etc/zitadel/certs/zitadel.crt and /etc/zitadel/certs/zitadel.key, keeping the key readable only by root (chmod 0600).
  2. Edit /etc/zitadel/zitadel.env and set ZITADEL_EXTERNALDOMAIN to your domain, then update the four ZITADEL_*URLV2 values in the same file so they use your domain instead of the instance address.
  3. 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
  1. Re-check the discovery document and confirm the issuer is now your domain:
curl -s https://<your-domain>/.well-known/openid-configuration | python3 -m json.tool | grep issuer

Then update every registered application's redirect URIs to the new domain, and restrict the security group so port 443 is reachable only from the networks that need it.

Licence

ZITADEL is licensed under the GNU Affero General Public License v3.0. The verbatim licence text and a written offer for the corresponding source are baked into the image.

head -2 /usr/local/share/cloudimg-zitadel/LICENSE.AGPL-3.0.txt
grep -m1 'git clone' /usr/local/share/cloudimg-zitadel/SOURCE_OFFER.txt
                    GNU AFFERO GENERAL PUBLIC LICENSE
                       Version 3, 19 November 2007

  git clone --branch v4.16.1 https://github.com/zitadel/zitadel.git

The upstream containers are shipped unmodified. Within the ZITADEL repository, proto/ and apps/docs/ are Apache-2.0 licensed, and apps/login/ (the Login v2 interface shipped in this image), packages/zitadel-client/ and packages/zitadel-proto/ are MIT licensed. cloudimg is not affiliated with or endorsed by ZITADEL; the ZITADEL name is used to identify the open source software shipped in this image.

Support

cloudimg provides 24/7 technical support for this image by email and live chat. We can help with deployment and initial configuration, OpenID Connect, OAuth 2.0 and SAML application setup, passkey and multi factor enrolment, 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, driving the management API, sizing, performance tuning and patching guidance.