SSD Nodes Learn 🎉 VPS desde $5.50/mês
Guias Matt ConnorPor Matt Connor

Como alojar o Zitadel num VPS com Docker

Instale o Zitadel num VPS com Docker, PostgreSQL, masterkey, TLS, SMTP e backups. Saiba por que a recomendação é 4 CPUs e 8 GB de RAM e como upgrades afetam o banco.

O que é necessário para alojar o Zitadel num VPS

Para alojar o Zitadel num VPS, precisa de um host Docker, de um nome DNS público a apontar para esse host, de PostgreSQL e de cerca de 4 núcleos de CPU com 8 GB de RAM. O Zitadel é um fornecedor de identidade. Emite tokens através de OIDC (OpenID Connect) e SAML (security assertion markup language), para que os outros serviços deixem de manter as suas próprias listas de utilizadores. A instalação é um curl e um docker compose up. Os elementos que determinam se a instalação se mantém operacional são a masterkey, o utilizador da base de dados, o SMTP (simple mail transfer protocol), a cópia de segurança e a primeira atualização.

Tudo o que se segue pressupõe o Ubuntu 24.04, o Docker Engine 24 ou posterior com o plugin Compose e um nome como auth.example.com já a resolver para o servidor.

De quanta RAM um VPS precisa para o Zitadel?

O quickstart do Compose na documentação do Zitadel pede 2 GB de RAM. Esse valor é para um portátil. O guia de produção do Zitadel apresenta valores diferentes.

ChartZitadel's own published sizing guidance, August 2026
The data behind this chart
[
  {
    "config": "Process floor, no load",
    "cpu_cores": 0.5,
    "ram_gb": 0.5
  },
  {
    "config": "Single node, reduced setup",
    "cpu_cores": 4,
    "ram_gb": 8
  },
  {
    "config": "HA node, logs and metrics on",
    "cpu_cores": 4,
    "ram_gb": 16
  }
]

Estas são recomendações publicadas, não medições feitas num servidor em execução. Use-as para perceber a dimensão do problema. O processo do Zitadel consome cerca de 0.5 GB de RAM em repouso. Os cores destinam-se ao hash de palavras-passe, que é deliberadamente lento. Por isso, um pico de logins provoca um pico de utilização da CPU. O PostgreSQL representa a outra parte do consumo: o mesmo guia calcula cerca de um core por 100 pedidos por segundo e 4 GB de RAM por core. Juntando os dois valores, chega-se aos 4 cores e aos 8 GB indicados no guia para um único nó, ou a 16 GB por nó quando o logging e as métricas estão ativos.

Assim, um VPS com 2 GB consegue iniciar esta stack, mas fica abaixo do recomendado pelo projeto para qualquer utilização real. O login é o serviço de que todos os outros serviços dependem. Quando fica indisponível, nenhum serviço que confie nele permite a entrada de utilizadores. Decidir que 8 GB é mais do que quer gastar com autenticação é uma escolha razoável e fica muito mais barato fazê-la agora do que depois de uma migração. A comparação entre Keycloak, Authentik e Zitadel explica o custo de cada opção em memória e em trabalho operacional, e um servidor Authentik self-hosted é normalmente a resposta para um servidor mais pequeno.

Obtenha a stack e fixe uma versão

mkdir zitadel-compose && cd zitadel-compose
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.yml
curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/.env.example
cp .env.example .env
chmod 600 .env

Esse ficheiro define os quatro serviços que vai efetivamente executar. O Traefik é o reverse proxy: encaminha por caminho e, com o overlay mais abaixo, termina o TLS (Transport Layer Security). zitadel-api é o binário Go na porta 8080. zitadel-login é a interface de início de sessão disponibilizada em /ui/v2/login. postgres contém tudo. Uma cache Redis e um coletor OpenTelemetry estão no mesmo ficheiro, atrás de perfis do Compose, e permanecem desativados até serem solicitados.

Ainda não execute docker compose up. O primeiro arranque cria a instância, e várias definições abaixo não podem ser alteradas depois sem trabalho adicional.

O .env que copiou fixa as próprias tags de imagem:

ZITADEL_VERSION=v4.16.0
TRAEFIK_IMAGE=traefik:v3.7.7
POSTGRES_IMAGE=postgres:17.10-alpine

