Installare Zitadel 4 in produzione su Debian senza Docker

Installare Zitadel 4 in produzione su Debian senza Docker

La pagina della documentazione ufficiale di Zitadel che spiega come installare il prodotto su Linux si apre con un avviso che vale la pena leggere due volte: non vale per la versione 4, e rimanda al deploy con container. Già prima di quell'avviso la stessa pagina dichiarava di essere pensata per scopi di sviluppo e dimostrazione. Per l'alta disponibilità la raccomandazione è Kubernetes con il chart ufficiale.

Detto in modo diretto: la via che sto per descrivere non è quella che il progetto documenta. Non la nascondo e non la presento come un trucco: è una scelta di deploy consapevole, con le sue ragioni e i suoi costi, che ho portato in produzione e che continua a funzionare perché i binari nativi vengono ancora prodotti a ogni rilascio. Fra gli artefatti della 4.16.1 ci sono sia zitadel-linux-amd64.tar.gz sia zitadel-login.tar.gz, con le varianti per le altre architetture e il file dei checksum. Il codice per girare senza container c'è; quello che manca è la documentazione.

Nei primi tre articoli di questa serie ho spiegato il modello con cui Zitadel organizza identità e autorizzazioni, perché l'ho scelto al posto di Keycloak e cosa cambia self-hostarlo invece di appoggiarsi ad Auth0 o Okta. In questo articolo passo a metterlo in produzione su Debian, senza container, e la prima cosa da chiarire è che la versione 4 ha cambiato la forma di ciò che installi.

Perché scegliere il deploy nativo quando la documentazione dice il contrario?

Perché in molti contesti reali il container non risolve un problema, ne aggiunge uno. Le ragioni per cui l'ho fatto, in ordine di peso.

Il resto dell'infrastruttura non è containerizzata. Introdurre un runtime di container su macchine gestite con pacchetti e unità di sistema significa introdurre un secondo modello mentale di deploy, un secondo posto dove guardare i log, un secondo meccanismo di aggiornamento e un secondo insieme di cose che possono rompersi. Su un parco macchine piccolo questo costo è reale e ricorrente.

Il binario Go è statico e non ha dipendenze di runtime. È esattamente il tipo di software che non ha bisogno di essere isolato in un'immagine: non trascina un interprete, non pretende librerie di sistema particolari, non scrive dove non deve. Il container risolverebbe un problema di dipendenze che non esiste.

L'hardening con systemd è più semplice di quello equivalente in un container, e questo sorprende chi si aspetta il contrario. Le direttive che vedrai più avanti danno un filesystem in sola lettura, nessuna capability, nessuna possibilità di elevare i privilegi e nessun accesso alle home, in undici righe leggibili da chiunque faccia un audit senza dover conoscere il modello di sicurezza di un runtime di container.

Il costo di questa scelta è uno e lo dichiaro subito: stai facendo una cosa che la documentazione non copre, quindi quando qualcosa non torna sei tu la documentazione. È accettabile se il sistema è tuo e ne conosci ogni pezzo; non lo è se domani deve prenderlo in mano qualcuno che si aspetta la procedura standard.

Cosa installi davvero: due componenti, non uno

Questa è la differenza principale rispetto a qualunque guida scritta per la versione 2 o 3, incluse le mie note interne di allora, e ignorarla porta a un impianto che si avvia senza errori e in cui nessuno riesce a fare login.

Nella versione 4 l'interfaccia di autenticazione non è più servita dal binario del core. È un'applicazione separata che ascolta sulla porta 3000 e serve il percorso /ui/v2/login, e che dialoga con il core attraverso le API autenticandosi come un utente macchina. Sulle istanze nuove il core reindirizza lì ogni autenticazione: se a quel percorso non risponde nessuno, il login interattivo semplicemente non esiste, e non lo capisci dai log del core perché dal suo punto di vista sta funzionando tutto.

L'utente macchina in questione va creato nella console e gli va assegnato il ruolo IAM_LOGIN_CLIENT, come descritto nella documentazione ufficiale del client di login. Poi si emette per lui un token di accesso personale, e lo si passa all'applicazione di login tramite la variabile d'ambiente ZITADEL_SERVICE_USER_TOKEN, oppure, ed è la forma che preferisco su una macchina di produzione, tramite ZITADEL_SERVICE_USER_TOKEN_FILE, che punta a un file leggibile solo dall'utente di servizio. La differenza non è cosmetica: una variabile d'ambiente è leggibile da chiunque possa ispezionare il processo, un file ha permessi.

