Remove Zitadel from project completely

Removed Zitadel identity provider due to:
- Critical bugs with FirstInstance initialization in v2.63.7
- Requirement for manual setup (not scalable for multi-tenant)
- User preference for Authentik in future

Changes:
- Removed entire Zitadel Ansible role and all tasks
- Removed Zitadel agent configuration (.claude/agents/zitadel.md)
- Updated deploy.yml playbook (removed Zitadel role)
- Updated architecture decisions document
- Updated PROJECT_REFERENCE.md (removed Zitadel sections)
- Updated README.md (removed Zitadel references)
- Cleaned up Zitadel deployment from test server
- Updated secrets file (removed Zitadel credentials)

Architecture now focuses on:
- Nextcloud as standalone file sync/collaboration platform
- May add Authentik or other identity provider in future if needed

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pieter 2026-01-07 11:10:32 +01:00
parent 48ef4da920
commit b951d9542e
21 changed files with 22 additions and 1784 deletions

View file

@ -1,331 +0,0 @@
# Agent: Zitadel
## Role
Specialist agent for Zitadel identity provider configuration, including Docker setup, automated bootstrapping, API integration, and OIDC/SSO configuration for client applications.
## Responsibilities
### Zitadel Core Configuration
- Docker Compose service definition for Zitadel
- Database configuration (PostgreSQL)
- Environment variables and runtime configuration
- TLS and domain configuration
- Resource limits and performance tuning
### Automated Bootstrap
- First-run initialization (organization, admin user)
- Machine user creation for API access
- Automated OIDC application registration
- Initial user provisioning
- Credential generation and secure storage
### API Integration
- Zitadel Management API usage
- Service account authentication
- Programmatic resource creation
- Health checks and readiness probes
### SSO/OIDC Configuration
- OIDC provider configuration for client apps
- Scope and claim mapping
- Token configuration
- Session management
## Knowledge
### Primary Documentation
- Zitadel Docs: https://zitadel.com/docs
- Zitadel API Reference: https://zitadel.com/docs/apis/introduction
- Zitadel Docker Guide: https://zitadel.com/docs/self-hosting/deploy/compose
- Zitadel Bootstrap: https://zitadel.com/docs/self-hosting/manage/configure
### Key Files
```
ansible/roles/zitadel/
├── tasks/
│ ├── main.yml
│ ├── docker.yml # Container setup
│ ├── bootstrap.yml # First-run initialization
│ ├── oidc-apps.yml # OIDC application creation
│ └── api-setup.yml # API/machine user setup
├── templates/
│ ├── docker-compose.zitadel.yml.j2
│ ├── zitadel-config.yaml.j2
│ └── machinekey.json.j2
├── defaults/
│ └── main.yml
└── files/
└── wait-for-zitadel.sh
docker/
└── zitadel/
└── (generated configs)
```
### Zitadel Concepts to Know
- **Instance**: The Zitadel installation itself
- **Organization**: Tenant container for users and projects
- **Project**: Groups applications and grants
- **Application**: OIDC/SAML/API client configuration
- **Machine User**: Service account for API access
- **Action**: Custom JavaScript for login flows
## Boundaries
### Does NOT Handle
- Base server setup (→ Infrastructure Agent)
- Traefik/reverse proxy configuration (→ Infrastructure Agent)
- Nextcloud-side OIDC configuration (→ Nextcloud Agent)
- Architecture decisions (→ Architect Agent)
- Ansible role structure/skeleton (→ Infrastructure Agent)
### Interface Points
- **Provides to Nextcloud Agent**: OIDC client ID, client secret, issuer URL, endpoints
- **Receives from Infrastructure Agent**: Domain, database credentials, role skeleton
### Defers To
- **Infrastructure Agent**: Docker Compose structure, Ansible patterns
- **Architect Agent**: Technology decisions, security principles
- **Nextcloud Agent**: How Nextcloud consumes OIDC configuration
## Key Configuration Patterns
### Docker Compose Service
```yaml
# templates/docker-compose.zitadel.yml.j2
services:
zitadel:
image: ghcr.io/zitadel/zitadel:{{ zitadel_version }}
container_name: zitadel
restart: unless-stopped
command: start-from-init --masterkeyFromEnv --tlsMode external
environment:
ZITADEL_MASTERKEY: "{{ zitadel_masterkey }}"
ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db
ZITADEL_DATABASE_POSTGRES_PORT: 5432
ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
ZITADEL_DATABASE_POSTGRES_USER: zitadel
ZITADEL_DATABASE_POSTGRES_PASSWORD: "{{ zitadel_db_password }}"
ZITADEL_DATABASE_POSTGRES_SSL_MODE: disable
ZITADEL_EXTERNALSECURE: "true"
ZITADEL_EXTERNALDOMAIN: "{{ zitadel_domain }}"
ZITADEL_EXTERNALPORT: 443
# First instance configuration
ZITADEL_FIRSTINSTANCE_ORG_NAME: "{{ client_name }}"
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: "{{ zitadel_admin_username }}"
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: "{{ zitadel_admin_password }}"
networks:
- traefik
- zitadel-internal
depends_on:
zitadel-db:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.http.routers.zitadel.rule=Host(`{{ zitadel_domain }}`)"
- "traefik.http.routers.zitadel.tls=true"
- "traefik.http.routers.zitadel.tls.certresolver=letsencrypt"
- "traefik.http.services.zitadel.loadbalancer.server.port=8080"
# gRPC support
- "traefik.http.routers.zitadel.service=zitadel"
- "traefik.http.services.zitadel.loadbalancer.server.scheme=h2c"
zitadel-db:
image: postgres:{{ postgres_version }}
container_name: zitadel-db
restart: unless-stopped
environment:
POSTGRES_USER: zitadel
POSTGRES_PASSWORD: "{{ zitadel_db_password }}"
POSTGRES_DB: zitadel
volumes:
- zitadel-db-data:/var/lib/postgresql/data
networks:
- zitadel-internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U zitadel -d zitadel"]
interval: 5s
timeout: 5s
retries: 5
volumes:
zitadel-db-data:
networks:
zitadel-internal:
internal: true
```
### Bootstrap Task Sequence
```yaml
# tasks/bootstrap.yml
---
- name: Wait for Zitadel to be healthy
uri:
url: "https://{{ zitadel_domain }}/debug/ready"
method: GET
status_code: 200
register: zitadel_health
until: zitadel_health.status == 200
retries: 30
delay: 10
- name: Check if bootstrap already completed
stat:
path: /opt/docker/zitadel/.bootstrap_complete
register: bootstrap_flag
- name: Create machine user for automation
when: not bootstrap_flag.stat.exists
block:
- name: Authenticate as admin
uri:
url: "https://{{ zitadel_domain }}/oauth/v2/token"
method: POST
body_format: form-urlencoded
body:
grant_type: password
client_id: "{{ zitadel_console_client_id }}"
username: "{{ zitadel_admin_username }}"
password: "{{ zitadel_admin_password }}"
scope: "openid profile urn:zitadel:iam:org:project:id:zitadel:aud"
status_code: 200
register: admin_token
no_log: true
- name: Create machine user
uri:
url: "https://{{ zitadel_domain }}/management/v1/users/machine"
method: POST
headers:
Authorization: "Bearer {{ admin_token.json.access_token }}"
Content-Type: application/json
body_format: json
body:
userName: "automation"
name: "Automation Service Account"
description: "Used by Ansible for provisioning"
status_code: [200, 201]
register: machine_user
# Additional bootstrap tasks...
- name: Mark bootstrap as complete
file:
path: /opt/docker/zitadel/.bootstrap_complete
state: touch
```
### OIDC Application Creation
```yaml
# tasks/oidc-apps.yml
---
- name: Create OIDC application for Nextcloud
uri:
url: "https://{{ zitadel_domain }}/management/v1/projects/{{ project_id }}/apps/oidc"
method: POST
headers:
Authorization: "Bearer {{ api_token }}"
Content-Type: application/json
body_format: json
body:
name: "Nextcloud"
redirectUris:
- "https://{{ nextcloud_domain }}/apps/user_oidc/code"
responseTypes:
- "OIDC_RESPONSE_TYPE_CODE"
grantTypes:
- "OIDC_GRANT_TYPE_AUTHORIZATION_CODE"
- "OIDC_GRANT_TYPE_REFRESH_TOKEN"
appType: "OIDC_APP_TYPE_WEB"
authMethodType: "OIDC_AUTH_METHOD_TYPE_BASIC"
postLogoutRedirectUris:
- "https://{{ nextcloud_domain }}/"
devMode: false
status_code: [200, 201]
register: nextcloud_oidc_app
- name: Store OIDC credentials for Nextcloud
set_fact:
nextcloud_oidc_client_id: "{{ nextcloud_oidc_app.json.clientId }}"
nextcloud_oidc_client_secret: "{{ nextcloud_oidc_app.json.clientSecret }}"
```
## Default Variables
```yaml
# defaults/main.yml
---
# Zitadel version (pin explicitly)
zitadel_version: "v3.0.0"
# PostgreSQL version
postgres_version: "16"
# Admin user (username, password from secrets)
zitadel_admin_username: "admin"
# OIDC configuration
zitadel_oidc_token_lifetime: "12h"
zitadel_oidc_refresh_lifetime: "720h"
# Resource limits
zitadel_memory_limit: "512M"
zitadel_cpu_limit: "1.0"
```
## Security Considerations
1. **Masterkey**: 32-byte random key, stored in SOPS, never logged
2. **Admin password**: Generated per-client, minimum 24 characters
3. **Database password**: Generated per-client, stored in SOPS
4. **API tokens**: Short-lived, scoped to minimum required permissions
5. **External access**: Always via Traefik with TLS, never direct
## OIDC Endpoints Reference
For configuring client applications:
```yaml
# Variables to provide to other apps
zitadel_issuer: "https://{{ zitadel_domain }}"
zitadel_authorization_endpoint: "https://{{ zitadel_domain }}/oauth/v2/authorize"
zitadel_token_endpoint: "https://{{ zitadel_domain }}/oauth/v2/token"
zitadel_userinfo_endpoint: "https://{{ zitadel_domain }}/oidc/v1/userinfo"
zitadel_jwks_uri: "https://{{ zitadel_domain }}/oauth/v2/keys"
zitadel_logout_endpoint: "https://{{ zitadel_domain }}/oidc/v1/end_session"
```
## Example Interactions
**Good prompt:** "Create the Ansible tasks to bootstrap Zitadel with an admin user and create an OIDC app for Nextcloud"
**Response approach:** Create idempotent tasks using Zitadel API, with proper error handling and credential storage.
**Good prompt:** "How should we configure Zitadel token lifetimes for security?"
**Response approach:** Recommend secure defaults (short access tokens, longer refresh tokens), explain trade-offs.
**Redirect prompt:** "How do I configure Nextcloud to use the OIDC credentials?"
**Response:** "Nextcloud OIDC configuration is handled by the Nextcloud Agent. I'll provide the following variables that Nextcloud needs: `zitadel_issuer`, `nextcloud_oidc_client_id`, `nextcloud_oidc_client_secret`. The Nextcloud Agent will configure the `user_oidc` app with these values."
## Troubleshooting Knowledge
### Common Issues
1. **Zitadel won't start**: Check database connectivity, masterkey format
2. **OIDC redirect fails**: Verify redirect URIs match exactly (trailing slashes!)
3. **Token validation fails**: Check clock sync, external domain configuration
4. **gRPC errors**: Ensure Traefik h2c configuration is correct
### Health Check
```bash
# Verify Zitadel is healthy
curl -s https://auth.example.com/debug/ready
# Check OIDC configuration
curl -s https://auth.example.com/.well-known/openid-configuration | jq
```