A versão v4 atual é v4.17.1, publicada em 14 August 2026. Defina ZITADEL_VERSION com a versão que pretende executar e mantenha-se na linha v4, em vez de acompanhar sempre a versão mais recente. O curl acima obtém docker-compose.yml do branch main, que não está fixo a nenhuma versão. Por isso, confirme cópias de ambos os ficheiros num repositório git. Caso contrário, o mesmo comando numa máquina nova no próximo mês fornecerá um ficheiro diferente e não saberá o que foi alterado.

Atribua ao Postgres um utilizador próprio e uma palavra-passe real

O .env fornecido liga o Zitadel ao PostgreSQL como superutilizador, com a palavra-passe postgres:

POSTGRES_ADMIN_USER=postgres
POSTGRES_ADMIN_PASSWORD=postgres
ZITADEL_DATABASE_POSTGRES_DSN=postgresql://postgres:postgres@postgres:5432/zitadel?sslmode=disable

Há uma armadilha nesta etapa de reforço da segurança. A documentação do Zitadel indica que deve acrescentar POSTGRES_ZITADEL_PASSWORD a .env, mas o docker-compose.yml base nunca lê essa variável. Por isso, defini-la não altera nada. Alterar apenas POSTGRES_ADMIN_PASSWORD interrompe a ligação, porque a palavra-passe também está escrita literalmente na cadeia DSN (data source name). A DSN é a linha que define como o Zitadel estabelece a ligação.

Os comentários em .env.example explicam o restante: quando uma DSN está configurada, o Zitadel usa diretamente esse utilizador e não cria automaticamente um utilizador sem privilégios. Por isso, a role tem de existir antes do primeiro arranque. Gere uma palavra-passe, inicie o Postgres isoladamente e crie a role.

tr -dc A-Za-z0-9 </dev/urandom | head -c 32; echo

docker compose --env-file .env -f docker-compose.yml up -d postgres

docker compose --env-file .env -f docker-compose.yml exec -T postgres \
  psql -U postgres -d postgres <<'SQL'
CREATE ROLE zitadel LOGIN PASSWORD 'the-password-you-generated';
ALTER DATABASE zitadel OWNER TO zitadel;
SQL

docker compose --env-file .env -f docker-compose.yml exec -T postgres \
  psql -U postgres -d zitadel -c 'ALTER SCHEMA public OWNER TO zitadel;'

Essas chamadas psql são executadas dentro do contentor através do socket local, em que a imagem oficial do Postgres confia. Por isso, não pedem uma palavra-passe. O proprietário é a parte importante. No PostgreSQL 15 e posteriores, um GRANT ALL PRIVILEGES ON DATABASE simples já não permite que uma role crie tabelas no schema public. Por isso, a fase de configuração do Zitadel falha com um erro de permissões ao criar os seus schemas. Atribuir à role a propriedade da base de dados e do schema evita esse problema.

Agora aponte a DSN para a nova role e defina uma palavra-passe de administrador real enquanto está no ficheiro:

POSTGRES_ADMIN_PASSWORD=a-32-character-random-string
ZITADEL_DATABASE_POSTGRES_DSN=postgresql://zitadel:the-password-you-generated@postgres:5432/zitadel?sslmode=disable

sslmode=disable é adequado neste caso, porque o Postgres só está acessível na rede privada do Compose e a porta nunca é publicada no host. Depois do primeiro arranque completo, confirme que a role é realmente proprietária dos seus dados:

docker compose exec -T postgres psql -U zitadel -d zitadel -c '\dn'

O resultado deve listar um schema eventstore e um schema projections. Uma lista vazia significa que a fase de configuração não chegou a esse ponto. O log do contentor da API indicará a causa.

A masterkey e o custo de a perder

O Zitadel cifra os segredos antes de os armazenar: segredos de cliente, credenciais de fornecedores de identidade, a palavra-passe SMTP, sementes de palavras-passe de utilização única e chaves de máquinas. A masterkey desbloqueia todos esses dados. Tem exatamente 32 caracteres, e a documentação é clara quanto à consequência: não pode ser alterada sem perder o acesso aos dados cifrados.

Gere uma e substitua a linha de marcador de posição em .env:

tr -dc A-Za-z0-9 </dev/urandom | head -c 32; echo

Edite a linha ZITADEL_MASTERKEY=MasterkeyNeedsToHave32Characters em vez de acrescentar uma segunda linha. O Compose usa a última definição de uma chave repetida, por isso acrescentar outra funciona, mas um ficheiro com duas linhas masterkey é uma armadilha para quem o ler a seguir.

