Deployment Guide

Deployment Guide

Step-by-step guide to deploy the nuuvi platform to a Hetzner Cloud server.

Prerequisites

Accounts & API Tokens

ServiceWhat you needPurpose
Hetzner CloudAPI token (per project)Server, firewall, volume
CloudflareAPI token (Zone:DNS:Edit for nuuvi.app)DNS records + Caddy TLS
Cloudflare R2Access key + secret keyMedia storage (S3-compatible)
MailgunSMTP DSN (EU region)Transactional email
GitLabDeploy 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:

  1. Builds Caddy with Cloudflare DNS plugin
  2. Pulls app Docker image from GitLab registry
  3. Starts PostgreSQL + Redis
  4. Starts Zitadel (creates admin user + PAT on first boot)
  5. Configures OIDC apps (web + mobile) and writes credentials to .env.prod
  6. Starts all services (app, worker, Caddy, Uptime Kuma)
  7. Runs database migrations
  8. 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

RoleURLUsernamePassword
Zitadel Adminhttps://auth.test.nuuvi.appadmin@localhostAdmin123!
Super Adminhttps://test.nuuvi.app/loginsuperadminSuperadmin1!

CI/CD Pipeline

Pushing to develop triggers the full pipeline:

lint → test → build → deploy-staging → smoke
  • Auto-deploy: Every push to develop deploys to staging
  • Manual deploy: Tag v*.*.* on main → manual deploy to production
  • Monitor: glab ci status or glab ci list

Daily Operations

TaskCommand
Deploy manuallymake deploy-test
SSH to serverssh deploy@$(cd infrastructure/tofu/environments/test && tofu output -raw server_ip)
View logsssh deploy@<IP> 'cd /opt/nuuvi && set -a && source .env.prod && set +a && docker compose -f docker-compose.prod.yml logs -f app'
Run migrationsssh 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 statusglab ci status
Trigger pipelinePush to develop branch
Check inframake infra-plan-test
Destroy servercd 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.