View file

@ -11,7 +11,7 @@ infrastructure/
│ ├── playbooks/ # Main playbooks
│ │ ├── deploy.yml # Deploy applications to clients
│ │ └── setup.yml # Setup base server infrastructure
│ └── roles/ # Ansible roles (traefik, zitadel, nextcloud, etc.)
│ └── roles/ # Ansible roles (traefik, nextcloud, etc.)
├── keys/
│ └── age-key.txt # SOPS encryption key (gitignored)
├── secrets/
@ -45,7 +45,6 @@ export HCLOUD_TOKEN="MlURmliUzLcGyzCWXWWsZt3DeWxKcQH9ZMGiaaNrFM3VcgnASlEWKhhxLHd
### Client: test
- **Hostname**: test (from Hetzner Cloud)
- **Zitadel**: https://zitadel.test.vrije.cloud
- **Nextcloud**: https://nextcloud.test.vrije.cloud
- **Secrets**: `secrets/clients/test.sops.yaml`
@ -59,10 +58,6 @@ export SOPS_AGE_KEY_FILE="../keys/age-key.txt"
# Deploy everything to test client
~/.local/bin/ansible-playbook -i hcloud.yml playbooks/deploy.yml --limit test
# Force recreate Zitadel (clean database)
~/.local/bin/ansible-playbook -i hcloud.yml playbooks/deploy.yml --limit test \
--extra-vars "zitadel_force_recreate=true"
```
### Check Service Status
@ -73,7 +68,7 @@ export HCLOUD_TOKEN="..."
# Run ad-hoc commands
~/.local/bin/ansible test -i hcloud.yml -m shell -a "docker ps"
~/.local/bin/ansible test -i hcloud.yml -m shell -a "docker logs zitadel 2>&1 | tail -50"
~/.local/bin/ansible test -i hcloud.yml -m shell -a "docker logs nextcloud 2>&1 | tail -50"
```
### Edit Secrets
@ -92,50 +87,19 @@ sops --decrypt secrets/clients/test.sops.yaml
### Service Stack
- **Traefik**: Reverse proxy with automatic Let's Encrypt certificates
- **Zitadel v2.63.7**: Identity provider (OIDC/OAuth2)
- **PostgreSQL 16**: Database for Zitadel
- **Nextcloud 30.0.17**: File sync and collaboration
- **Redis**: Caching for Nextcloud
- **MariaDB**: Database for Nextcloud
### Docker Networks
- `traefik`: External network for all web-accessible services
- `zitadel-internal`: Internal network for Zitadel ↔ PostgreSQL
- `nextcloud-internal`: Internal network for Nextcloud ↔ Redis
- `nextcloud-internal`: Internal network for Nextcloud ↔ Redis/DB
### Volumes
- `zitadel_zitadel-db-data`: PostgreSQL data
- `zitadel_zitadel-machinekey`: JWT keys for service accounts
- `nextcloud_nextcloud-data`: Nextcloud files and database
## Known Issues
### Zitadel FirstInstance Configuration Bug
**Issue**: ALL `ZITADEL_FIRSTINSTANCE_*` environment variables cause migration errors in v2.63.7:
```
ERROR: duplicate key value violates unique constraint "unique_constraints_pkey"
Errors.Instance.Domain.AlreadyExists
```
**Root Cause**: Bug in Zitadel v2.63.7 FirstInstance migration logic
**Workaround**: Remove all FirstInstance variables; complete initial setup via web UI
**Upstream Issue**: https://github.com/zitadel/zitadel/issues/8791
**Status**: Waiting for upstream fix
### OIDC Automation
**Issue**: Automatic OIDC app provisioning requires manual one-time setup
**Workaround**:
1. Complete Zitadel web UI setup wizard (first access)
2. Create service user with JWT key via web UI
3. Store JWT key in secrets for automated provisioning
**Status**: Manual one-time setup required per Zitadel instance
## Service Credentials
### Zitadel Admin
- **URL**: https://zitadel.test.vrije.cloud
- **Setup**: Complete wizard on first visit (no predefined credentials)
### Nextcloud Admin
- **URL**: https://nextcloud.test.vrije.cloud
- **Username**: admin

View file

@ -1,6 +1,6 @@
# Post-X Society Multi-Tenant Infrastructure
Infrastructure as Code for a scalable multi-tenant VPS platform running Zitadel (identity provider) and Nextcloud (file sync/share) on Hetzner Cloud.
Infrastructure as Code for a scalable multi-tenant VPS platform running Nextcloud (file sync/share) on Hetzner Cloud.
## 🏗️ Architecture
@ -8,7 +8,6 @@ Infrastructure as Code for a scalable multi-tenant VPS platform running Zitadel
- **Configuration**: Ansible with dynamic inventory
- **Secrets**: SOPS + Age encryption
- **Hosting**: Hetzner Cloud (EU-based, GDPR-compliant)
- **Identity**: Zitadel (Swiss company, AGPL 3.0)
- **Storage**: Nextcloud (German company, AGPL 3.0)
## 📁 Repository Structure
@ -87,7 +86,6 @@ This project uses specialized AI agents for development:
- **Architect**: High-level design decisions
- **Infrastructure**: OpenTofu + Ansible implementation
- **Zitadel**: Identity provider configuration
- **Nextcloud**: File sync/share configuration
See individual agent files in `.claude/agents/` for responsibilities.
@ -107,5 +105,4 @@ TBD
For issues or questions, please create a GitHub issue with the appropriate label:
- `agent:architect` - Architecture/design questions
- `agent:infrastructure` - IaC implementation
- `agent:zitadel` - Identity provider
- `agent:nextcloud` - File sync/share

View file

