Deployment: Hetzner Cloud via Terraform (Multi-Node, skalierbar)

Infrastruktur als Code für den Skalierungs-Test auf Hetzner:
- deploy/terraform: privates Netz, Firewalls, 2 App-Nodes, DB-Node, Load
  Balancer (Health-Check /health); cloud-init bootet Docker + Stack je Node
- deploy/compose/docker-compose.prod.yml + nginx.prod.conf: App-Node-Stack
  (PHP-FPM + Nginx) routet /api,/p,/t,/css,/health → Symfony, Rest → Vue-SPA
- App-Anpassungen: HealthController (/health für LB), brand.css nach /css
  verschoben (kein Pfad-Clash mit SPA-Assets im Prod-Routing)
- deploy/README.md: Anleitung inkl. JWT-Key-Verteilung & Cross-Node-Test
- reference.php (auto-generiert) aus Versionierung entfernt

Terraform validiert (terraform validate), Prod-Compose-Syntax geprüft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-05-31 21:20:58 +02:00
parent 67e4353c8d
commit c3e05257cb
16 changed files with 615 additions and 1818 deletions

7
.gitignore vendored
View File

@ -24,3 +24,10 @@ backend/.playwright-mcp/
# Auto-generierte Symfony-Config-Referenz
/backend/config/reference.php
# Terraform
deploy/terraform/.terraform/
deploy/terraform/*.tfstate
deploy/terraform/*.tfstate.*
deploy/terraform/terraform.tfvars
deploy/terraform/.terraform.lock.hcl

View File

@ -16,6 +16,7 @@ Konzept & Datenmodell: siehe [`docs/KONZEPT.md`](docs/KONZEPT.md).
backend/ Symfony-API (JSON/JSON-LD)
frontend/ Vue-3-SPA (Dashboards)
docker/ Dockerfile (PHP) + Nginx-Config
deploy/ Hetzner-Deployment (Terraform + Prod-Compose) — siehe deploy/README.md
docs/ Konzept & Datenmodell
```

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,29 @@
<?php
namespace App\Controller;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
/**
* Health-Check für den Load Balancer. Öffentlich (nicht unter /api),
* liefert 200, solange App + DB erreichbar sind, sonst 503.
*/
final class HealthController
{
#[Route('/health', name: 'health', methods: ['GET'])]
public function __invoke(Connection $connection): JsonResponse
{
try {
$connection->executeQuery('SELECT 1');
$db = 'up';
$status = 200;
} catch (\Throwable) {
$db = 'down';
$status = 503;
}
return new JsonResponse(['status' => 200 === $status ? 'ok' : 'degraded', 'db' => $db], $status);
}
}