Agora pense onde essa chave fica armazenada. O ficheiro Compose inicia o contentor da API desta forma:

command: start-from-init --masterkey "${ZITADEL_MASTERKEY}"

A masterkey fica, portanto, na linha de comandos do contentor, onde docker inspect a mostra a qualquer pessoa que consiga aceder ao socket do Docker. Num VPS administrado por uma única pessoa, esta é uma troca aceitável, e o modo definido em .env é o que a protege no disco. Se não for aceitável, monte a chave como um ficheiro e use --masterkeyFile /run/secrets/zitadel-masterkey em vez disso. Assim, o valor não fica exposto nos argumentos do processo.

Copie a masterkey para o seu gestor de palavras-passe antes do primeiro arranque. Ela não aparece num dump da base de dados. Por isso, um dump restaurado com uma masterkey diferente cria uma instância que não consegue ler os próprios segredos. Guarde-a num local diferente do arquivo que contém o dump, para que uma única cópia de segurança roubada não contenha os dados cifrados e a chave para os ler.

Defina o domínio externo antes do primeiro arranque

ZITADEL_DOMAIN em .env alimenta ZITADEL_EXTERNALDOMAIN no contentor, e esse é o nome que os utilizadores introduzem. O Zitadel deriva dele o emissor OIDC, o URI base da interface de início de sessão, os endpoints SAML e o nome de início de sessão do primeiro administrador. Portanto, não é um valor meramente estético.

ZITADEL_DOMAIN=auth.example.com
ZITADEL_EXTERNALPORT=443
ZITADEL_EXTERNALSECURE=true

O Zitadel determina a instância à qual está a aceder a partir do cabeçalho Host. Se esse cabeçalho não corresponder a um domínio que conheça, todos os pedidos recebem a mesma resposta:

ID=QUERY-1kIjX Message=Instance not found

Este é o erro mais comum em instalações self-hosted do Zitadel e quase sempre significa uma de duas coisas. Ou ZITADEL_DOMAIN não é o nome que está a utilizar no acesso, ou um proxy à frente está a reescrever Host para o endereço do upstream. Aceder ao endereço IP do servidor em vez do nome também produz este erro.

Pode alterar estes valores mais tarde. O Zitadel tem de executar novamente a fase de configuração para aplicar a alteração, e cada aplicação que já tenha registado mantém os URI de redirecionamento antigos. Escolher agora o nome definitivo é muito mais simples do que mudá-lo posteriormente.

Termine o TLS com a sobreposição do Let's Encrypt

Para um domínio público, adicione a sobreposição do Let's Encrypt do Zitadel. Ela muda o Traefik para o desafio HTTP do ACME (ambiente de gestão automática de certificados) e substitui as portas publicadas por 80 e 443. Nenhum outro serviço no servidor pode ocupar qualquer uma dessas portas.

curl -fsSLO https://raw.githubusercontent.com/zitadel/zitadel/main/deploy/compose/docker-compose.mode-letsencrypt.yml
echo 'LETSENCRYPT_EMAIL=ops@example.com' >> .env

A sobreposição também define ZITADEL_EXTERNALPORT: 443 e ZITADEL_EXTERNALSECURE: true no contentor da API. Por isso, o URL público e os URLs que o Zitadel gera para si próprio são coerentes. O registo A tem de resolver antes de iniciar, porque o desafio HTTP falha sem ele.

Se já termina o TLS no nginx ou num balanceador de carga, use docker-compose.mode-external-tls.yml e defina TRAEFIK_TRUSTED_IPS com os intervalos a partir dos quais o proxy envia pedidos. O Traefik só aceita cabeçalhos X-Forwarded-* provenientes dos endereços dessa lista. Um valor incorreto faz com que o protocolo encaminhado seja descartado, e o Zitadel começa a gerar URLs http:// para um site HTTPS.

Um proxy upstream tem duas funções que o Zitadel exige. Tem de comunicar com o backend por HTTP/2, porque a API usa gRPC. Também tem de encaminhar Host sem alterações, juntamente com X-Forwarded-Proto: https. O exemplo de nginx do próprio Zitadel mostra a estrutura:

server {
    listen 443 ssl;
    http2 on;
    ssl_certificate     /etc/certs/selfsigned.crt;
    ssl_certificate_key /etc/certs/selfsigned.key;
    location /ui/v2/login {
        proxy_pass http://login-external-tls:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto https;
    }
    location / {
        grpc_pass grpc://zitadel-external-tls:8080;
        grpc_set_header Host $host;
        grpc_set_header X-Forwarded-Proto https;
    }
}

Os nomes upstream apresentados são os contentores da configuração de teste do Zitadel. Substitua-os pelos seus. Se disponibilizar o Zitadel numa porta diferente de 443, use grpc_set_header Host $host:$server_port; para incluir a porta no cabeçalho. O restante é um virtual host comum. Uma configuração de reverse proxy nginx explicada linha a linha aborda as partes que não são específicas do Zitadel.

O primeiro administrador e a alteração obrigatória da palavra-passe

O primeiro arranque cria uma instância, uma organização e um administrador humano. O nome de início de sessão é zitadel-admin@ mais zitadel. mais o seu domínio externo. Com ZITADEL_DOMAIN=auth.example.com, fica assim:

zitadel-admin@zitadel.auth.example.com

A palavra-passe é Password1!, exceto se definir uma própria. A predefinição original do Zitadel obriga a alterá-la no primeiro início de sessão, mas o ficheiro Compose fornecido substitui essa predefinição:

ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: false

Essa linha está definida diretamente em docker-compose.yml, em vez de ser lida de .env. Por isso, coloque os seus próprios valores numa pequena sobreposição. Dê-lhe o nome docker-compose.local.yml:

services:
  zitadel-api:
    environment:
      ZITADEL_FIRSTINSTANCE_ORG_HUMAN_EMAIL_ADDRESS: you@example.com
      ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: "a-long-temporary-password"
      ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: "true"

O Compose só carrega docker-compose.override.yml automaticamente quando é executado sem a opção -f. Todos os comandos do guia do Zitadel passam -f, o que desativa esse comportamento. Em vez de repetir uma lista de opções cada vez maior, fixe a lista de ficheiros em .env:

COMPOSE_FILE=docker-compose.yml:docker-compose.mode-letsencrypt.yml:docker-compose.local.yml

Agora inicie-o:

docker compose pull
docker compose up -d --wait

--wait mantém o comando em execução até os healthchecks passarem. Quando o contentor da API não chega a esse estado, o Compose termina com dependency failed to start: container zitadel-compose-zitadel-api-1 is unhealthy, e docker compose logs zitadel-api contém o motivo. Num primeiro arranque, o motivo costuma ser o comprimento da masterkey ou o DSN da base de dados.

Inicie sessão em https://auth.example.com/ui/console, altere a palavra-passe e ative um segundo fator para essa conta antes de criar qualquer outro recurso. Cada valor de ZITADEL_FIRSTINSTANCE_* só se aplica enquanto a primeira instância está a ser criada. Depois de a instância existir, alterá-los não produz qualquer efeito.

Por que a redefinição de senha não faz nada até o SMTP funcionar

Um provedor de identidade que não consegue enviar email apresenta uma falha que pode permanecer oculta durante semanas. O Zitadel envia email para convites de utilizadores, verificação de endereços, links de redefinição de senha, códigos de utilização única e notificações de reivindicação de domínio. Sem um provedor SMTP configurado, a Console continua a indicar que a ação foi concluída, e a mensagem é enviada para um worker de notificações sem destino para o envio. As predefinições atribuem MaxAttempts: 3 e MaxTtl: 5m a esse worker, que tenta novamente algumas vezes durante alguns minutos e depois para. Nada informa a pessoa que está à espera do link.

Configure esta opção na Console, nas definições da instância em https://auth.example.com/ui/console/settings. O formulário do provedor SMTP pede um endereço de email do remetente, um nome do remetente, o host e a porta, um utilizador, uma senha SMTP e um comutador TLS. Use o botão de teste nesse formulário antes de guardar, porque ele envia uma mensagem real: ela chega ou não chega.

Existe um conjunto correspondente de variáveis de ambiente, ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_SMTP_HOST e as variáveis relacionadas. Elas são aplicadas quando uma instância é criada. Numa stack que já está em execução, não têm efeito; por isso, a Console é o local correto para uma instância existente.

