Deployment: Caddy-Edge (TLS + On-Demand für Custom-Domains) + Hetzner DNS

- Caddy ersetzt den Hetzner-LB: terminiert TLS (Portal-Domain automatisch) und
  load-balanced per reverse_proxy über die App-Nodes. Für Custom-Domains (§11)
  On-Demand-TLS, autorisiert über GET /internal/tls-allowed.
- TlsCheckController + DomainRepository::findVerifiedByHostname: erlaubt Zertifikate
  nur für Portal-Domain oder verifizierte Domains (Schutz vor Cert-Flooding).
- Terraform: hcloud_load_balancer entfernt, Caddy-Server + Firewall (80/443) +
  cloud-init-caddy (Caddyfile templated mit Upstreams/Domain/ACME).
- Optional Hetzner DNS via API (manage_dns): A-Record Portal + Wildcard → Caddy.
- nginx.prod: /internal zu Symfony geroutet; APP_PORTAL_DOMAIN-Env.

Validiert: Caddyfile (caddy validate), Terraform (validate), /internal/tls-allowed (200/403/400).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-05-31 22:13:29 +02:00
parent c3e05257cb
commit 79e996ab03
13 changed files with 266 additions and 67 deletions

View File

@ -46,6 +46,11 @@ JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=d75959918d9ccc5c89c62edbd6e6c6af82d6e2a3d303c53a6f3328e94a05b60a JWT_PASSPHRASE=d75959918d9ccc5c89c62edbd6e6c6af82d6e2a3d303c53a6f3328e94a05b60a
###< lexik/jwt-authentication-bundle ### ###< lexik/jwt-authentication-bundle ###
###> App ###
# Portal-Domain (für On-Demand-TLS-Autorisierung). In Prod auf die echte Domain setzen.
APP_PORTAL_DOMAIN=localhost
###< App ###
###> S3 / Object Storage (Druck-Assets) ### ###> S3 / Object Storage (Druck-Assets) ###
# Lokal: MinIO. Prod: Hetzner Object Storage (Werte in .env.local / Server-Env setzen). # Lokal: MinIO. Prod: Hetzner Object Storage (Werte in .env.local / Server-Env setzen).
S3_ENDPOINT=http://minio:9000 S3_ENDPOINT=http://minio:9000

View File

@ -0,0 +1,44 @@
<?php
namespace App\Controller;
use App\Repository\DomainRepository;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* On-Demand-TLS-Autorisierung für Caddy (KONZEPT §11): Caddy fragt vor dem
* Ausstellen eines Let's-Encrypt-Zertifikats hier nach, ob die Domain erlaubt ist.
* Erlaubt = Portal-Domain oder eine verifizierte Custom-Domain aus der DB.
* 200 ausstellen, sonst ablehnen (verhindert unbegrenzte Zertifikatsanfragen).
*/
final class TlsCheckController
{
public function __construct(
#[Autowire('%env(APP_PORTAL_DOMAIN)%')]
private readonly string $portalDomain,
) {
}
#[Route('/internal/tls-allowed', name: 'tls_allowed', methods: ['GET'])]
public function __invoke(Request $request, DomainRepository $domains): Response
{
$host = strtolower(trim((string) $request->query->get('domain')));
if ('' === $host) {
return new Response('missing domain', 400);
}
$portal = strtolower($this->portalDomain);
if ($host === $portal || $host === 'www.'.$portal) {
return new Response('ok', 200);
}
if (null !== $domains->findVerifiedByHostname($host)) {
return new Response('ok', 200);
}
return new Response('not allowed', 403);
}
}

View File

@ -15,4 +15,10 @@ class DomainRepository extends ServiceEntityRepository
{ {
parent::__construct($registry, Domain::class); parent::__construct($registry, Domain::class);
} }
/** Verifizierte (TLS-fähige) Domain anhand des Hostnamens. */
public function findVerifiedByHostname(string $hostname): ?Domain
{
return $this->findOneBy(['hostname' => $hostname, 'status' => Domain::STATUS_VERIFIED]);
}
} }