View File

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}vcard4reseller{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><rect width=%22128%22 height=%22128%22 rx=%2228%22 fill=%22%23f58220%22/><text y=%221.0em%22 x=%220.5em%22 text-anchor=%22middle%22 font-size=%2284%22 font-family=%22Arial%22 font-weight=%22700%22 fill=%22%23fff%22>v</text></svg>">
<link rel="stylesheet" href="{{ asset('assets/brand.css') }}">
<link rel="stylesheet" href="{{ asset('css/brand.css') }}">
{% block stylesheets %}{% endblock %}
{% block meta %}{% endblock %}
</head>

76
deploy/README.md Normal file
View File

@ -0,0 +1,76 @@
# Deployment auf Hetzner Cloud (Terraform)
Provisioniert einen **Multi-Node-Test** mit nachgewiesener Skalierbarkeit:
```
Hetzner Load Balancer (lb11, :80 → /health)
/ \
vcard4-app-1 vcard4-app-2 (zustandslose App-Nodes)
\ /
┌─────────┴───────────┐ ┌─────┴──────────────────┐
vcard4-db (MariaDB, Volume) Hetzner Object Storage (S3, Assets)
```
App-Nodes sind zustandslos (Code + Docker), **State** liegt in DB (eigene VM) und
Object Storage (Hintergrund-PDFs/Schriften). Dadurch beliebig horizontal skalierbar.
## Voraussetzungen (einmalig, manuell)
1. **Terraform** ≥ 1.6 + Hetzner **API-Token** (Projekt → Security → API Tokens, Read&Write).
2. **Object Storage** in der Hetzner Console anlegen: Bucket + Access-Key/Secret
(hcloud/Terraform verwalten Object Storage derzeit nicht).
3. **Git-Repo** erreichbar für die Nodes (öffentlich oder Deploy-Token in der `repo_url`).
4. **JWT-Schlüsselpaar** einmal erzeugen — auf **allen** Nodes identisch:
```bash
# lokal im backend/-Container oder mit openssl
docker compose exec php php bin/console lexik:jwt:generate-keypair --overwrite
# → backend/config/jwt/private.pem & public.pem + Passphrase aus .env (JWT_PASSPHRASE)
```
Inhalt der beiden PEM-Dateien + Passphrase in `terraform.tfvars` eintragen.
5. **DNS**: nach `terraform apply` einen A-Record `domain → load_balancer_ip` setzen
(Hetzner DNS ist separat; A-Record auch für die Custom-Domain-Funktion, KONZEPT §11).
## Deploy
```bash
cd deploy/terraform
cp terraform.tfvars.example terraform.tfvars # ausfüllen
terraform init
terraform plan
terraform apply
terraform output # load_balancer_ip etc.
```
cloud-init installiert auf jedem App-Node Docker, klont das Repo, schreibt
`.env.prod.local` + JWT-Keys, baut das SPA und startet
`deploy/compose/docker-compose.prod.yml`. Migrationen laufen **nur** auf `app-1`.
Deploy-Log auf dem Node: `/var/log/vcard4-deploy.log`.
## Skalierbarkeit verifizieren
```bash
# 1) Login + Token holen (LB-IP oder Domain)
TOKEN=$(curl -s -X POST http://<LB-IP>/api/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@vcard4reseller.de","password":"admin"}' | jq -r .token)
# 2) Health über den LB (verteilt auf beide Nodes)
for i in $(seq 1 6); do curl -s http://<LB-IP>/health; echo; done
# 3) Cross-Node-Beweis: Hintergrund über den LB hochladen, dann
# auf BEIDEN Nodes rendern (per SSH) identische PDFs aus Object Storage:
ssh root@<app-1-ip> 'cd /opt/vcard4 && docker compose -f deploy/compose/docker-compose.prod.yml exec -T php php bin/console app:render-card erika-mustermann'
ssh root@<app-2-ip> 'cd /opt/vcard4 && docker compose -f deploy/compose/docker-compose.prod.yml exec -T php php bin/console app:render-card erika-mustermann'
```
Horizontal skalieren: `app_count` erhöhen → `terraform apply` (LB-Targets werden automatisch ergänzt).
## Noch offen / Hinweise
- **TLS**: aktuell HTTP am LB. Für HTTPS entweder Hetzner **Managed Certificate**
am LB (DNS bei Hetzner) oder **Caddy** auf den Nodes (On-Demand-TLS) — letzteres
ist auch der Weg für die **Custom-Domains** der Firmenkunden (§11).
- **Trusted Proxies**: für korrekte absolute URLs hinter dem LB
`framework.trusted_proxies` auf `%env(TRUSTED_PROXIES)%` setzen.
- **Seed**: optional einmalig `app:seed` auf `app-1` für Demo-Daten.
- **Updates**: neuen Stand ausrollen = auf den App-Nodes `git pull` + `docker compose ... up -d --build` (später per CI/Skript).

View File

@ -0,0 +1,25 @@
# Läuft auf jedem App-Node (zustandslos). DB & Object Storage sind extern.
# Aufruf mit --project-directory <repo> (Pfade relativ zum Repo-Root):
# docker compose --project-directory /opt/vcard4 \
# -f deploy/compose/docker-compose.prod.yml up -d --build
services:
php:
build:
context: ./docker/php
volumes:
- ./backend:/app
env_file:
- ./backend/.env.prod.local
restart: unless-stopped
nginx:
image: nginx:1.27-alpine
ports:
- "80:80"
volumes:
- ./backend:/app:ro # Symfony public (API + öffentliche Seiten)
- ./frontend/dist:/spa:ro # gebautes Vue-SPA (Dashboard)
- ./deploy/compose/nginx.prod.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- php
restart: unless-stopped

View File

@ -0,0 +1,39 @@
# Routing auf dem App-Node:
# /api, /p, /t, /css, /bundles, /health → Symfony (PHP-FPM)
# alles andere → Vue-SPA (history-fallback)
server {
listen 80;
server_name _;
# Standard: SPA-Build
root /spa;
index index.html;
client_max_body_size 32m;
# Symfony-Pfade (API + serverseitige öffentliche Seiten)
location ~ ^/(api|p|t|css|bundles|health)(/|$) {
root /app/public;
try_files $uri /index.php$is_args$args;
}
location ~ ^/index\.php(/|$) {
root /app/public;
fastcgi_pass php:9000;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $document_root;
internal;
}
# SPA-Routing (history mode)
location / {
try_files $uri /index.html;
}
# direkte .php-Aufrufe blockieren
location ~ \.php$ {
return 404;
}
}

View File

@ -0,0 +1,64 @@
#cloud-config
package_update: true
write_files:
- path: /opt/secrets/.env.prod.local
permissions: '0600'
content: |
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=${app_secret}
DATABASE_URL="${database_url}"
CORS_ALLOW_ORIGIN=${cors_allow_origin}
TRUSTED_PROXIES=10.0.0.0/16
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=${jwt_passphrase}
S3_ENDPOINT=${s3_endpoint}
S3_REGION=${s3_region}
S3_BUCKET=${s3_bucket}
S3_KEY=${s3_key}
S3_SECRET=${s3_secret}
S3_PATH_STYLE=true
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
- path: /opt/secrets/private.pem.b64
permissions: '0600'
content: ${base64encode(jwt_private_key)}
- path: /opt/secrets/public.pem.b64
permissions: '0644'
content: ${base64encode(jwt_public_key)}
- path: /opt/secrets/deploy.vars
permissions: '0600'
content: |
REPO_URL=${repo_url}
REPO_BRANCH=${repo_branch}
DOMAIN=${domain}
RUN_MIGRATIONS=${run_migrations}
- path: /opt/deploy.sh
permissions: '0755'
content: |
#!/usr/bin/env bash
set -euo pipefail
. /opt/secrets/deploy.vars
export DEBIAN_FRONTEND=noninteractive
command -v docker >/dev/null 2>&1 || curl -fsSL https://get.docker.com | sh
apt-get update && apt-get install -y git
rm -rf /opt/vcard4
git clone --branch "$REPO_BRANCH" --depth 1 "$REPO_URL" /opt/vcard4
cd /opt/vcard4
cp /opt/secrets/.env.prod.local backend/.env.prod.local
mkdir -p backend/config/jwt
base64 -d /opt/secrets/private.pem.b64 > backend/config/jwt/private.pem
base64 -d /opt/secrets/public.pem.b64 > backend/config/jwt/public.pem
chmod 640 backend/config/jwt/private.pem
# SPA bauen (Profil-/QR-Links zeigen auf die öffentliche Domain)
docker run --rm -e VITE_PUBLIC_BASE="https://$DOMAIN" -v "$PWD/frontend":/app -w /app node:25-alpine sh -c "npm ci && npm run build"
chown -R 1000:1000 /opt/vcard4
COMPOSE="docker compose --project-directory /opt/vcard4 -f deploy/compose/docker-compose.prod.yml"
$COMPOSE up -d --build
sleep 20
if [ "$RUN_MIGRATIONS" = "true" ]; then
$COMPOSE exec -T php php bin/console doctrine:migrations:migrate --no-interaction || true
fi
$COMPOSE exec -T php php bin/console cache:clear || true
runcmd:
- bash /opt/deploy.sh > /var/log/vcard4-deploy.log 2>&1

View File

@ -0,0 +1,14 @@
#cloud-config
package_update: true
runcmd:
- command -v docker >/dev/null 2>&1 || curl -fsSL https://get.docker.com | sh
- mkdir -p /opt/db/data
- |
docker run -d --name mariadb --restart unless-stopped \
-p 10.0.1.10:3306:3306 \
-e MARIADB_ROOT_PASSWORD='${db_root_password}' \
-e MARIADB_DATABASE='${db_name}' \
-e MARIADB_USER='${db_user}' \
-e MARIADB_PASSWORD='${db_password}' \
-v /opt/db/data:/var/lib/mysql \
mariadb:11.4

159
deploy/terraform/main.tf Normal file
View File

@ -0,0 +1,159 @@
locals {
db_private_ip = "10.0.1.10"
lb_private_ip = "10.0.1.5"
app_base_ip = 20 # App-Nodes: 10.0.1.20, .21, ...
database_url = "mysql://${var.db_user}:${var.db_password}@${local.db_private_ip}:3306/${var.db_name}?serverVersion=11.4.0-MariaDB&charset=utf8mb4"
}
resource "hcloud_ssh_key" "admin" {
name = "vcard4-admin"
public_key = var.ssh_public_key
}
# --- Privates Netz ---
resource "hcloud_network" "net" {
name = "vcard4-net"
ip_range = "10.0.0.0/16"
}
resource "hcloud_network_subnet" "subnet" {
network_id = hcloud_network.net.id
type = "cloud"
network_zone = var.network_zone
ip_range = "10.0.1.0/24"
}
# --- Firewalls ---
resource "hcloud_firewall" "app" {
name = "vcard4-app-fw"
rule {
direction = "in"
protocol = "tcp"
port = "22"
source_ips = [var.admin_cidr]
}
rule {
direction = "in"
protocol = "tcp"
port = "80"
source_ips = ["10.0.0.0/16"] # nur aus dem privaten Netz (Load Balancer)
}
}
resource "hcloud_firewall" "db" {
name = "vcard4-db-fw"
rule {
direction = "in"
protocol = "tcp"
port = "22"
source_ips = [var.admin_cidr]
}
rule {
direction = "in"
protocol = "tcp"
port = "3306"
source_ips = ["10.0.0.0/16"] # nur App-Nodes
}
}
# --- DB-Node ---
resource "hcloud_server" "db" {
name = "vcard4-db"
server_type = var.db_server_type
image = "ubuntu-24.04"
location = var.location
ssh_keys = [hcloud_ssh_key.admin.id]
firewall_ids = [hcloud_firewall.db.id]
user_data = templatefile("${path.module}/cloud-init-db.yaml.tftpl", {
db_name = var.db_name
db_user = var.db_user
db_password = var.db_password
db_root_password = var.db_root_password
})
network {
network_id = hcloud_network.net.id
ip = local.db_private_ip
}
depends_on = [hcloud_network_subnet.subnet]
}
# --- App-Nodes (zustandslos) ---
resource "hcloud_server" "app" {
count = var.app_count
name = "vcard4-app-${count.index + 1}"
server_type = var.app_server_type
image = "ubuntu-24.04"
location = var.location
ssh_keys = [hcloud_ssh_key.admin.id]
firewall_ids = [hcloud_firewall.app.id]
user_data = templatefile("${path.module}/cloud-init-app.yaml.tftpl", {
repo_url = var.repo_url
repo_branch = var.repo_branch
run_migrations = count.index == 0 # Migrationen nur auf dem ersten Node
app_secret = var.app_secret
database_url = local.database_url
domain = var.domain
cors_allow_origin = "^https?://(www\\.)?${replace(var.domain, ".", "\\.")}$"
jwt_passphrase = var.jwt_passphrase
jwt_private_key = var.jwt_private_key
jwt_public_key = var.jwt_public_key
s3_endpoint = var.s3_endpoint
s3_region = var.s3_region
s3_bucket = var.s3_bucket
s3_key = var.s3_key
s3_secret = var.s3_secret
})
network {
network_id = hcloud_network.net.id
ip = "10.0.1.${local.app_base_ip + count.index}"
}
depends_on = [hcloud_network_subnet.subnet, hcloud_server.db]
}
# --- Load Balancer ---
resource "hcloud_load_balancer" "lb" {
name = "vcard4-lb"
load_balancer_type = "lb11"
location = var.location
}
resource "hcloud_load_balancer_network" "lb_net" {
load_balancer_id = hcloud_load_balancer.lb.id
network_id = hcloud_network.net.id
ip = local.lb_private_ip
}
resource "hcloud_load_balancer_target" "app" {
count = var.app_count
type = "server"
load_balancer_id = hcloud_load_balancer.lb.id
server_id = hcloud_server.app[count.index].id
use_private_ip = true
depends_on = [hcloud_load_balancer_network.lb_net]
}
resource "hcloud_load_balancer_service" "http" {
load_balancer_id = hcloud_load_balancer.lb.id
protocol = "http"
listen_port = 80
destination_port = 80
health_check {
protocol = "http"
port = 80
interval = 10
timeout = 5
retries = 3
http {
path = "/health"
status_codes = ["2??"]
}
}
}

View File

@ -0,0 +1,14 @@
output "load_balancer_ip" {
description = "Öffentliche IP des Load Balancers → hierauf den DNS-A-Record setzen"
value = hcloud_load_balancer.lb.ipv4
}
output "app_server_ips" {
description = "Öffentliche IPs der App-Nodes (SSH/Debug)"
value = hcloud_server.app[*].ipv4_address
}
output "db_server_ip" {
description = "Öffentliche IP des DB-Nodes (SSH)"
value = hcloud_server.db.ipv4_address
}

View File

@ -0,0 +1,42 @@
# Kopiere nach terraform.tfvars und fülle die Werte. NICHT committen (steht in .gitignore).
hcloud_token = "DEIN_HETZNER_API_TOKEN"
ssh_public_key = "ssh-ed25519 AAAA... dein-key"
admin_cidr = "1.2.3.4/32" # deine IP für SSH
location = "nbg1"
network_zone = "eu-central"
app_count = 2
app_server_type = "cx22"
db_server_type = "cx22"
# Anwendung
repo_url = "https://github.com/DEIN-USER/vcard4reseller.git" # privat: Token in URL
repo_branch = "main"
domain = "test.example.com"
app_secret = "GENERIERE_32_HEX" # z. B. openssl rand -hex 16
# Datenbank
db_password = "STARKES_PASSWORT"
db_root_password = "STARKES_ROOT_PASSWORT"
# JWT (einmal erzeugen, identisch für alle Nodes siehe README)
jwt_passphrase = "DEINE_PASSPHRASE"
jwt_private_key = <<-EOT
-----BEGIN ENCRYPTED PRIVATE KEY-----
...
-----END ENCRYPTED PRIVATE KEY-----
EOT
jwt_public_key = <<-EOT
-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----
EOT
# Hetzner Object Storage (Bucket + Keys vorab in der Console anlegen)
s3_endpoint = "https://nbg1.your-objectstorage.com"
s3_region = "nbg1"
s3_bucket = "vcard4-card-assets"
s3_key = "OBJECT_STORAGE_ACCESS_KEY"
s3_secret = "OBJECT_STORAGE_SECRET_KEY"

View File

@ -0,0 +1,131 @@
variable "hcloud_token" {
description = "Hetzner Cloud API Token (Projekt → Security → API Tokens, Read&Write)"
type = string
sensitive = true
}
variable "location" {
description = "Hetzner Standort"
type = string
default = "nbg1"
}
variable "network_zone" {
description = "Netzwerk-Zone passend zum Standort (eu-central für nbg1/fsn1/hel1)"
type = string
default = "eu-central"
}
variable "ssh_public_key" {
description = "Öffentlicher SSH-Schlüssel für Server-Zugang"
type = string
}
variable "admin_cidr" {
description = "CIDR, das per SSH auf die Server darf (z. B. deine IP/32)"
type = string
}
variable "app_count" {
description = "Anzahl App-Nodes (für den Skalierungstest >= 2)"
type = number
default = 2
}
variable "app_server_type" {
description = "Servertyp App-Nodes"
type = string
default = "cx22"
}
variable "db_server_type" {
description = "Servertyp DB-Node"
type = string
default = "cx22"
}
# --- Anwendung / Deploy ---
variable "repo_url" {
description = "Git-URL des Repos (per cloud-init geklont; bei privat: Deploy-Token in der URL)"
type = string
}
variable "repo_branch" {
description = "Zu deployender Branch"
type = string
default = "main"
}
variable "domain" {
description = "Öffentliche Domain (für CORS, Profil-URLs, später TLS)"
type = string
}
variable "app_secret" {
description = "Symfony APP_SECRET"
type = string
sensitive = true
}
variable "db_name" {
type = string
default = "vcard4reseller"
}
variable "db_user" {
type = string
default = "app"
}
variable "db_password" {
type = string
sensitive = true
}
variable "db_root_password" {
type = string
sensitive = true
}
variable "jwt_passphrase" {
description = "Passphrase der JWT-Schlüssel (identisch zu den erzeugten Keys)"
type = string
sensitive = true
}
variable "jwt_private_key" {
description = "Inhalt von config/jwt/private.pem (auf ALLEN Nodes identisch)"
type = string
sensitive = true
}
variable "jwt_public_key" {
description = "Inhalt von config/jwt/public.pem"
type = string
sensitive = true
}
# --- Hetzner Object Storage (S3) ---
variable "s3_endpoint" {
description = "z. B. https://nbg1.your-objectstorage.com"
type = string
}
variable "s3_region" {
type = string
default = "nbg1"
}
variable "s3_bucket" {
type = string
}
variable "s3_key" {
type = string
sensitive = true
}
variable "s3_secret" {
type = string
sensitive = true
}

View File

@ -0,0 +1,13 @@
terraform {
required_version = ">= 1.6"
required_providers {
hcloud = {
source = "hetznercloud/hcloud"
version = "~> 1.48"
}
}
}
provider "hcloud" {
token = var.hcloud_token
}