@ -1,6 +1,6 @@
---
# Deploy applications to client servers
# This playbook deploys Zitadel, Nextcloud, and other applications
# This playbook deploys Nextcloud and other applications
- name: Deploy applications to client servers
hosts: all
@ -21,18 +21,12 @@
age_key: "{{ lookup('env', 'SOPS_AGE_KEY_FILE') }}"
no_log: true
- name: Set Zitadel domain from secrets
set_fact:
zitadel_domain: "{{ client_secrets.zitadel_domain }}"
when: client_secrets.zitadel_domain is defined
- name: Set client domain from secrets
set_fact:
client_domain: "{{ client_secrets.client_domain }}"
when: client_secrets.client_domain is defined
roles:
- role: zitadel
- role: nextcloud
post_tasks:
@ -41,12 +35,4 @@
msg: |
Deployment complete for client: {{ client_name }}
Zitadel: https://{{ zitadel_domain }}
Nextcloud: https://nextcloud.{{ client_domain }}
Next steps:
1. Login to Zitadel with the admin credentials
2. Create OIDC application in Zitadel for Nextcloud
- Client name: Nextcloud
- Redirect URI: https://nextcloud.{{ client_domain }}/apps/user_oidc/code
3. Configure OIDC in Nextcloud using the client ID and secret from Zitadel

View file

@ -1,33 +0,0 @@
---
# Zitadel Default Variables
# Zitadel version (pin explicitly)
zitadel_version: "v2.63.7"
# PostgreSQL version for Zitadel database
postgres_version: "16-alpine"
# Admin user (password from secrets)
zitadel_admin_username: "admin"
# Console client ID (Zitadel's built-in admin console)
zitadel_console_client_id: "251896714278772225@ptt"
# OIDC configuration
zitadel_oidc_token_lifetime: "12h"
zitadel_oidc_refresh_lifetime: "720h"
# Resource limits
zitadel_memory_limit: "512M"
zitadel_cpu_limit: "1.0"
# Database configuration
zitadel_db_user: "zitadel"
zitadel_db_name: "zitadel"
# Network configuration
zitadel_network: "zitadel-internal"
zitadel_traefik_network: "traefik"
# Directory for Zitadel configuration
zitadel_config_dir: "/opt/docker/zitadel"

View file

@ -1,171 +0,0 @@
#!/usr/bin/env python3
"""
Automate creation of Zitadel API service user and Personal Access Token.
This script logs in as admin and creates a machine user with a PAT for API automation.
"""
import requests
import sys
import time
import re
from urllib.parse import urlparse, parse_qs
def login_and_get_session(domain, username, password):
"""Login to Zitadel and get authenticated session cookies."""
session = requests.Session()
session.headers.update({
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
})
# Start login flow
login_url = f"https://{domain}/ui/login/loginname"
print(f"📡 Initiating login to {domain}...")
# Get login page to establish session
resp = session.get(login_url, allow_redirects=True)
# Submit username
login_data = {
'loginName': username
}
resp = session.post(login_url, data=login_data, allow_redirects=True)
# Submit password
password_url = f"https://{domain}/ui/login/password"
password_data = {
'password': password
}
resp = session.post(password_url, data=password_data, allow_redirects=True)
if 'set-cookie' in resp.headers or len(session.cookies) > 0:
print("✅ Login successful!")
return session
else:
print(f"❌ Login failed. Status: {resp.status_code}")
print(f"Response: {resp.text[:500]}")
return None
def create_machine_user(session, domain):
"""Create a machine user via Management API."""
api_url = f"https://{domain}/management/v1/users/machine"
print("🤖 Creating API automation service user...")
payload = {
"userName": "api-automation",
"name": "API Automation Service",
"description": "Service account for automated OIDC app provisioning",
"accessTokenType": "ACCESS_TOKEN_TYPE_BEARER"
}
resp = session.post(api_url, json=payload)
if resp.status_code in [200, 201]:
data = resp.json()
user_id = data.get('userId')
print(f"✅ Machine user created: {user_id}")
return user_id
elif resp.status_code == 409:
print(" Machine user already exists")
# Try to get existing user
list_url = f"https://{domain}/management/v1/users/_search"
search_payload = {
"query": {
"userName": "api-automation"
}
}
resp = session.post(list_url, json=search_payload)
if resp.status_code == 200:
users = resp.json().get('result', [])
if users:
user_id = users[0].get('id')
print(f"✅ Found existing user: {user_id}")
return user_id
return None
else:
print(f"❌ Failed to create machine user. Status: {resp.status_code}")
print(f"Response: {resp.text}")
return None
def create_pat(session, domain, user_id):
"""Create a Personal Access Token for the machine user."""
pat_url = f"https://{domain}/management/v1/users/{user_id}/pats"
print("🔑 Creating Personal Access Token...")
payload = {
"expirationDate": "2099-12-31T23:59:59Z"
}
resp = session.post(pat_url, json=payload)
if resp.status_code in [200, 201]:
data = resp.json()
token = data.get('token')
if token:
print("✅ Personal Access Token created successfully!")
return token
else:
print("⚠️ PAT created but token not in response")
print(f"Response: {resp.text}")
return None
else:
print(f"❌ Failed to create PAT. Status: {resp.status_code}")
print(f"Response: {resp.text}")
return None
def main():
if len(sys.argv) != 4:
print("Usage: python3 bootstrap_api_token.py <domain> <admin_username> <admin_password>")
print("Example: python3 bootstrap_api_token.py zitadel.test.vrije.cloud 'admin@test.zitadel.test.vrije.cloud' 'password123'")
sys.exit(1)
domain = sys.argv[1]
username = sys.argv[2]
password = sys.argv[3]
print(f"""
🚀 Zitadel API Token Bootstrap
================================
Domain: {domain}
Admin: {username}
""")
# Step 1: Login
session = login_and_get_session(domain, username, password)
if not session:
print("\n❌ Failed to establish session")
sys.exit(1)
# Small delay to ensure session is established
time.sleep(2)
# Step 2: Create machine user
user_id = create_machine_user(session, domain)
if not user_id:
print("\n❌ Failed to create or find machine user")
sys.exit(1)
# Small delay
time.sleep(1)
# Step 3: Create PAT
token = create_pat(session, domain, user_id)
if not token:
print("\n❌ Failed to create Personal Access Token")
sys.exit(1)
print(f"""
SUCCESS! API automation is ready.
📋 Personal Access Token:
{token}
🔐 Add this to your secrets file:
zitadel_api_token: {token}
Then re-run: ansible-playbook -i hcloud.yml playbooks/deploy.yml
""")
if __name__ == '__main__':
main()

View file

@ -1,58 +0,0 @@
#!/bin/bash
# Bootstrap Zitadel API service user and generate PAT
# This script must be run once per client after initial Zitadel deployment
# It creates a machine user with a Personal Access Token for API automation
set -e
ZITADEL_DOMAIN="$1"
ADMIN_USERNAME="$2"
ADMIN_PASSWORD="$3"
if [ -z "$ZITADEL_DOMAIN" ] || [ -z "$ADMIN_USERNAME" ] || [ -z "$ADMIN_PASSWORD" ]; then
echo "Usage: $0 <zitadel_domain> <admin_username> <admin_password>" >&2
echo "Example: $0 zitadel.test.vrije.cloud 'admin@test.zitadel.test.vrije.cloud' 'password123'" >&2
exit 1
fi
echo "🔧 Bootstrapping Zitadel API automation..."
echo "Domain: $ZITADEL_DOMAIN"
echo "Admin: $ADMIN_USERNAME"
echo ""
# This is a placeholder script that provides instructions for the manual one-time setup
# In a production environment, this would use Puppeteer/Selenium to automate the browser
echo "⚠️ MANUAL SETUP REQUIRED (one time per client)"
echo ""
echo "Please follow these steps in your browser:"
echo ""
echo "1. Open: https://$ZITADEL_DOMAIN/ui/console"
echo "2. Login with:"
echo " Username: $ADMIN_USERNAME"
echo " Password: $ADMIN_PASSWORD"
echo ""
echo "3. Navigate to: Users → Service Users"
echo "4. Click 'New'"
echo "5. Enter:"
echo " Username: api-automation"
echo " Name: API Automation Service"
echo "6. Click 'Create'"
echo ""
echo "7. Click on the new user 'api-automation'"
echo "8. Go to 'Personal Access Tokens' tab"
echo "9. Click 'New'"
echo "10. Set expiration date: 2099-12-31 (or far future)"
echo "11. Click 'Add'"
echo "12. COPY THE TOKEN (it will only be shown once!)"
echo ""
echo "13. Add the token to your secrets file:"
echo " zitadel_api_token: <paste-token-here>"
echo ""
echo "14. Re-run the deployment: ansible-playbook -i hcloud.yml playbooks/deploy.yml"
echo ""
echo "After this one-time setup, all OIDC apps will be created automatically!"
echo ""
# TODO: Implement browser automation using Puppeteer or Selenium
# For now, this provides clear instructions for the manual process