Esiste una via d'uscita documentata, e va conosciuta perché in alcune migrazioni è la scelta giusta: impostando ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED=false si riattiva la login classica dentro il core, e si torna a un solo componente. La uso quando devo aggiornare un impianto esistente in due tempi, portando prima il core e poi la login, invece di cambiare due cose insieme.

Il modo più veloce per capire se un'installazione della versione 4 è fatta bene: apri il percorso /ui/v2/login dal reverse proxy. Se risponde il core con un errore invece dell'applicazione di login, lo split di routing è sbagliato, e tutto il resto funzionerà benissimo tranne la cosa per cui hai installato un identity provider.

Preparazione della macchina

Do per scontato un Debian aggiornato e un PostgreSQL raggiungibile e amministrato a parte. Sulla versione del database vale la pena essere espliciti: dalla versione 3 CockroachDB non è più supportato e PostgreSQL è l'unica opzione, quindi qualunque guida che offra la scelta fra i due è vecchia di almeno due major.

Si parte da un utente di servizio senza shell e senza home, e da una struttura di cartelle di proprietà sua:

sudo useradd --system --no-create-home --shell /usr/sbin/nologin zitadel
sudo mkdir -p /opt/zitadel/etc /opt/zitadel/bin
sudo chown -R zitadel:zitadel /opt/zitadel/etc /opt/zitadel/bin
sudo chmod 750 /opt/zitadel/etc /opt/zitadel/bin

Poi la chiave maestra, che è il segreto con cui Zitadel cifra i dati sensibili nel database. Deve essere esattamente di 32 byte, identica su tutti i nodi, e va custodita fuori dalla macchina come custodiresti la chiave di un backup cifrato: se la perdi, il contenuto del database non è più leggibile, e non esiste un percorso di recupero.

tr -dc A-Za-z0-9 </dev/urandom | head -c 32 | sudo tee /opt/zitadel/etc/master.key
sudo chown zitadel:zitadel /opt/zitadel/etc/master.key
sudo chmod 400 /opt/zitadel/etc/master.key
sudo chattr +i /opt/zitadel/etc/master.key

Quell'ultima riga rende il file immutabile anche per l'utente amministrativo, ed è una precauzione che consiglio: protegge da uno script di deploy scritto male molto più che da un attaccante.

L'installazione del binario è uno scaricamento e uno spostamento, e il dettaglio che fa la differenza fra uno script casalingo e uno che puoi rieseguire è usare install(1) invece di mv, così proprietario, gruppo e permessi sono impostati nell'operazione stessa:

sudo install -o zitadel -g zitadel -m 0755 \
    "${TMPDIR}/zitadel-linux-amd64/zitadel" /opt/zitadel/bin/zitadel
sudo -u zitadel /opt/zitadel/bin/zitadel --version

I tre file di configurazione, e perché sono tre

Zitadel accetta più file passati con --config, e li fonde nell'ordine in cui li elenchi. È una proprietà utile che vale la pena sfruttare per separare le cose secondo chi le può leggere, invece di avere un unico file che finisce per non stare da nessuna parte.

Il primo file contiene la configurazione pubblica e deterministica, quella che puoi tenere sotto controllo di versione senza pensarci:

Log:
  Level: warn
  Formatter:
    Format: json

Telemetry:
  Enabled: false

Port: 8080
ExternalPort: 443
ExternalDomain: auth.example.com
ExternalSecure: true

# TLS terminata dal reverse proxy, il core parla HTTP in rete interna
TLS:
  Enabled: false

WebAuthNName: "Example Identity"

Database:
  postgres:
    Host: db.example.com
    Port: 5432
    Database: zitadel
    MaxOpenConns: 200
    MaxIdleConns: 20
    MaxConnLifetime: 30m
    MaxConnIdleTime: 5m
    User:
      Username: zitadel_user
      SSL:
        Mode: disable
    Admin:
      Username: postgres
      SSL:
        Mode: disable

Caches:
  Postgres:
    Enabled: true
    AutoPrune:
      Interval: 15m
      TimeOut: 30s