Há dois pontos sobre o envio a partir de um VPS, porque é aí que normalmente surgem as falhas. A maioria dos provedores bloqueia a porta de saída 25 em contas novas, por isso um envio direto para o servidor de email do destinatário termina por timeout sem um erro útil. Use um relay autenticado na porta 587. Publique também registos SPF (sender policy framework) e DKIM (domainkeys identified mail) para o domínio de envio. Caso contrário, o link de redefinição chega à pasta de spam, o que para o utilizador parece exatamente que o email nunca foi enviado.

Confirme o funcionamento antes de convidar alguém. Crie um utilizador descartável, peça uma redefinição de senha e monitorize a chegada da mensagem. Se ela não chegar, docker compose logs -f zitadel-api identifica a falha SMTP. A senha SMTP é armazenada encriptada na base de dados, o que é mais uma coisa que a masterkey mantém protegida.

Faça backup do Postgres e da masterkey separadamente

Tudo o que o Zitadel conhece está no PostgreSQL. A masterkey é o que permite desencriptar esses dados. Faça o backup em dois locais diferentes.

Primeiro, crie o dump:

sudo install -d -m 700 /srv/zitadel-backups
docker compose exec -T postgres \
  pg_dump -U postgres -Fc zitadel > "/srv/zitadel-backups/zitadel-$(date +%F).dump"

-Fc é o formato personalizado. Ele comprime os dados durante a exportação, e o pg_restore consegue ler partes específicas desse formato. exec -T remove a necessidade de um terminal, o que é importante porque este comando é executado pelo cron sem um terminal associado.

Depois, envie esse diretório para um local externo com o restic, que cifra e elimina duplicados:

export RESTIC_REPOSITORY="sftp:backup@backup.example.com:/srv/restic/zitadel"
export RESTIC_PASSWORD_FILE=/root/.restic-password
restic init
restic backup /srv/zitadel-backups
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune

restic init é executado uma única vez, apenas no primeiro dia. Coloque o dump e os dois últimos comandos em /usr/local/bin/zitadel-backup.sh e execute-o todas as noites:

0 3 * * * /usr/local/bin/zitadel-backup.sh

Faça backup do .env e de todos os ficheiros Compose que utiliza num repositório git. A masterkey é a exceção a tudo isto. Guarde-a no seu gestor de palavras-passe e num segundo local que não seja este repositório restic, porque um arquivo que contenha a base de dados e a respetiva chave de desencriptação deixa de ser um backup de um sistema cifrado.

Um backup que nunca foi restaurado é apenas uma suposição. Restaure-o numa base de dados temporária no mesmo servidor e verifique o conteúdo:

docker compose exec -T postgres createdb -U postgres zitadel_restore_test
docker compose exec -T postgres pg_restore -U postgres -d zitadel_restore_test \
  < /srv/zitadel-backups/zitadel-2026-08-21.dump
docker compose exec -T postgres psql -U postgres -d zitadel_restore_test -c '\dt eventstore.*'
docker compose exec -T postgres dropdb -U postgres zitadel_restore_test

Uma lista de tabelas no esquema eventstore significa que o dump é válido. Uma mensagem de erro a indicar que o esquema não existe significa que o dump não é válido. Assim, descobrirá o problema num dia em que a falha não lhe custa nada. O padrão geral para fazer backup e atualizar uma stack Compose aplica-se aqui quase sem alterações. Manter a masterkey fora do mesmo arquivo é a única parte específica do Zitadel.

Atualizar o Zitadel sem perder a instância

Uma atualização consiste em alterar a versão em .env e executar dois comandos:

docker compose pull
docker compose up -d --wait

Entenda o que o segundo comando faz antes de o executar num ambiente usado por pessoas para iniciar sessão. O comando do contentor é start-from-init. Ele executa as fases de inicialização e configuração antes de começar a atender pedidos. A fase de configuração consiste nas migrações da base de dados. Portanto, uma atualização executa migrações do esquema na base de dados em produção durante o arranque do contentor, sem intervenção, enquanto --wait fica à espera de um healthcheck. É exatamente por isso que o teste de restauração acima não é opcional.

Faça um dump novo imediatamente antes da atualização. O dump da noite anterior é outra coisa.

Não salte uma versão principal. Para passar da v3 para a v4, primeiro é necessário estar na v3.4.1 ou posterior, porque a v4 removeu as chaves de assinatura OIDC legadas. Por isso, os tokens assinados com as chaves antigas deixam de ser validados no momento da atualização. O aviso técnico A-10017 do Zitadel descreve este problema. A correção consiste em manter a versão v3 mais recente em execução durante tempo suficiente para os tokens antigos expirarem antes de atualizar.