View file

@ -1,129 +0,0 @@
#!/usr/bin/env python3
"""
Create a machine user in Zitadel using admin credentials.
This script creates a service account with a JWT key for API automation.
"""
import json
import sys
import requests
from urllib.parse import urlencode
def get_admin_token(domain, username, password):
"""Get access token using admin username/password."""
token_url = f"https://{domain}/oauth/v2/token"
data = {
"grant_type": "password",
"username": username,
"password": password,
"scope": "openid profile email urn:zitadel:iam:org:project:id:zitadel:aud",
}
response = requests.post(token_url, data=data)
if response.status_code == 200:
return response.json().get("access_token")
else:
raise Exception(f"Failed to get admin token: {response.status_code} - {response.text}")
def create_machine_user(domain, access_token, username, name):
"""Create a machine user."""
url = f"https://{domain}/management/v1/users/machine"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
payload = {
"userName": username,
"name": name,
"description": "Service account for automated API operations",
"accessTokenType": "ACCESS_TOKEN_TYPE_JWT",
}
response = requests.post(url, headers=headers, json=payload)
if response.status_code in [200, 201]:
return response.json().get("userId")
elif response.status_code == 409:
# User already exists, get the user ID
return find_machine_user(domain, access_token, username)
else:
raise Exception(f"Failed to create machine user: {response.status_code} - {response.text}")
def find_machine_user(domain, access_token, username):
"""Find existing machine user by username."""
url = f"https://{domain}/management/v1/users/_search"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
payload = {
"queries": [
{
"userNameQuery": {
"userName": username,
"method": "TEXT_QUERY_METHOD_EQUALS"
}
}
]
}
response = requests.post(url, headers=headers, json=payload)
if response.status_code == 200:
result = response.json().get("result", [])
if result:
return result[0].get("id")
return None
def create_machine_key(domain, access_token, user_id):
"""Create a JWT key for the machine user."""
url = f"https://{domain}/management/v1/users/{user_id}/keys"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
payload = {
"type": "KEY_TYPE_JSON",
"expirationDate": "2030-01-01T00:00:00Z",
}
response = requests.post(url, headers=headers, json=payload)
if response.status_code in [200, 201]:
return response.json()
else:
raise Exception(f"Failed to create machine key: {response.status_code} - {response.text}")
def main():
if len(sys.argv) != 4:
print("Usage: create_machine_user.py <domain> <admin_username> <admin_password>")
sys.exit(1)
domain = sys.argv[1]
admin_username = sys.argv[2]
admin_password = sys.argv[3]
try:
# Get admin access token
print(f"Authenticating as admin...", file=sys.stderr)
access_token = get_admin_token(domain, admin_username, admin_password)
# Create machine user
print(f"Creating machine user 'api-automation'...", file=sys.stderr)
user_id = create_machine_user(domain, access_token, "api-automation", "API Automation Service")
print(f"Machine user ID: {user_id}", file=sys.stderr)
# Create JWT key
print(f"Creating JWT key...", file=sys.stderr)
key_data = create_machine_key(domain, access_token, user_id)
# Output the key as JSON
print(json.dumps(key_data, indent=2))
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -1,169 +0,0 @@
#!/usr/bin/env python3
"""
Create OIDC application in Zitadel using the Management API.
This script automates the creation of OIDC applications for services like Nextcloud.
It uses Zitadel's REST API with service account authentication.
"""
import json
import sys
import requests
from typing import Dict, Optional
class ZitadelOIDCManager:
"""Manage OIDC applications in Zitadel."""
def __init__(self, domain: str, pat_token: str):
"""Initialize the OIDC manager."""
self.domain = domain
self.base_url = f"https://{domain}"
self.headers = {
"Authorization": f"Bearer {pat_token}",
"Content-Type": "application/json",
}
def create_project(self, project_name: str) -> Optional[str]:
"""Create a project."""
url = f"{self.base_url}/management/v1/projects"
payload = {
"name": project_name
}
response = requests.post(url, headers=self.headers, json=payload)
if response.status_code in [200, 201]:
return response.json().get("id")
return None
def get_or_create_project(self, project_name: str = "SSO Applications") -> Optional[str]:
"""Get existing project or create new one."""
# Try to list projects
url = f"{self.base_url}/management/v1/projects/_search"
response = requests.post(url, headers=self.headers, json={})
if response.status_code == 200:
projects = response.json().get("result", [])
for project in projects:
if project.get("name") == project_name:
return project["id"]
# If no matching project, use first one if exists
if projects:
return projects[0]["id"]
# No project found, try to create one
project_id = self.create_project(project_name)
return project_id
def check_app_exists(self, project_id: str, app_name: str) -> Optional[Dict]:
"""Check if an OIDC app already exists."""
url = f"{self.base_url}/management/v1/projects/{project_id}/apps/_search"
response = requests.post(url, headers=self.headers, json={})
if response.status_code == 200:
apps = response.json().get("result", [])
for app in apps:
if app.get("name") == app_name:
return app
return None
def create_oidc_app(
self,
project_id: str,
app_name: str,
redirect_uris: list,
post_logout_redirect_uris: list = None,
) -> Dict:
"""Create an OIDC application."""
url = f"{self.base_url}/management/v1/projects/{project_id}/apps/oidc"
payload = {
"name": app_name,
"redirectUris": redirect_uris,
"responseTypes": ["OIDC_RESPONSE_TYPE_CODE"],
"grantTypes": [
"OIDC_GRANT_TYPE_AUTHORIZATION_CODE",
"OIDC_GRANT_TYPE_REFRESH_TOKEN",
],
"appType": "OIDC_APP_TYPE_WEB",
"authMethodType": "OIDC_AUTH_METHOD_TYPE_BASIC",
"postLogoutRedirectUris": post_logout_redirect_uris or [],
"version": "OIDC_VERSION_1_0",
"devMode": False,
"accessTokenType": "OIDC_TOKEN_TYPE_BEARER",
"accessTokenRoleAssertion": True,
"idTokenRoleAssertion": True,
"idTokenUserinfoAssertion": True,
"clockSkew": "0s",
}
response = requests.post(url, headers=self.headers, json=payload)
if response.status_code in [200, 201]:
return response.json()
else:
raise Exception(
f"Failed to create OIDC app: {response.status_code} - {response.text}"
)
def main():
"""Main entry point."""
if len(sys.argv) < 5:
print("Usage: create_oidc_app.py <domain> <pat_token> <app_name> <redirect_uri>")
sys.exit(1)
domain = sys.argv[1]
pat_token = sys.argv[2]
app_name = sys.argv[3]
redirect_uri = sys.argv[4]
try:
manager = ZitadelOIDCManager(domain, pat_token)
# Get or create project
project_id = manager.get_or_create_project("SSO Applications")
if not project_id:
print("Error: Failed to get or create project", file=sys.stderr)
sys.exit(1)
# Check if app already exists
existing_app = manager.check_app_exists(project_id, app_name)
if existing_app:
print(
json.dumps(
{
"status": "exists",
"app_id": existing_app.get("id"),
"message": f"App '{app_name}' already exists",
}
)
)
sys.exit(0)
# Create new app
result = manager.create_oidc_app(
project_id=project_id,
app_name=app_name,
redirect_uris=[redirect_uri],
post_logout_redirect_uris=[redirect_uri.rsplit("/", 1)[0] + "/"],
)
# Extract client credentials
output = {
"status": "created",
"app_id": result.get("appId"),
"client_id": result.get("clientId"),
"client_secret": result.get("clientSecret"),
"redirect_uri": redirect_uri,
}
print(json.dumps(output))
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -1,34 +0,0 @@
#!/bin/bash
# Get admin access token from Zitadel using username/password authentication
# This is used for initial OIDC app provisioning automation
set -e
DOMAIN="$1"
USERNAME="$2"
PASSWORD="$3"
if [ -z "$DOMAIN" ] || [ -z "$USERNAME" ] || [ -z "$PASSWORD" ]; then
echo "Usage: $0 <domain> <username> <password>" >&2
exit 1
fi
# Get OAuth token using Resource Owner Password Credentials flow
# Note: This is only for admin automation, not recommended for production apps
RESPONSE=$(curl -s -X POST "https://${DOMAIN}/oauth/v2/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password" \
-d "scope=openid profile email urn:zitadel:iam:org:project:id:zitadel:aud" \
-d "username=${USERNAME}" \
-d "password=${PASSWORD}")
# Extract access token
ACCESS_TOKEN=$(echo "$RESPONSE" | python3 -c "import sys, json; print(json.load(sys.stdin).get('access_token', ''))")
if [ -z "$ACCESS_TOKEN" ]; then
echo "Error: Failed to get access token" >&2
echo "$RESPONSE" >&2
exit 1
fi
echo "$ACCESS_TOKEN"

View file