View File

@ -3,14 +3,22 @@
Provisioniert einen **Multi-Node-Test** mit nachgewiesener Skalierbarkeit: Provisioniert einen **Multi-Node-Test** mit nachgewiesener Skalierbarkeit:
``` ```
Hetzner Load Balancer (lb11, :80 → /health) Internet (HTTPS)
vcard4-caddy ── TLS: Portal-Domain (automatisch) + Custom-Domains (On-Demand)
(Reverse-Proxy + Load-Balancing)
/ \ / \
vcard4-app-1 vcard4-app-2 (zustandslose App-Nodes) vcard4-app-1 vcard4-app-2 (zustandslose App-Nodes, HTTP im privaten Netz)
\ / \ /
┌─────────┴──────────┐ ┌───────────────────────┐ ┌─────────┴──────────┐ ┌───────────────────────┐
vcard4-db (MariaDB, Volume) Hetzner Object Storage (S3, Assets) vcard4-db (MariaDB+Vol) Hetzner Object Storage (S3, Assets)
``` ```
**Caddy** übernimmt TLS (Let's Encrypt) und Load-Balancing — kein Hetzner-LB nötig.
Für **Custom-Domains** der Firmenkunden (§11) macht Caddy *On-Demand-TLS* und fragt
vorher `GET /internal/tls-allowed?domain=…` in der App (Portal-Domain oder verifizierte
Domain aus der DB) → schützt vor unbegrenzten Zertifikatsanfragen.
App-Nodes sind zustandslos (Code + Docker), **State** liegt in DB (eigene VM) und App-Nodes sind zustandslos (Code + Docker), **State** liegt in DB (eigene VM) und
Object Storage (Hintergrund-PDFs/Schriften). Dadurch beliebig horizontal skalierbar. Object Storage (Hintergrund-PDFs/Schriften). Dadurch beliebig horizontal skalierbar.
@ -27,8 +35,9 @@ Object Storage (Hintergrund-PDFs/Schriften). Dadurch beliebig horizontal skalier
# → backend/config/jwt/private.pem & public.pem + Passphrase aus .env (JWT_PASSPHRASE) # → backend/config/jwt/private.pem & public.pem + Passphrase aus .env (JWT_PASSPHRASE)
``` ```
Inhalt der beiden PEM-Dateien + Passphrase in `terraform.tfvars` eintragen. Inhalt der beiden PEM-Dateien + Passphrase in `terraform.tfvars` eintragen.
5. **DNS**: nach `terraform apply` einen A-Record `domain → load_balancer_ip` setzen 5. **DNS**: A-Record `domain → caddy_ip` (+ optional `*.zone → caddy_ip` für Subdomains).
(Hetzner DNS ist separat; A-Record auch für die Custom-Domain-Funktion, KONZEPT §11). Entweder manuell **oder** automatisch über die **Hetzner DNS API** (`manage_dns = true`
+ `hetzner_dns_token` + `dns_zone_name`; Zone muss bei Hetzner DNS liegen).
## Deploy ## Deploy
@ -49,28 +58,36 @@ Deploy-Log auf dem Node: `/var/log/vcard4-deploy.log`.
## Skalierbarkeit verifizieren ## Skalierbarkeit verifizieren
```bash ```bash
# 1) Login + Token holen (LB-IP oder Domain) # 1) Login über die Domain (Caddy → App-Nodes)
TOKEN=$(curl -s -X POST http://<LB-IP>/api/login \ TOKEN=$(curl -s -X POST https://<domain>/api/login \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"email":"admin@vcard4reseller.de","password":"admin"}' | jq -r .token) -d '{"email":"admin@vcard4reseller.de","password":"admin"}' | jq -r .token)
# 2) Health über den LB (verteilt auf beide Nodes) # 2) Health über Caddy (round-robin auf beide Nodes)
for i in $(seq 1 6); do curl -s http://<LB-IP>/health; echo; done for i in $(seq 1 6); do curl -s https://<domain>/health; echo; done
# 3) Cross-Node-Beweis: Hintergrund über den LB hochladen, dann # 3) Cross-Node-Beweis: Hintergrund über die Domain hochladen, dann
# auf BEIDEN Nodes rendern (per SSH) identische PDFs aus Object Storage: # 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-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' 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). ## Skalieren
`app_count` erhöhen → `terraform apply` legt neue App-Nodes an. **Achtung:** die
Caddy-Upstreams stehen in der Caddyfile (per cloud-init beim ersten Boot gerendert) —
neue Nodes werden **nicht automatisch** aufgenommen. Optionen: Caddy-Node neu erstellen
(`terraform taint hcloud_server.caddy && apply`) oder Caddyfile auf dem Caddy-Node
aktualisieren + `docker exec caddy caddy reload`. (Später besser: Caddy-Config-Templating
per CI oder Service-Discovery.)
## Noch offen / Hinweise ## Noch offen / Hinweise
- **TLS**: aktuell HTTP am LB. Für HTTPS entweder Hetzner **Managed Certificate** - **TLS**: erledigt Caddy (Portal automatisch, Custom-Domains On-Demand). Erste
am LB (DNS bei Hetzner) oder **Caddy** auf den Nodes (On-Demand-TLS) — letzteres Zertifikatsausstellung dauert ein paar Sekunden nach korrektem DNS.
ist auch der Weg für die **Custom-Domains** der Firmenkunden (§11). - **Trusted Proxies**: für korrekte absolute URLs hinter Caddy
- **Trusted Proxies**: für korrekte absolute URLs hinter dem LB
`framework.trusted_proxies` auf `%env(TRUSTED_PROXIES)%` setzen. `framework.trusted_proxies` auf `%env(TRUSTED_PROXIES)%` setzen.
- **Host-basiertes Routing** (Custom-Domain → richtige Firmenseite) ist §11-Folgearbeit;
Caddy stellt bereits Zertifikate für verifizierte Domains aus.
- **Seed**: optional einmalig `app:seed` auf `app-1` für Demo-Daten. - **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). - **Updates**: neuen Stand ausrollen = auf den App-Nodes `git pull` + `docker compose ... up -d --build` (später per CI/Skript).

View File

@ -11,8 +11,8 @@ server {
client_max_body_size 32m; client_max_body_size 32m;
# Symfony-Pfade (API + serverseitige öffentliche Seiten) # Symfony-Pfade (API + serverseitige öffentliche Seiten + interne Endpunkte)
location ~ ^/(api|p|t|css|bundles|health)(/|$) { location ~ ^/(api|p|t|css|bundles|health|internal)(/|$) {
root /app/public; root /app/public;
try_files $uri /index.php$is_args$args; try_files $uri /index.php$is_args$args;
} }

View File

@ -7,6 +7,7 @@ write_files:
APP_ENV=prod APP_ENV=prod
APP_DEBUG=0 APP_DEBUG=0
APP_SECRET=${app_secret} APP_SECRET=${app_secret}
APP_PORTAL_DOMAIN=${domain}
DATABASE_URL="${database_url}" DATABASE_URL="${database_url}"
CORS_ALLOW_ORIGIN=${cors_allow_origin} CORS_ALLOW_ORIGIN=${cors_allow_origin}
TRUSTED_PROXIES=10.0.0.0/16 TRUSTED_PROXIES=10.0.0.0/16

View File

@ -0,0 +1,40 @@
#cloud-config
package_update: true
write_files:
- path: /opt/caddy/Caddyfile
permissions: '0644'
content: |
{
email ${acme_email}
on_demand_tls {
# Caddy fragt die App, ob es für die Domain ein Zertifikat ausstellen darf
ask http://${ask_upstream}/internal/tls-allowed
}
}
# Portal (Haupt-Domain): automatisches TLS, Load-Balancing über die App-Nodes
${domain}, www.${domain} {
reverse_proxy ${app_upstreams} {
lb_policy round_robin
}
}
# Custom-Domains der Firmenkunden: On-Demand-TLS (nur erlaubte Hosts)
https:// {
tls {
on_demand
}
reverse_proxy ${app_upstreams} {
lb_policy round_robin
}
}
runcmd:
- command -v docker >/dev/null 2>&1 || curl -fsSL https://get.docker.com | sh
- mkdir -p /opt/caddy/data /opt/caddy/config
- |
docker run -d --name caddy --restart unless-stopped \
-p 80:80 -p 443:443 -p 443:443/udp \
-v /opt/caddy/Caddyfile:/etc/caddy/Caddyfile:ro \
-v /opt/caddy/data:/data \
-v /opt/caddy/config:/config \
caddy:2

32
deploy/terraform/dns.tf Normal file
View File

@ -0,0 +1,32 @@
# Optional: DNS-Records über die Hetzner DNS API anlegen (manage_dns = true).
# Voraussetzung: Zone liegt bei Hetzner DNS, separater DNS-API-Token.
data "hetznerdns_zone" "zone" {
count = var.manage_dns ? 1 : 0
name = var.dns_zone_name
}
locals {
# Relativer Record-Name: "@" wenn Portal == Zone, sonst der Subdomain-Teil
portal_record_name = var.domain == var.dns_zone_name ? "@" : replace(var.domain, ".${var.dns_zone_name}", "")
}
# Portal-Domain Caddy
resource "hetznerdns_record" "portal" {
count = var.manage_dns ? 1 : 0
zone_id = data.hetznerdns_zone.zone[0].id
name = local.portal_record_name
type = "A"
value = hcloud_server.caddy.ipv4_address
ttl = 300
}
# Wildcard für Firmen-Subdomains (KONZEPT §11) Caddy (On-Demand-TLS)
resource "hetznerdns_record" "wildcard" {
count = var.manage_dns ? 1 : 0
zone_id = data.hetznerdns_zone.zone[0].id
name = "*"
type = "A"
value = hcloud_server.caddy.ipv4_address
ttl = 300
}

View File

@ -1,9 +1,13 @@
locals { locals {
db_private_ip = "10.0.1.10" db_private_ip = "10.0.1.10"
lb_private_ip = "10.0.1.5" caddy_private_ip = "10.0.1.5"
app_base_ip = 20 # App-Nodes: 10.0.1.20, .21, ... 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" database_url = "mysql://${var.db_user}:${var.db_password}@${local.db_private_ip}:3306/${var.db_name}?serverVersion=11.4.0-MariaDB&charset=utf8mb4"
# Caddy-Upstreams = private IPs der App-Nodes (:80)
app_upstreams = join(" ", [for i in range(var.app_count) : "10.0.1.${local.app_base_ip + i}:80"])
ask_upstream = "10.0.1.${local.app_base_ip}" # app-1 für die On-Demand-TLS-Abfrage
} }
resource "hcloud_ssh_key" "admin" { resource "hcloud_ssh_key" "admin" {
@ -117,43 +121,54 @@ resource "hcloud_server" "app" {
depends_on = [hcloud_network_subnet.subnet, hcloud_server.db] depends_on = [hcloud_network_subnet.subnet, hcloud_server.db]
} }
# --- Load Balancer --- # --- Caddy-Edge (TLS-Terminierung + Reverse-Proxy/Load-Balancing) ---
resource "hcloud_load_balancer" "lb" { resource "hcloud_firewall" "caddy" {
name = "vcard4-lb" name = "vcard4-caddy-fw"
load_balancer_type = "lb11" rule {
direction = "in"
protocol = "tcp"
port = "22"
source_ips = [var.admin_cidr]
}
rule {
direction = "in"
protocol = "tcp"
port = "80"
source_ips = ["0.0.0.0/0", "::/0"]
}
rule {
direction = "in"
protocol = "tcp"
port = "443"
source_ips = ["0.0.0.0/0", "::/0"]
}
rule {
direction = "in"
protocol = "udp"
port = "443"
source_ips = ["0.0.0.0/0", "::/0"]
}
}
resource "hcloud_server" "caddy" {
name = "vcard4-caddy"
server_type = var.app_server_type
image = "ubuntu-24.04"
location = var.location location = var.location
} ssh_keys = [hcloud_ssh_key.admin.id]
firewall_ids = [hcloud_firewall.caddy.id]
resource "hcloud_load_balancer_network" "lb_net" { user_data = templatefile("${path.module}/cloud-init-caddy.yaml.tftpl", {
load_balancer_id = hcloud_load_balancer.lb.id acme_email = var.acme_email
domain = var.domain
app_upstreams = local.app_upstreams
ask_upstream = local.ask_upstream
})
network {
network_id = hcloud_network.net.id network_id = hcloud_network.net.id
ip = local.lb_private_ip ip = local.caddy_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??"]
}
} }
depends_on = [hcloud_network_subnet.subnet, hcloud_server.app]
} }

