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
###< 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) ###
# Lokal: MinIO. Prod: Hetzner Object Storage (Werte in .env.local / Server-Env setzen).
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);
}
/** 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:
```
Hetzner Load Balancer (lb11, :80 → /health)
/ \
vcard4-app-1 vcard4-app-2 (zustandslose App-Nodes)
\ /
┌─────────┴───────────┐ ┌─────┴──────────────────┐
vcard4-db (MariaDB, Volume) Hetzner Object Storage (S3, Assets)
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, HTTP im privaten Netz)
\ /
┌─────────┴──────────┐ ┌────────┴───────────────┐
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
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)
```
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).
5. **DNS**: A-Record `domain → caddy_ip` (+ optional `*.zone → caddy_ip` für Subdomains).
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
@ -49,28 +58,36 @@ 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 \
# 1) Login über die Domain (Caddy → App-Nodes)
TOKEN=$(curl -s -X POST https://<domain>/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
# 2) Health über Caddy (round-robin auf beide Nodes)
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:
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).
## 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
- **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
- **TLS**: erledigt Caddy (Portal automatisch, Custom-Domains On-Demand). Erste
Zertifikatsausstellung dauert ein paar Sekunden nach korrektem DNS.
- **Trusted Proxies**: für korrekte absolute URLs hinter Caddy
`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.
- **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;
# Symfony-Pfade (API + serverseitige öffentliche Seiten)
location ~ ^/(api|p|t|css|bundles|health)(/|$) {
# Symfony-Pfade (API + serverseitige öffentliche Seiten + interne Endpunkte)
location ~ ^/(api|p|t|css|bundles|health|internal)(/|$) {
root /app/public;
try_files $uri /index.php$is_args$args;
}

View File

@ -7,6 +7,7 @@ write_files:
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=${app_secret}
APP_PORTAL_DOMAIN=${domain}
DATABASE_URL="${database_url}"
CORS_ALLOW_ORIGIN=${cors_allow_origin}
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 {
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, ...
db_private_ip = "10.0.1.10"
caddy_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"
# 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" {
@ -117,43 +121,54 @@ resource "hcloud_server" "app" {
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??"]
}
# --- Caddy-Edge (TLS-Terminierung + Reverse-Proxy/Load-Balancing) ---
resource "hcloud_firewall" "caddy" {
name = "vcard4-caddy-fw"
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
ssh_keys = [hcloud_ssh_key.admin.id]
firewall_ids = [hcloud_firewall.caddy.id]
user_data = templatefile("${path.module}/cloud-init-caddy.yaml.tftpl", {
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
ip = local.caddy_private_ip
}
depends_on = [hcloud_network_subnet.subnet, hcloud_server.app]
}

View File

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

View File

@ -14,8 +14,14 @@ 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
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
# 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
db_password = "STARKES_PASSWORT"

View File

@ -57,10 +57,35 @@ variable "repo_branch" {
}
variable "domain" {
description = "Öffentliche Domain (für CORS, Profil-URLs, später TLS)"
description = "Öffentliche Portal-Domain (CORS, Profil-URLs, TLS)"
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" {
description = "Symfony APP_SECRET"
type = string

View File

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