Due voci meritano una spiegazione perché sono quelle che si sbagliano. ExternalDomain e ExternalSecure descrivono come il mondo vede il servizio, non come lo vede la macchina: il core ascolta in chiaro sulla 8080 dietro il proxy, ma deve sapere di essere pubblicato in HTTPS sulla 443, perché quei valori finiscono negli URL che emette e nell'attributo di sicurezza dei cookie. Sbagliarli produce redirect verso http e sessioni che non si stabiliscono, con un sintomo che sembra un problema di cookie e non di configurazione. E WebAuthNName è il nome che l'utente vede quando registra una chiave hardware: cambiarlo dopo che qualcuno l'ha registrata invalida le credenziali già create, quindi va deciso una volta e lasciato stare.

Il secondo file contiene le credenziali, non finisce mai sotto controllo di versione, e ha permessi propri:

Database:
  postgres:
    User:
      Password: "..."
    Admin:
      Password: "..."

La distinzione fra i due utenti del database è più importante di quanto sembri. L'utente amministrativo serve solo durante la fase di inizializzazione, perché deve creare database, schemi e ruoli; l'utente applicativo è quello con cui il servizio lavora tutti i giorni e può avere privilegi ridotti. Tenerli separati significa che il processo che gira ventiquattro ore al giorno non ha i permessi per creare un database, che è esattamente il punto.

Il terzo file descrive la prima istanza e viene consumato una volta sola:

FirstInstance:
  Skip: false
  InstanceName: Example IAM
  DefaultLanguage: it
  Org:
    Name: Example S.r.l.
    Human:
      UserName: admin
      FirstName: IAM
      LastName: Administrator
      Email:
        Address: [email protected]
        Verified: true
      PreferredLanguage: it
      Password: "..."
      PasswordChangeRequired: true

La password iniziale deve rispettare la politica di complessità predefinita, altrimenti la fase di configurazione fallisce con un errore che rimanda alla policy e non alla password, e si perde tempo a cercare nel posto sbagliato.

Vale la pena sapere che ogni chiave di questi file si può sovrascrivere con una variabile d'ambiente nella forma ZITADEL_[SEZIONE]_[CHIAVE]. È il meccanismo che uso per i segreti quando c'è un gestore di segreti in casa: file per la struttura, ambiente per i valori sensibili.

L'unità systemd che regge un audit

Questa è la parte che giustifica da sola il deploy nativo, ed è il pezzo che consiglio di copiare anche a chi poi sceglierà i container:

[Unit]
Description=Zitadel IAM Service
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=0

[Service]
Type=simple
User=zitadel
Group=zitadel
WorkingDirectory=/opt/zitadel

ExecStart=/opt/zitadel/bin/zitadel start \
  --config /opt/zitadel/etc/defaults.yaml \
  --config /opt/zitadel/etc/secrets.yaml \
  --masterkeyFile /opt/zitadel/etc/master.key

StandardOutput=journal
StandardError=journal
Restart=on-failure
RestartSec=5s

PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
NoNewPrivileges=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectControlGroups=true
ReadWritePaths=/opt/zitadel
CapabilityBoundingSet=
AmbientCapabilities=

[Install]
WantedBy=multi-user.target

Le righe che contano davvero sono le ultime. ProtectSystem=strict rende l'intero filesystem in sola lettura tranne ciò che dichiari in ReadWritePaths, quindi un processo compromesso non può scrivere da nessuna parte se non nella sua cartella. CapabilityBoundingSet= vuoto toglie ogni capability del kernel, comprese quelle che il processo non ha mai chiesto: senza questa riga il processo eredita un insieme predefinito che non gli serve. NoNewPrivileges=true impedisce l'elevazione anche attraverso binari con il bit setuid. ProtectHome=true rende invisibili le home degli utenti.

Sono undici righe che un auditor legge in un minuto e capisce senza chiedere spiegazioni, ed è una proprietà che vale più di quanto sembri quando la conformità la deve dimostrare qualcun altro. Il fatto che i log vadano al giornale di sistema, poi, elimina la rotazione dei file: niente configurazione di rotazione, niente file che riempie un disco alle quattro di notte.

Se stai mettendo in produzione un identity provider e vuoi che l'impianto regga una verifica di sicurezza invece di limitarsi a funzionare, nel mio profilo professionale trovi il percorso su hardening di sistemi Debian, gestione di VPS e infrastruttura self-managed da cui viene questa configurazione. Se vuoi controllare com'è esposto il tuo dominio prima e dopo la messa in opera, il mio analizzatore di header di sicurezza ti dice cosa risponde davvero il proxy davanti all'identity provider.

