Deployment Guide
Step-by-step guide to deploy the nuuvi platform to a Hetzner Cloud server.
Prerequisites
Accounts & API Tokens
| Service | What you need | Purpose |
|---|---|---|
| Hetzner Cloud | API token (per project) | Server, firewall, volume |
| Cloudflare | API token (Zone:DNS:Edit for nuuvi.app) | DNS records + Caddy TLS |
| Cloudflare R2 | Access key + secret key | Media storage (S3-compatible) |
| Mailgun | SMTP DSN (EU region) | Transactional email |
| GitLab | Deploy token (read_registry scope) | Docker image pull on server |
CLI Tools
brew install opentofu hcloud glab
glab auth login
SSH Key
Your ~/.ssh/id_ed25519.pub will be added to the server’s deploy user.
Step 1: Generate Secrets
Important: Use openssl rand -hex (not base64) — special characters like /, +, = break database URLs.
openssl rand -hex 32 # APP_SECRET
openssl rand -hex 16 # DB_PASSWORD
openssl rand -hex 16 # REDIS_PASSWORD
openssl rand -hex 16 # ZITADEL_MASTERKEY (must be exactly 32 chars)
openssl rand -hex 16 # ZITADEL_DB_PASSWORD
Save these securely (e.g., 1Password).
Step 2: Configure OpenTofu
cd infrastructure/tofu/environments/test # or production
tofu init
Create secret.auto.tfvars (gitignored) with your tokens and secrets:
# Provider tokens
hcloud_token = "your-hetzner-token"
cloudflare_api_token = "your-cloudflare-token"
# SSH
ssh_public_key = "ssh-ed25519 AAAA... you@machine"
# App secrets (hex-only, no special chars!)
app_secret = "<generated>"
db_password = "<generated>"
redis_password = "<generated>"
zitadel_masterkey = "<generated-32-chars>"
zitadel_db_password = "<generated>"
# Cloudflare R2
r2_access_key = "<your-r2-access-key>"
r2_secret_key = "<your-r2-secret-key>"
# Zitadel OIDC (auto-populated by server-setup.sh on first boot)
zitadel_client_id = "placeholder"
zitadel_client_secret = "placeholder"
# GitLab Container Registry (deploy token with read_registry scope)
gitlab_deploy_user = "gitlab+deploy-token-XXXXX"
gitlab_deploy_token = "gldt-XXXXX"
# Mail
mailgun_smtp_dsn = "smtp://user:pass@smtp.eu.mailgun.org:587"
Step 3: Provision Server
make infra-plan-test # preview (7 resources: server, firewall, volume, SSH key, DNS)
make infra-apply-test # create everything
This creates:
- Hetzner CPX22 (2 vCPU, 4 GB RAM) in Nuremberg
- 20 GB ext4 volume at
/mnt/data - Firewall (SSH/HTTP/HTTPS/ICMP)
- Cloudflare DNS:
test.nuuvi.app+*.test.nuuvi.app - Cloud-init: Docker, fail2ban,
.env.prod, data directories
Wait ~3-5 minutes for cloud-init to complete:
SERVER_IP=$(cd infrastructure/tofu/environments/test && tofu output -raw server_ip)
ssh deploy@$SERVER_IP cloud-init status # wait for "done"
ssh deploy@$SERVER_IP docker --version # verify Docker installed
Step 4: Set GitLab CI/CD Variables
Generate a deploy SSH key (RSA PEM format for Alpine compatibility)
ssh-keygen -t rsa -b 4096 -C "gitlab-ci-deploy@nuuvi" -f ~/.ssh/nuuvi-deploy -N ""
ssh-keygen -p -m PEM -f ~/.ssh/nuuvi-deploy -N "" -P "" # convert to PEM format
Add to server + GitLab
SERVER_IP=$(cd infrastructure/tofu/environments/test && tofu output -raw server_ip)
# Add deploy key to server
ssh deploy@$SERVER_IP "echo '$(cat ~/.ssh/nuuvi-deploy.pub)' >> ~/.ssh/authorized_keys"
# Set GitLab CI variables
glab variable set SSH_PRIVATE_KEY --type file < ~/.ssh/nuuvi-deploy
glab variable set SSH_KNOWN_HOSTS "$(ssh-keyscan $SERVER_IP 2>/dev/null)"
glab variable set SSH_HOST_STAGING "$SERVER_IP"
Docker registry login is handled automatically by server-setup.sh using the gitlab_deploy_user and gitlab_deploy_token from secret.auto.tfvars.
Step 5: Sync & Initialize Server
make server-sync-test # copies compose, Caddy, Zitadel config, scripts to server
make server-init-test # first boot: builds Caddy, starts services, configures Zitadel OIDC, runs migrations, creates users
server-init does everything automatically:
- Builds Caddy with Cloudflare DNS plugin
- Pulls app Docker image from GitLab registry
- Starts PostgreSQL + Redis
- Starts Zitadel (creates admin user + PAT on first boot)
- Configures OIDC apps (web + mobile) and writes credentials to
.env.prod - Starts all services (app, worker, Caddy, Uptime Kuma)
- Runs database migrations
- Creates platform users and organizations (
app:init)
Step 6: Verify
# Health check
curl -sf https://test.nuuvi.app/healthz
# Zitadel OIDC
curl -sf https://auth.test.nuuvi.app/oauth/v2/keys | head -c 50
# All containers running
ssh deploy@$SERVER_IP "cd /opt/nuuvi && set -a && source .env.prod && set +a && docker compose -f docker-compose.prod.yml ps"
Login credentials
| Role | URL | Username | Password |
|---|---|---|---|
| Zitadel Admin | https://auth.test.nuuvi.app | admin@localhost | Admin123! |
| Super Admin | https://test.nuuvi.app/login | superadmin | Superadmin1! |
CI/CD Pipeline
Pushing to develop triggers the full pipeline:
lint → test → build → deploy-staging → smoke
- Auto-deploy: Every push to
developdeploys to staging - Manual deploy: Tag
v*.*.*onmain→ manual deploy to production - Monitor:
glab ci statusorglab ci list
Daily Operations
| Task | Command |
|---|---|
| Deploy manually | make deploy-test |
| SSH to server | ssh deploy@$(cd infrastructure/tofu/environments/test && tofu output -raw server_ip) |
| View logs | ssh deploy@<IP> 'cd /opt/nuuvi && set -a && source .env.prod && set +a && docker compose -f docker-compose.prod.yml logs -f app' |
| Run migrations | ssh deploy@<IP> 'cd /opt/nuuvi && set -a && source .env.prod && set +a && docker compose -f docker-compose.prod.yml exec -T app php bin/console doctrine:migrations:migrate --no-interaction' |
| Pipeline status | glab ci status |
| Trigger pipeline | Push to develop branch |
| Check infra | make infra-plan-test |
| Destroy server | cd infrastructure/tofu/environments/test && tofu destroy |
Production Deployment
Same steps, replace test with prod:
make infra-apply-prod
make server-sync-prod
make server-init-prod
Production differences:
- Domain:
nuuvi.app(root) - Volume: 40 GB
- No swap
- Manual deploy only (via
v*.*.*tags)
Architecture
Internet → Caddy (TLS termination, port 443)
├── nuuvi.app → app:8080 (FrankenPHP)
├── *.nuuvi.app → app:8080 (org subdomains)
├── auth.nuuvi.app → zitadel:8080 (OIDC)
└── status.nuuvi.app → uptime-kuma:3001
Internal (Docker network):
├── app → PostgreSQL, Redis
├── worker → PostgreSQL, Redis (Messenger consumers)
├── zitadel → PostgreSQL
└── caddy → app, zitadel, uptime-kuma
7 containers: Caddy, App, Worker, PostgreSQL, Redis, Zitadel, Uptime Kuma
Backup & Disaster Recovery
After first setup completes, save .env.prod to 1Password:
ssh deploy@<server-ip> cat /opt/nuuvi/.env.prod
This file contains 4 OIDC credentials generated by Zitadel on first boot (ZITADEL_CLIENT_ID, ZITADEL_CLIENT_SECRET, ZITADEL_MOBILE_CLIENT_ID, ZITADEL_SERVICE_USER_PAT). They only exist on the server — if you lose both the server and the backup, these are gone.
For backup setup, retention policy, restore procedures, and full disaster recovery scenarios, see infrastructure/backup/README.md.