Monitore a fase de configuração com docker compose logs -f zitadel-api. As migrações de um eventstore grande podem demorar alguns minutos. O Traefik não encaminhará pedidos para a API até o healthcheck passar. Por isso, o site ficará indisponível durante esse período. Planeie essa indisponibilidade em vez de a descobrir durante a atualização.

A reversão não consiste em repor a tag antiga. Depois de as migrações serem executadas, o binário antigo não entende o esquema encontrado. Portanto, reverter significa restaurar o dump. Quando a instância já tiver utilizadores reais, passe para docker-compose.prodlike.yml. Esse overlay executa a inicialização e a configuração em etapas separadas do arranque. Assim, uma migração é algo que pode iniciar e monitorar, em vez de ser um efeito secundário do reinício de um contentor.

O que apontar para o novo provedor de identidade

No Console, crie um projeto e, em seguida, uma aplicação dentro dele. Escolha OIDC para qualquer aplicação moderna. O Zitadel fornece um ID de cliente, um segredo de cliente e um documento de descoberta em https://auth.example.com/.well-known/openid-configuration. A maioria dos softwares self-hosted compatíveis com início de sessão único requer exatamente estes dados.

Muitos softwares não são compatíveis com esta funcionalidade ou só a disponibilizam num plano pago. No primeiro caso, o oauth2-proxy à frente da aplicação transforma qualquer serviço HTTP em algo que o Zitadel pode proteger. No segundo caso, vale a pena ler o custo do SSO nas aplicações self-hosted antes de planear uma migração com base numa funcionalidade que ainda não adquiriu.

FAQ

Quanta RAM e CPU um Zitadel auto-hospedado precisa?

O guia de produção do Zitadel recomenda cerca de 4 núcleos de CPU e 8 GB de RAM para um único nó com uma configuração reduzida, e 16 GB por nó com o registo de logs e as métricas ativados. O PostgreSQL é dimensionado separadamente, com aproximadamente um núcleo por cada 100 pedidos por segundo e 4 GB de RAM por núcleo. O quickstart do Compose inicia com menos de 2 GB, o que é suficiente para testes, mas fica abaixo do recomendado pelo projeto para um sistema do qual dependam outros serviços.

O que acontece se eu perder a masterkey do Zitadel?

Tudo o que estiver cifrado com ela continua cifrado. Os segredos dos clientes, as credenciais dos provedores de identidade, a palavra-passe SMTP e as seeds de palavras-passe de uso único não podem ser decifrados, e a chave não pode ser alterada posteriormente. Um dump da base de dados, por si só, não restaura uma instância funcional, porque o dump contém texto cifrado e nenhuma chave. Guarde a masterkey num gestor de palavras-passe e num local separado do backup que contém o dump. Se ambos desaparecerem, a única opção restante é reconstruir a instância de raiz.

Porque é que os emails de reposição de palavras-passe do Zitadel nunca chegam?

Porque não está configurado nenhum provedor SMTP ou porque o provedor configurado não consegue entregar as mensagens. O Zitadel coloca cada notificação numa fila para um worker, com três tentativas por predefinição, e indica sucesso na Console em qualquer dos casos, pelo que a falha é silenciosa. Configure o provedor SMTP nas definições da instância e use o botão de teste nesse formulário, que envia uma mensagem real. Num VPS, use um relay autenticado na porta 587, porque a maioria dos provedores bloqueia a porta de saída 25, e publique registos SPF e DKIM para o domínio de envio, para que o email não seja filtrado como spam.

Posso alterar o domínio externo do Zitadel depois da instalação?

Sim, mas não basta editar .env. Altere ZITADEL_EXTERNALDOMAIN, ZITADEL_EXTERNALPORT e ZITADEL_EXTERNALSECURE e deixe o Zitadel executar novamente a fase de configuração para aplicar a alteração. As aplicações já registadas mantêm os URIs de redirecionamento antigos e têm de ser atualizadas manualmente, e qualquer pedido cujo cabeçalho Host não corresponda a um domínio conhecido pelo Zitadel recebe Instance not found. Escolher o nome final antes do primeiro arranque evita todos estes problemas.