Le tre fasi, e l'ordine che non si inverte

Zitadel separa il ciclo di vita in tre comandi distinti, e capire cosa fa ciascuno è ciò che rende poi possibile l'aggiornamento senza fermo.

init si esegue una volta sola nella vita del sistema. Si connette al database con l'utente amministrativo e crea database, schemi e utente applicativo. Non si ripete agli aggiornamenti.

sudo -u zitadel /opt/zitadel/bin/zitadel init \
  --config /opt/zitadel/etc/defaults.yaml \
  --config /opt/zitadel/etc/secrets.yaml

setup si esegue a ogni nuova versione, e applica le migrazioni dello schema e delle proiezioni. È idempotente, ma va eseguito da un solo esecutore alla volta: due nodi che lo lanciano insieme litigano sullo stesso schema. Su una sola macchina la cosa non si pone; con due nodi diventa il cardine dell'intera procedura, ed è il tema del pezzo su alta disponibilità e aggiornamenti senza fermo.

sudo -u zitadel /opt/zitadel/bin/zitadel setup \
  --config /opt/zitadel/etc/defaults.yaml \
  --config /opt/zitadel/etc/secrets.yaml \
  --steps  /opt/zitadel/etc/steps.yaml \
  --masterkeyFile /opt/zitadel/etc/master.key

start è la fase di esercizio, ed è quella che l'unità di sistema esegue. Il processo è stateless, quindi puoi averne quanti ne vuoi in parallelo contro lo stesso database.

Il secondo componente e lo split del proxy

Il secondo archivio di rilascio contiene l'applicazione di login. Va installata con lo stesso criterio, con un proprio utente di servizio o riusando quello esistente, una propria unità di sistema e le variabili che le dicono dove trovare il core e con quale identità presentarsi: l'indirizzo dell'API del core, e il token dell'utente macchina via ZITADEL_SERVICE_USER_TOKEN_FILE. Ascolta sulla porta 3000.

A quel punto il reverse proxy deve fare una cosa sola ma farla bene: mandare /ui/v2/login alla 3000 e tutto il resto alla 8080. In NGINX è questione di due blocchi, con l'accortezza che il blocco più specifico va dichiarato per primo:

location /ui/v2/login {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host              $host;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
}

location / {
    proxy_pass http://127.0.0.1:8080;
    proxy_set_header Host              $host;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_read_timeout 600s;
}

L'intestazione X-Forwarded-Proto non è decorativa: è così che il core sa di essere pubblicato in HTTPS pur ricevendo traffico in chiaro, e senza di essa gli URL emessi e gli attributi dei cookie tornano sbagliati. Se usi Apache, l'equivalente sono due direttive di proxy con il percorso specifico dichiarato prima di quello generico, e la stessa cura sulle intestazioni.

Cosa salvare, perché un backup del database non basta

Un identity provider ha una proprietà scomoda: il backup del database, da solo, non è ripristinabile. I dati sensibili sono cifrati con la chiave maestra, quindi un dump ripristinato su una macchina che non ha quella chiave è un archivio di byte illeggibili. È il genere di cosa che si scopre durante un ripristino di emergenza, cioè nel momento peggiore.

Le cose da salvare sono tre, e vanno tenute in posti diversi. Il database, con un dump logico giornaliero verso una destinazione esterna alla macchina, ed eventualmente una copia fisica settimanale se ti serve un punto di ripristino più granulare. La chiave maestra, che va nel gestore di segreti o nella cassaforte delle password aziendali, mai accanto al dump: se le tieni nello stesso posto, chi prende quel posto ha tutto. I file di configurazione, che sembrano ricostruibili e non lo sono: il file pubblico lo tieni sotto controllo di versione, quello con le credenziali no, e senza di esso il ripristino si blocca al primo avvio.

La prova che vale la pena istituire, perché è quella che distingue un piano di continuità reale da uno dichiarato: ripristinare periodicamente su una macchina di prova dump, chiave e configurazione, avviare il servizio e fare un login. Dura venti minuti e risponde all'unica domanda che conta, cioè se il ripristino funziona davvero. Un backup mai ripristinato non è un backup, è una speranza archiviata con cura.