View File

@ -1,6 +1,6 @@
output "load_balancer_ip" { output "caddy_ip" {
description = "Öffentliche IP des Load Balancers → hierauf den DNS-A-Record setzen" description = "Öffentliche IP des Caddy-Edge → hierauf den DNS-A-Record (Portal + *.) setzen"
value = hcloud_load_balancer.lb.ipv4 value = hcloud_server.caddy.ipv4_address
} }
output "app_server_ips" { output "app_server_ips" {

View File

@ -14,9 +14,15 @@ db_server_type = "cx22"
# Anwendung # Anwendung
repo_url = "https://github.com/DEIN-USER/vcard4reseller.git" # privat: Token in URL repo_url = "https://github.com/DEIN-USER/vcard4reseller.git" # privat: Token in URL
repo_branch = "main" repo_branch = "main"
domain = "test.example.com" domain = "test.example.com" # Portal-Domain (ins Portal einloggen)
acme_email = "admin@example.com" # Let's Encrypt
app_secret = "GENERIERE_32_HEX" # z. B. openssl rand -hex 16 app_secret = "GENERIERE_32_HEX" # z. B. openssl rand -hex 16
# DNS optional über Hetzner DNS API (sonst A-Record manuell auf caddy_ip setzen)
manage_dns = false
hetzner_dns_token = "" # separater DNS-API-Token
dns_zone_name = "" # z. B. example.com
# Datenbank # Datenbank
db_password = "STARKES_PASSWORT" db_password = "STARKES_PASSWORT"
db_root_password = "STARKES_ROOT_PASSWORT" db_root_password = "STARKES_ROOT_PASSWORT"

View File

@ -57,10 +57,35 @@ variable "repo_branch" {
} }
variable "domain" { variable "domain" {
description = "Öffentliche Domain (für CORS, Profil-URLs, später TLS)" description = "Öffentliche Portal-Domain (CORS, Profil-URLs, TLS)"
type = string type = string
} }
variable "acme_email" {
description = "E-Mail für Let's Encrypt (Caddy ACME)"
type = string
}
# --- DNS (optional, Hetzner DNS API) ---
variable "manage_dns" {
description = "true = A-Records (Portal + Wildcard) per Hetzner DNS anlegen"
type = bool
default = false
}
variable "hetzner_dns_token" {
description = "Hetzner DNS API Token (separat vom Cloud-Token; nur bei manage_dns)"
type = string
default = ""
sensitive = true
}
variable "dns_zone_name" {
description = "DNS-Zone bei Hetzner DNS (z. B. example.com), nur bei manage_dns"
type = string
default = ""
}
variable "app_secret" { variable "app_secret" {
description = "Symfony APP_SECRET" description = "Symfony APP_SECRET"
type = string type = string

View File

@ -5,9 +5,17 @@ terraform {
source = "hetznercloud/hcloud" source = "hetznercloud/hcloud"
version = "~> 1.48" version = "~> 1.48"
} }
hetznerdns = {
source = "germanbrew/hetznerdns"
version = "~> 3.0"
}
} }
} }
provider "hcloud" { provider "hcloud" {
token = var.hcloud_token token = var.hcloud_token
} }
provider "hetznerdns" {
api_token = var.hetzner_dns_token
}