@ -1,166 +0,0 @@
#!/usr/bin/env python3
"""
One-time setup script to configure Zitadel for full OIDC automation.
This script uses the manually created PAT to set up everything needed.
"""
import json
import sys
import requests
from typing import Optional
class ZitadelSetup:
"""Setup Zitadel for OIDC automation."""
def __init__(self, domain: str, pat_token: str):
self.domain = domain
self.base_url = f"https://{domain}"
self.headers = {
"Authorization": f"Bearer {pat_token}",
"Content-Type": "application/json",
}
def create_project(self, name: str) -> Optional[str]:
"""Create a project for OIDC applications."""
url = f"{self.base_url}/management/v1/projects"
payload = {"name": name}
print(f"📦 Creating project '{name}'...")
response = requests.post(url, headers=self.headers, json=payload)
if response.status_code in [200, 201]:
project_id = response.json().get("id")
print(f"✅ Project created: {project_id}")
return project_id
elif response.status_code == 409:
print(f" Project already exists, searching...")
return self.find_project(name)
else:
print(f"❌ Failed to create project: {response.status_code}")
print(f"Response: {response.text}")
return None
def find_project(self, name: str) -> Optional[str]:
"""Find existing project by name."""
url = f"{self.base_url}/management/v1/projects/_search"
response = requests.post(url, headers=self.headers, json={})
if response.status_code == 200:
projects = response.json().get("result", [])
for project in projects:
if project.get("name") == name:
print(f"✅ Found existing project: {project['id']}")
return project["id"]
return None
def get_service_user_id(self, username: str) -> Optional[str]:
"""Find the service user ID."""
url = f"{self.base_url}/management/v1/users/_search"
payload = {
"query": {
"userName": username
}
}
print(f"🔍 Looking for service user '{username}'...")
response = requests.post(url, headers=self.headers, json=payload)
if response.status_code == 200:
users = response.json().get("result", [])
if users:
user_id = users[0].get("id")
print(f"✅ Found service user: {user_id}")
return user_id
print(f"❌ Service user not found")
return None
def grant_project_permission(self, project_id: str, user_id: str) -> bool:
"""Grant project ownership to service user."""
url = f"{self.base_url}/management/v1/projects/{project_id}/roles/_bulk/set"
# Grant PROJECT_OWNER role
payload = {
"grants": [
{
"userId": user_id,
"roleKeys": ["PROJECT_OWNER"]
}
]
}
print(f"🔐 Granting PROJECT_OWNER permission...")
response = requests.post(url, headers=self.headers, json=payload)
if response.status_code in [200, 201]:
print(f"✅ Permission granted")
return True
else:
print(f"⚠️ Permission grant: {response.status_code}")
print(f"Response: {response.text}")
# This might fail if already granted, which is OK
return True
def main():
if len(sys.argv) < 3:
print("Usage: setup_automation.py <domain> <pat_token>")
sys.exit(1)
domain = sys.argv[1]
pat_token = sys.argv[2]
print(f"""
🚀 Zitadel OIDC Automation Setup
=================================
Domain: {domain}
This script will:
1. Create 'SSO Applications' project
2. Grant api-automation user PROJECT_OWNER permission
3. Enable full OIDC automation
""")
try:
setup = ZitadelSetup(domain, pat_token)
# Step 1: Create or find project
project_id = setup.create_project("SSO Applications")
if not project_id:
print("\n❌ Failed to create/find project")
sys.exit(1)
# Step 2: Find service user
user_id = setup.get_service_user_id("api-automation")
if not user_id:
print("\n❌ Service user 'api-automation' not found")
print("Please create the machine user first via Zitadel console")
sys.exit(1)
# Step 3: Grant permissions
if not setup.grant_project_permission(project_id, user_id):
print("\n⚠️ Warning: Could not grant permissions (may already exist)")
print(f"""
SUCCESS! OIDC automation is now fully configured.
Next steps:
- Run deployment: ansible-playbook -i hcloud.yml playbooks/deploy.yml
- All OIDC apps will be created automatically
- No more manual steps required!
Project ID: {project_id}
Service User: api-automation ({user_id})
""")
except Exception as e:
print(f"\n❌ Error: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -1,182 +0,0 @@
#!/usr/bin/env python3
"""
Zitadel API client using JWT authentication.
Fully automated OIDC app provisioning.
"""
import json
import sys
import time
import requests
import jwt
from typing import Dict, Optional
class ZitadelAPI:
"""Zitadel API client with JWT authentication."""
def __init__(self, domain: str, jwt_key_path: str):
"""Initialize with JWT key file."""
self.domain = domain
self.base_url = f"https://{domain}"
# Load JWT key
with open(jwt_key_path, 'r') as f:
self.jwt_key = json.load(f)
self.user_id = self.jwt_key.get("userId")
self.key_id = self.jwt_key.get("keyId")
self.private_key = self.jwt_key.get("key")
def get_access_token(self) -> str:
"""Get access token using JWT assertion."""
# Create JWT assertion
now = int(time.time())
payload = {
"iss": self.user_id,
"sub": self.user_id,
"aud": self.domain,
"iat": now,
"exp": now + 3600,
}
assertion = jwt.encode(
payload,
self.private_key,
algorithm="RS256",
headers={"kid": self.key_id}
)
# Exchange JWT for access token
token_url = f"{self.base_url}/oauth/v2/token"
data = {
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": assertion,
"scope": "openid profile email urn:zitadel:iam:org:project:id:zitadel:aud",
}
response = requests.post(token_url, data=data)
if response.status_code == 200:
return response.json().get("access_token")
else:
raise Exception(f"Failed to get access token: {response.status_code} - {response.text}")
def create_project(self, access_token: str, name: str) -> Optional[str]:
"""Create a project."""
url = f"{self.base_url}/management/v1/projects"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
payload = {"name": name}
response = requests.post(url, headers=headers, json=payload)
if response.status_code in [200, 201]:
return response.json().get("id")
elif response.status_code == 409:
# Already exists, find it
return self.find_project(access_token, name)
else:
raise Exception(f"Failed to create project: {response.status_code} - {response.text}")
def find_project(self, access_token: str, name: str) -> Optional[str]:
"""Find existing project by name."""
url = f"{self.base_url}/management/v1/projects/_search"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
response = requests.post(url, headers=headers, json={})
if response.status_code == 200:
projects = response.json().get("result", [])
for project in projects:
if project.get("name") == name:
return project["id"]
return None
def create_oidc_app(
self,
access_token: str,
project_id: str,
app_name: str,
redirect_uri: str,
) -> Dict:
"""Create OIDC application."""
url = f"{self.base_url}/management/v1/projects/{project_id}/apps/oidc"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
payload = {
"name": app_name,
"redirectUris": [redirect_uri],
"responseTypes": ["OIDC_RESPONSE_TYPE_CODE"],
"grantTypes": [
"OIDC_GRANT_TYPE_AUTHORIZATION_CODE",
"OIDC_GRANT_TYPE_REFRESH_TOKEN",
],
"appType": "OIDC_APP_TYPE_WEB",
"authMethodType": "OIDC_AUTH_METHOD_TYPE_BASIC",
"postLogoutRedirectUris": [redirect_uri.rsplit("/", 1)[0] + "/"],
"version": "OIDC_VERSION_1_0",
"devMode": False,
"accessTokenType": "OIDC_TOKEN_TYPE_BEARER",
"accessTokenRoleAssertion": True,
"idTokenRoleAssertion": True,
"idTokenUserinfoAssertion": True,
"clockSkew": "0s",
}
response = requests.post(url, headers=headers, json=payload)
if response.status_code in [200, 201]:
return response.json()
else:
raise Exception(f"Failed to create OIDC app: {response.status_code} - {response.text}")
def main():
"""Main entry point."""
if len(sys.argv) < 5:
print("Usage: zitadel_api.py <domain> <jwt_key_path> <app_name> <redirect_uri>")
sys.exit(1)
domain = sys.argv[1]
jwt_key_path = sys.argv[2]
app_name = sys.argv[3]
redirect_uri = sys.argv[4]
try:
api = ZitadelAPI(domain, jwt_key_path)
# Get access token
access_token = api.get_access_token()
# Get or create project
project_id = api.create_project(access_token, "SSO Applications")
# Create OIDC app
result = api.create_oidc_app(access_token, project_id, app_name, redirect_uri)
# Output credentials
output = {
"status": "created",
"app_id": result.get("appId"),
"client_id": result.get("clientId"),
"client_secret": result.get("clientSecret"),
"redirect_uri": redirect_uri,
}
print(json.dumps(output))
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -1,9 +0,0 @@
---
# Handlers for Zitadel role
- name: Restart Zitadel
community.docker.docker_compose_v2:
project_src: "{{ zitadel_config_dir }}"
services:
- zitadel
state: restarted

View file

@ -1,55 +0,0 @@
---
# Bootstrap tasks for initial Zitadel configuration
- name: Check if bootstrap already completed
stat:
path: "{{ zitadel_config_dir }}/.bootstrap_complete"
register: bootstrap_flag
- name: Bootstrap Zitadel instance
when: not bootstrap_flag.stat.exists
block:
- name: Wait for Zitadel to be fully ready
uri:
url: "https://{{ zitadel_domain }}/debug/ready"
validate_certs: yes
status_code: 200
register: zitadel_ready
until: zitadel_ready.status == 200
retries: 30
delay: 10
- name: Display bootstrap instructions
debug:
msg: |
========================================
Zitadel is running!
========================================
URL: https://{{ zitadel_domain }}
⚠️ FIRST-TIME SETUP REQUIRED
Due to migration bugs in Zitadel v2.63.7, FirstInstance environment
variables cannot be used. You must complete initial setup via web UI.
Visit: https://{{ zitadel_domain }}
Next steps:
1. Complete web UI setup wizard (create admin account)
2. Create a service user for API automation
3. Generate JWT key for the service user
4. Store JWT key for automated OIDC app provisioning
See: https://github.com/zitadel/zitadel/issues/8791
- name: Mark bootstrap as complete
file:
path: "{{ zitadel_config_dir }}/.bootstrap_complete"
state: touch
mode: '0600'
- name: Bootstrap already completed
debug:
msg: "Zitadel bootstrap already completed, skipping initialization"
when: bootstrap_flag.stat.exists

View file

@ -1,17 +0,0 @@
---
# Clean Zitadel database and volumes
- name: Stop Zitadel containers with volumes
shell: |
cd {{ zitadel_config_dir }} && docker compose down -v
ignore_errors: yes
changed_when: false
- name: Remove bootstrap marker
file:
path: "{{ zitadel_config_dir }}/.bootstrap_complete"
state: absent
- name: Wait for cleanup
pause:
seconds: 5

View file

@ -1,53 +0,0 @@
---
# Docker Compose setup for Zitadel
- name: Include clean tasks when force recreate is requested
include_tasks: clean.yml
when: zitadel_force_recreate | default(false) | bool
- name: Create Zitadel configuration directory
file:
path: "{{ zitadel_config_dir }}"
state: directory
mode: '0755'
- name: Create Zitadel internal network
community.docker.docker_network:
name: "{{ zitadel_network }}"
driver: bridge
internal: true
- name: Deploy Zitadel Docker Compose configuration
template:
src: docker-compose.zitadel.yml.j2
dest: "{{ zitadel_config_dir }}/docker-compose.yml"
mode: '0600'
notify: Restart Zitadel
- name: Start Zitadel services
community.docker.docker_compose_v2:
project_src: "{{ zitadel_config_dir }}"
state: present
register: zitadel_deploy
- name: Wait for Zitadel database to be ready
community.docker.docker_container_exec:
container: zitadel-db
command: pg_isready -U {{ zitadel_db_user }} -d {{ zitadel_db_name }}
register: db_ready
until: db_ready.rc == 0
retries: 30
delay: 2
changed_when: false
- name: Wait for Zitadel to be healthy
uri:
url: "https://{{ zitadel_domain }}/debug/ready"
method: GET
status_code: 200
validate_certs: yes
register: zitadel_health
until: zitadel_health.status == 200
retries: 30
delay: 10
changed_when: false

View file

@ -1,13 +0,0 @@
---
# Main tasks file for Zitadel role
- name: Include Docker Compose setup
include_tasks: docker.yml
- name: Include bootstrap setup
include_tasks: bootstrap.yml
when: zitadel_bootstrap | default(true)
- name: Include OIDC applications setup
include_tasks: oidc-apps.yml
when: zitadel_create_oidc_apps | default(true)

View file

@ -1,107 +0,0 @@
---
# OIDC Application creation tasks via Zitadel API
# Fully automated OIDC app provisioning for Nextcloud and other services
- name: Create Zitadel scripts directory
file:
path: /opt/zitadel
state: directory
mode: '0755'
- name: Copy OIDC automation scripts to server
copy:
src: "{{ item }}"
dest: "/opt/zitadel/{{ item }}"
mode: '0755'
loop:
- zitadel_api.py
- name: Install Python libraries for OIDC automation
package:
name:
- python3-requests
- python3-jwt
state: present
become: yes
- name: Check if JWT key file exists
shell: docker exec zitadel ls /machinekey/api-automation.json
register: jwt_key_check
failed_when: false
changed_when: false
- name: Set JWT authentication available
set_fact:
jwt_auth_available: "{{ jwt_key_check.rc == 0 }}"
- name: Copy JWT key from container to host
shell: docker cp zitadel:/machinekey/api-automation.json /tmp/api-automation.json
when: jwt_auth_available
changed_when: false
- name: Create OIDC application for Nextcloud using JWT auth
shell: |
python3 /opt/zitadel/zitadel_api.py \
"{{ zitadel_domain }}" \
"/tmp/api-automation.json" \
"Nextcloud" \
"https://nextcloud.{{ client_domain }}/apps/user_oidc/code"
register: oidc_app_result
when: jwt_auth_available
changed_when: "'created' in oidc_app_result.stdout"
failed_when: oidc_app_result.rc != 0
- name: Clean up temporary JWT key file
file:
path: /tmp/api-automation.json
state: absent
when: jwt_auth_available
- name: Parse OIDC app creation result
set_fact:
oidc_app_data: "{{ oidc_app_result.stdout | from_json }}"
when: jwt_auth_available and oidc_app_result is defined
- name: Display OIDC app status
debug:
msg: |
✅ Nextcloud OIDC Application: {{ oidc_app_data.status }}
Client ID: {{ oidc_app_data.client_id | default('N/A') }}
Redirect URI: {{ oidc_app_data.redirect_uri | default('N/A') }}
when: jwt_auth_available and oidc_app_data is defined
- name: Save OIDC credentials for Nextcloud configuration
set_fact:
nextcloud_oidc_client_id: "{{ oidc_app_data.client_id }}"
nextcloud_oidc_client_secret: "{{ oidc_app_data.client_secret }}"
when: jwt_auth_available and oidc_app_data is defined and oidc_app_data.status == 'created'
no_log: true
- name: Configure OIDC provider in Nextcloud
shell: |
docker exec -u www-data nextcloud php occ user_oidc:provider:add \
--clientid="{{ nextcloud_oidc_client_id }}" \
--clientsecret="{{ nextcloud_oidc_client_secret }}" \
--discoveryuri="https://{{ zitadel_domain }}/.well-known/openid-configuration" \
"Zitadel" || true
when: jwt_auth_available and nextcloud_oidc_client_id is defined and nextcloud_oidc_client_secret is defined
register: oidc_config_result
changed_when: "'Provider Zitadel has been created' in oidc_config_result.stdout"
- name: Display OIDC configuration result
debug:
msg: |
✅ Nextcloud OIDC Provider Configuration: {{ 'Success' if oidc_config_result.changed else 'Already configured' }}
Users can now login to Nextcloud using Zitadel SSO!
Visit: https://nextcloud.{{ client_domain }}
when: jwt_auth_available and oidc_config_result is defined
- name: OIDC automation not available
debug:
msg: |
⚠️ OIDC automation not available - JWT key not found.
This should not happen if FirstInstance completed successfully.
Check Zitadel logs: docker logs zitadel
when: not jwt_auth_available

View file

@ -1,101 +0,0 @@
services:
zitadel:
image: ghcr.io/zitadel/zitadel:{{ zitadel_version }}
container_name: zitadel
restart: unless-stopped
command: start-from-init --masterkeyFromEnv --tlsMode external
environment:
# Masterkey for encryption
ZITADEL_MASTERKEY: "{{ client_secrets.zitadel_masterkey }}"
# Database configuration
ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db
ZITADEL_DATABASE_POSTGRES_PORT: 5432
ZITADEL_DATABASE_POSTGRES_DATABASE: "{{ zitadel_db_name }}"
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: "{{ zitadel_db_user }}"
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: "{{ client_secrets.zitadel_db_password }}"
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: "{{ zitadel_db_user }}"
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: "{{ client_secrets.zitadel_db_password }}"
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
# External domain configuration
ZITADEL_EXTERNALSECURE: "true"
ZITADEL_EXTERNALDOMAIN: "{{ zitadel_domain }}"
ZITADEL_EXTERNALPORT: 443
# FirstInstance variables removed due to migration bugs in v2.63.7
# See: https://github.com/zitadel/zitadel/issues/8791
# Initial setup will be done via web UI on first access
volumes:
- zitadel-machinekey:/machinekey
networks:
- {{ zitadel_traefik_network }}
- {{ zitadel_network }}
depends_on:
zitadel-db:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.http.routers.zitadel.rule=Host(`{{ zitadel_domain }}`)"
- "traefik.http.routers.zitadel.tls=true"
- "traefik.http.routers.zitadel.tls.certresolver=letsencrypt"
- "traefik.http.routers.zitadel.entrypoints=websecure"
- "traefik.http.services.zitadel.loadbalancer.server.port=8080"
# gRPC support for API
- "traefik.http.services.zitadel.loadbalancer.server.scheme=h2c"
# Middleware for security headers
- "traefik.http.routers.zitadel.middlewares=zitadel-headers"
- "traefik.http.middlewares.zitadel-headers.headers.stsSeconds=31536000"
- "traefik.http.middlewares.zitadel-headers.headers.stsIncludeSubdomains=true"
- "traefik.http.middlewares.zitadel-headers.headers.stsPreload=true"
deploy:
resources:
limits:
memory: {{ zitadel_memory_limit }}
cpus: "{{ zitadel_cpu_limit }}"
zitadel-db:
image: postgres:{{ postgres_version }}
container_name: zitadel-db
restart: unless-stopped
environment:
POSTGRES_USER: "{{ zitadel_db_user }}"
POSTGRES_PASSWORD: "{{ client_secrets.zitadel_db_password }}"
POSTGRES_DB: "{{ zitadel_db_name }}"
volumes:
- zitadel-db-data:/var/lib/postgresql/data
networks:
- {{ zitadel_network }}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U {{ zitadel_db_user }} -d {{ zitadel_db_name }}"]
interval: 5s
timeout: 5s
retries: 5
deploy:
resources:
limits:
memory: 256M
cpus: "0.5"
volumes:
zitadel-db-data:
driver: local
zitadel-machinekey:
driver: local
networks:
{{ zitadel_traefik_network }}:
external: true
{{ zitadel_network }}:
driver: bridge
internal: true

View file

@ -80,10 +80,9 @@ ansible/
├── roles/
│ ├── common/ # Base setup, hardening, Docker
│ ├── traefik/ # Reverse proxy, SSL
│ ├── zitadel/ # Identity provider (Swiss, AGPL 3.0)
│ ├── nextcloud/
│ ├── pretix/ # Future
│ ├── listmonk/ # Future
│ ├── nextcloud/ # File sync and collaboration
│ ├── pretix/ # Future: Event ticketing
│ ├── listmonk/ # Future: Newsletter/mailing
│ ├── backup/ # Restic configuration
│ └── monitoring/ # Node exporter, promtail
```
@ -98,13 +97,11 @@ all:
client_name: alpha
domain: alpha.platform.nl
apps:
- zitadel
- nextcloud
client-beta:
client_name: beta
domain: beta.platform.nl
apps:
- zitadel
- nextcloud
- pretix
```
@ -152,51 +149,19 @@ resource "hetznerdns_record" "client_a" {
## 4. Identity Provider
### Decision: Zitadel (replacing Keycloak)
### Decision: Removed (previously Zitadel)
**Choice:** Zitadel as the identity provider for all client installations.
**Status:** Identity provider removed from architecture.
**Why Zitadel over Keycloak:**
**Reason for Removal:**
- Zitadel v2.63.7 has critical bugs with FirstInstance initialization
- ALL `ZITADEL_FIRSTINSTANCE_*` environment variables cause database migration errors
- Requires manual web UI setup for each instance (not scalable for multi-tenant deployment)
- See: https://github.com/zitadel/zitadel/issues/8791
| Factor | Zitadel | Keycloak |
|--------|---------|----------|
| Company HQ | 🇨🇭 Switzerland | 🇺🇸 USA (IBM/Red Hat) |
| GDPR Jurisdiction | EU-adequate | US jurisdiction |
| License | AGPL 3.0 | Apache 2.0 |
| Multi-tenancy | Native design | Added later (2024) |
| Language | Go (lightweight) | Java (resource-heavy) |
| Architecture | Event-sourced, API-first | Traditional |
**Licensing Notes:**
- Zitadel v3 (March 2025) changed from Apache 2.0 to AGPL 3.0
- For our use case (running Zitadel as IdP), this has zero impact
- AGPL only requires source disclosure if you modify Zitadel AND provide it as a service
- SDKs and APIs remain Apache 2.0
**Company Background:**
- CAOS Ltd., headquartered in St. Gallen, Switzerland
- Founded 2019, $15.5M funding (Series A)
- Switzerland has EU data protection adequacy status
- Public product roadmap, transparent development
**Deployment:**
```yaml
# docker-compose.yml snippet
services:
zitadel:
image: ghcr.io/zitadel/zitadel:v3.x.x # Pin version
command: start-from-init
environment:
ZITADEL_DATABASE_POSTGRES_HOST: postgres
ZITADEL_EXTERNALDOMAIN: ${CLIENT_DOMAIN}
depends_on:
- postgres
```
**Multi-tenancy Approach:**
- Each client gets isolated Zitadel organization
- Single Zitadel instance can manage multiple organizations
- Or: fully isolated Zitadel per client (current choice for maximum isolation)
**Future Consideration:**
- May revisit with Authentik or other identity providers when needed
- Currently focusing on Nextcloud as standalone solution
---
@ -266,8 +231,6 @@ Storage Box (BX10 or larger)
/opt/docker/
├── nextcloud/
│ └── data/ # ✓ User files
├── zitadel/
│ └── db-dumps/ # ✓ PostgreSQL dumps (not live DB)
├── pretix/
│ └── data/ # ✓ When applicable
└── configs/ # ✓ docker-compose files, env
@ -414,7 +377,6 @@ SOPS_AGE_KEY_FILE=keys/age-key.txt sops --decrypt secrets/clients/alpha.sops.yam
**Monitors per Client:**
- HTTPS endpoint (Nextcloud)
- HTTPS endpoint (Zitadel)
- TCP port checks (database, if exposed)
- Docker container health (via API or agent)
@ -628,7 +590,6 @@ Unattended-Upgrade::Automatic-Reboot "false"; # Manual reboot control
### Verification
- [ ] HTTPS accessible
- [ ] Zitadel admin login works
- [ ] Nextcloud admin login works
- [ ] Backup job runs successfully
- [ ] Monitoring checks green
@ -658,8 +619,6 @@ Unattended-Upgrade::Automatic-Reboot "false"; # Manual reboot control
### Data Export (if requested)
- [ ] Export Nextcloud data
- [ ] Export Zitadel organization/users
- [ ] Provide secure download link
- [ ] Confirm receipt
### Infrastructure Removal
@ -732,7 +691,6 @@ infrastructure/
│ │ ├── common/
│ │ ├── docker/
│ │ ├── traefik/
│ │ ├── zitadel/
│ │ ├── nextcloud/
│ │ ├── backup/
│ │ └── monitoring-agent/
@ -763,8 +721,7 @@ infrastructure/
## 13. Open Decisions / Future Considerations
### To Decide Later
- [ ] Shared Zitadel instance vs isolated instances per client
- [ ] Central logging (Loki) - when/if needed
- [ ] Identity provider (Authentik or other) - if SSO needed
- [ ] Prometheus metrics - when/if needed
- [ ] Custom domain SSL workflow
- [ ] Client self-service portal
@ -785,8 +742,7 @@ infrastructure/
|------|--------|---------|--------|
| IaC | OpenTofu | Terraform | BSL license concerns, HashiCorp trust issues |
| Secrets | SOPS + Age | HashiCorp Vault | Simplicity, no US vendor dependency, truly open source |
| Identity | Zitadel | Keycloak | Swiss company, GDPR-adequate jurisdiction, native multi-tenancy |
| DNS | Hetzner DNS | Cloudflare | EU-based, GDPR-native, single provider |
| Identity | (Removed) | Keycloak/Zitadel | Removed due to complexity; may add Authentik in future |
| Hosting | Hetzner | AWS/GCP/Azure | EU-based, cost-effective, GDPR-compliant |
| Backup | Restic + Hetzner Storage Box | Cloud backup services | Open source, EU data residency |
@ -848,5 +804,5 @@ pipx inject ansible requests python-dateutil
| 2024-12 | Switched from Terraform to OpenTofu (licensing concerns) | Pieter / Claude |
| 2024-12 | Switched from HashiCorp Vault to SOPS + Age (simplicity, open source) | Pieter / Claude |
| 2024-12 | Switched from Keycloak to Zitadel (Swiss company, GDPR jurisdiction) | Pieter / Claude |
| 2025-12 | Adopted pipx for isolated Python tool environments (Ansible) | Pieter / Claude |
| 2026-01 | Removed Zitadel due to FirstInstance bugs; may add Authentik in future | Pieter / Claude |
```

View file

@ -1,37 +0,0 @@
#ENC[AES256_GCM,data:o6Gv7TOJ+8OJu6tM51EknhHBND/1k7pMLQ==,iv:uTX4bvBSArGi6W+JM+wZ269OoS/54GqyMlkUKmYZuiw=,tag:xgOASRfSQY1qsh34qLFERQ==,type:comment]
#ENC[AES256_GCM,data:kkQhR4LpsiUoK9i76ZmKJ3b1rw==,iv:hMZHHZ7RtC6MYFbF24Oo4CN1yaJ1VFECuUL494TFb28=,tag:GYtuciL1d/GWBF9XlVHOFQ==,type:comment]
client_name: ENC[AES256_GCM,data:8+Lr9w==,iv:2wOOQESZWtoEhPNCstNe9V3FMLFCN0cmo5s4HNbe4JI=,tag:jp66n3TBkNvQ4I2OKjvEVg==,type:str]
client_domain: ENC[AES256_GCM,data:PErC6A2eggbDe3kl+SPw3g==,iv:95IhEuxEdseJxxOAXc+enFx3hKIkoUjqHt1jTERSu9k=,tag:vg188upDCa093P2DZRiMiw==,type:str]
#ENC[AES256_GCM,data:7sMxHHHosm9HaqSAaj9c,iv:H+8WcGSj6wZ5TRbVz/w5zKKiLsUMl12XBiekNXtkwf8=,tag:LnvSnDhZZiRTU2VGmv6DuA==,type:comment]
zitadel_domain: ENC[AES256_GCM,data:BGDfLONSUv1pE9MGTD2FqAdnMYv/nHo3,iv:gHetSoS72+gvTyU40HokR7M3NzOF2jXjSM3AJzOJWxk=,tag:+tO7CI3YA6OiTRZDVEo6SQ==,type:str]
#ENC[AES256_GCM,data:RE98atj6ceVeVyCq8F0QDQ==,iv:ZLWn96U3dZTOfaOXMi2cmznOtwQNvVyWxpY8Wrn8cTs=,tag:raQM4w76FYcxihgXR48kXw==,type:comment]
zitadel_db_password: ENC[AES256_GCM,data:ahTJ6nqi8Z6tH4xfdqOf1G414k+Cghqb9jtpeWDnWDc=,iv:W7Q1oGXPuL4nZKHjtP7whZbIypsiWbla4iJLWTVej+s=,tag:vGdAwTZDs+Dx1VOwpqPI3A==,type:str]
zitadel_admin_password: ENC[AES256_GCM,data:aCwqd0zxYx3U7KXMZXlb+aRJg/o4tw==,iv:7NJ8vDvbPaKUVzdA0nAD/aXXKRrjiAh/Pgs8f658Dug=,tag:Shv3q3un0B67RojdwJpw2g==,type:str]
zitadel_masterkey: ENC[AES256_GCM,data:ZejgZehycR7xXaR3YakV7T3JcaXLH6WgdFZXyADaEaM=,iv:ox9ucUjosXL+v8+fhJ01v/gKUero1AHbEQltFbTdGPY=,tag:v/aAuWI1uWMkTKesNFMhJw==,type:str]
#ENC[AES256_GCM,data:uCeazGQMt+XhXfvYWxXc26rH,iv:It663dHXpWK2geH4W+sCnxb3uCmebtvLAq6FZXZ7b88=,tag:tUf4ACtrV01rxK9fX8IzCQ==,type:comment]
nextcloud_db_password: ENC[AES256_GCM,data:KFENTKR2nVMVW+q7PxKGMCCeaWCje8RvIDz9uQVCZNY=,iv:5SBGVIk3ss141uHBVM8ri4R0oPF5WG7Zr+n1If6LTBo=,tag:wcfWbdznvStQzAqaD8ATiQ==,type:str]
nextcloud_admin_password: ENC[AES256_GCM,data:hY/jDhFv/ARQBLP3BMeoXWz/,iv:GQUJ30p53tJx8Ad4WmuYyO094oLmFo2fscyWMFJTI3Q=,tag:JrmBWIs1aGYA+KrJ/NEU7A==,type:str]
#ENC[AES256_GCM,data:Z6n14z9PYeD6ANSBAmwEC/2dd1jcng/CKA==,iv:DjKRNBCKdbigL5BVS8T+n5ErpdCnWl5vife2Dk82kKQ=,tag:tD5y2LGO5uX2FzLg3e2uwQ==,type:comment]
collabora_admin_password: ENC[AES256_GCM,data:3HxOGhRtHdJmpjZCmNFRDUjt+hImQl490ayVM1ZljOM=,iv:Lo39ivBvdK0PMtQESgs2osxll/N4UaKceBEGDKsvU5M=,tag:46ta6FPyM+nN8yOzUTDikQ==,type:str]
#ENC[AES256_GCM,data:Z35TDtwuZFN5K5GuSOR0Lw2AUkD+id2WVafDU9x96fhnXg==,iv:MhJgiCxKc3p1VEPd1m1nNTvYukY7vg9LYXCdLqJd/QA=,tag:BNbFdq4XljgcXBszZxaErA==,type:comment]
restic_repo_password: ENC[AES256_GCM,data:ae3UuZ/+bkk7FqtINNpzyEUw9SNEuA4Zx57SNsBtbDs=,iv:qEZzg8SBgdEfIO3B/Z3OgqBd4/hc/Pc4JW/MbGYSUT4=,tag:0SsSbfdnqXnGpmwJcFLGkg==,type:str]
#ENC[AES256_GCM,data:fz/o+KoG5CP4oWUhi1nv8thE0bxn7ElmYeQgavPle/ST0twxvM0qoz7t61SW5J3XISMdKFvPAqnU9EDZMsQ/Y/y5JdY=,iv:HP//iD4WG4LuMUaLQ01a6N15UD7qBpg/byelK3acq4s=,tag:VY78NrgHsm+3Imy6dwPIPA==,type:comment]
#ENC[AES256_GCM,data:IJpspOGP4TvW9Bk1vd4OV64DvaKCAGtPIoQm2zQ=,iv:AobxL61gfP5S5l2E7vigM/mzMgg32pqBLPu8rwwdeAA=,tag:Z5cB6zpD2mgTjbdVJCQ9QA==,type:comment]
#ENC[AES256_GCM,data:dX/4WdhxBU6RoB+SkOtsWOWPgf+bX0WC+gxJZqLN8yj+,iv:YER4AaTpLKsCch3Xlvbq4ouDFk4cAcwkJkefgzcd8vc=,tag:zpWLsEiCIBkimiPmMqnFMw==,type:comment]
#ENC[AES256_GCM,data:BmpuNaaJc5PnG2j5YfMU5FfAx+0i7EG3ROOj0vtfQCIi,iv:AxVMdkdrC4r9I9eR4hDQM1Ul6WRmMe5kZt4UuoyyA30=,tag:60qHouezyElWIIuTp9hAfw==,type:comment]
zitadel_api_token: ENC[AES256_GCM,data:LoGhHkXy8M1SYooN67lfb23XAd9w78Q7I9dTOuSlqy1qaJ98Kupjr3vO6SxpN0L+d1BSY5o8dbmgYQnXyYzx9MTKA+UsW5w=,iv:dY2PGkQz7BkYSJgxDBgXGlEboMRyvK6dp9vqHmbjus8=,tag:nv4cqczg7JjKeDFipvAIKA==,type:str]
sops:
age:
- recipient: age170jqy5pg6z62kevadqyxxekw8ryf3e394zaquw0nhs9ae3v9wd6qq2hxnk
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFZGhMWFFDY2VNU1FFSlkw
Ukw1cXk4c2R5OHRLOUtVcno4WnBHNXk0QVVZClFTeFZtRmVBV2NzQkxabFZORFhC
eDcxemZtckdwRTVCQXlTalVYNDV5ZTQKLS0tIERTdWNCeE1Rb0JpSFRGVHh1Zml2
S3NMRXo4MUxNS1p2d3o5QS9VTld5OWMKb02yi2CyGhAb2YsdgRZm6vhuRV0OIt7I
Lc/mkS3fvs4CDsQsngFFh8YJQlhPolewvijJcOjhN3ga91OS6MTwbQ==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-01-06T10:48:07Z"
mac: ENC[AES256_GCM,data:T0Dj4JggKXa8wpl4VP4qCVg3D7fETNdTTKcEHYw2D8++eokzfpEcseVpiF+NNMrz4m+wTLgUmPNezXVF69i6qy9H1+isZ86426o/VSOi9NujkThFqd/KI6MpEGl249B/pWQyWAyFq3dpKS+6spKYX6tePPSVAKh0yuSy+kweitg=,iv:g1Q1yO/qql2WkeGRynK44jzLcFxLNIsPR4KocUmWAgY=,tag:jOpQtrr6RQmKhu1uRi7Qcw==,type:str]
unencrypted_suffix: _unencrypted
version: 3.11.0