C'è infine un dettaglio che riguarda proprio la versione 4 e che nei piani scritti per le versioni precedenti manca: fra le cose da custodire c'è ora anche il token dell'utente macchina della login. Non è un dato cifrato nel database, è una credenziale che vive in un file sulla macchina della login, e se ricostruisci quel nodo senza averla devi rigenerarla dalla console del core. Il che è fattibile, purché il core sia in piedi: se stai ripristinando entrambi, l'ordine è core prima, login dopo.

Cosa controllare prima di dichiararlo in produzione

La lista che eseguo, in quest'ordine, perché ogni voce ne presuppone una precedente.

Il servizio del core risponde all'endpoint di stato di salute e restituisce esattamente ok. Il documento di configurazione OpenID all'indirizzo /.well-known/openid-configuration è raggiungibile dall'esterno e contiene il dominio pubblico, non localhost né un indirizzo interno: se contiene quelli, ExternalDomain è sbagliato e ogni client fallirà in modo poco chiaro. Il percorso /ui/v2/login mostra l'interfaccia di autenticazione e non un errore del core. L'accesso con l'utente amministrativo creato in fase di configurazione funziona e chiede il cambio password. Il servizio è abilitato all'avvio e si riprende dopo un riavvio della macchina. La chiave maestra è custodita fuori dalla macchina, in un posto da cui la sapresti recuperare senza accedere alla macchina stessa.

E un controllo che aggiungo dopo aver visto cosa succede senza: prova a fermare il servizio e a farlo ripartire, prima di metterci sopra il traffico. Un impianto che si è avviato una volta durante l'installazione, con l'ambiente della tua sessione interattiva, non è la stessa cosa di un impianto che riparte da solo dopo un riavvio.

I tre modi in cui questa installazione si rompe

Metto per iscritto i tre sintomi che ho visto più spesso, perché ciascuno ha una causa che non somiglia al sintomo e ciascuno costa un'ora se non lo riconosci.

Il login reindirizza in ciclo e la sessione non si stabilisce. La causa quasi certa è la coppia ExternalSecure e X-Forwarded-Proto: il core crede di essere pubblicato in chiaro, quindi emette cookie senza l'attributo di sicurezza mentre il browser è su HTTPS, oppure costruisce redirect verso http che il proxy rimanda a https, e il giro ricomincia. Si diagnostica in trenta secondi guardando il documento di configurazione OpenID: se gli URL dentro cominciano con http://, hai trovato.

Tutto funziona tranne il login. È lo scenario specifico della versione 4 descritto sopra: il core sta bene, le API rispondono, la console è raggiungibile, ma il percorso /ui/v2/login non è instradato o l'applicazione di login non parte. Vale la pena guardare i log della login e non quelli del core: se il token dell'utente macchina è sbagliato o scaduto, l'errore è lì e il core non ne sa nulla.

Il servizio riparte in ciclo dopo un aggiornamento. Nove volte su dieci è la fase di configurazione non eseguita: il binario nuovo trova uno schema vecchio e si rifiuta di lavorare. La sequenza è sempre la stessa, prima setup e poi start, e l'unità di sistema esegue solo la seconda. È esattamente il motivo per cui la procedura di aggiornamento merita uno script e non la memoria di chi lo fa.

Vale la pena aggiungere una regola che ho imparato a mie spese e che copre tutti e tre i casi: quando qualcosa non torna, guarda prima cosa risponde il sistema dall'esterno e poi cosa dice di sé nei log. Il documento di configurazione OpenID e l'endpoint di stato di salute raccontano in due richieste come il servizio crede di essere pubblicato, e nella maggior parte dei casi la discrepanza fra quello e la realtà è tutta la diagnosi.

Un'ultima nota che vale per tutti e tre: i log stanno nel giornale di sistema e si leggono con journalctl -u zitadel -f. Sembra ovvio, ma su una macchina abituata ai file di log in /var/log la prima reazione di molti è cercare un file che non esiste, e concludere che il servizio non stia scrivendo nulla.

Quello che resta fuori da questo articolo è cosa fare quando le macchine diventano due, che non è la ripetizione di questa procedura: l'esistenza di una fase di configurazione che tocca lo schema del database impone un ordine preciso fra i nodi e un aggiornamento che non li tratta allo stesso modo. È il prossimo articolo della serie. Se nel frattempo stai portando in produzione un impianto simile e preferisci che qualcuno riveda la configurazione prima che ci passi il traffico vero, scrivimi pure: su un identity provider gli errori non si manifestano al deploy, si manifestano il primo lunedì mattina.

Ultima modifica: