In Teil 1 habe ich einen kleinen VPS gemietet und abgesichert. Jetzt soll er endlich etwas tun: eine Website ausliefern. In diesem Teil geht es um DNS, den Webserver Caddy, automatische TLS-Zertifikate und Security-Header. Und um eine Content-Security-Policy, die mir als Erstes das Theme meiner Seite zerlegt hat.
Voraussetzungen
- Der gehärtete VPS aus Teil 1 mit Debian 13, Firewall
ufw(Ports 22, 80 und 443 offen) - Eine eigene Domain, deren DNS bei einem Anbieter liegt, bei dem man A- und AAAA-Einträge setzen kann (bei mir: Cloudflare)
- Später für den CSP-Teil: Hugo 0.169.0 mit dem Theme PaperMod
In den Beispielen steht example.com für meine Domain, 203.0.113.10 für die IPv4- und 2001:db8::10 für die IPv6-Adresse des Servers.
Schritt 1: DNS – und warum die Wolke grau bleibt
Meine Domain liegt bei Cloudflare. Cloudflare kann Einträge entweder nur als DNS beantworten (graue Wolke, „DNS only“) oder den ganzen Verkehr als Proxy durch die eigenen Server leiten (orange Wolke). Der Proxy ist standardmäßig an. Ich habe ihn bewusst ausgeschaltet:
- Datenschutz: Mit Proxy läuft jeder Seitenaufruf inklusive IP-Adresse über ein US-Unternehmen, das den TLS-Verkehr zudem entschlüsselt. Das müsste ich in der Datenschutzerklärung erklären – und genau solche Abhängigkeiten wollte ich vermeiden.
- Durchschaubarkeit: Ohne Proxy holt sich der Webserver seine Zertifikate selbst, und ich sehe direkt, was zwischen Browser und Server passiert.
- Zukunft: Ich plane einen eigenen Fediverse-Server. Fediverse-Server sprechen direkt miteinander, und Bot-Schutz im Proxy blockt solche Anfragen gern.
Cloudflare beantwortet bei mir also nur noch DNS-Anfragen und sieht keine Besucher.
IPv6-Adresse herausfinden
Für den AAAA-Eintrag brauche ich die IPv6-Adresse des Servers:
ip -6 addr show scope global
Interessant: Die Adresse, die netcup automatisch vergibt, wird aus der MAC-Adresse der virtuellen Netzwerkkarte gebildet (man erkennt das am ff:fe in der Mitte). Sie bleibt stabil, solange die Netzwerkkarte nicht neu angelegt wird. Für den Anfang reicht das völlig.
Die Einträge
| Typ | Name | Ziel | Proxy |
|---|---|---|---|
| A | @ | 203.0.113.10 | DNS only |
| AAAA | @ | 2001:db8::10 | DNS only |
| A | www | 203.0.113.10 | DNS only |
| AAAA | www | 2001:db8::10 | DNS only |
Prüfen, ob alles angekommen ist – direkt beim öffentlichen Resolver gefragt:
dig +short example.com A @1.1.1.1
dig +short example.com AAAA @1.1.1.1
dig +short www.example.com A @1.1.1.1
Kommen hier Adressen von Cloudflare zurück statt der eigenen, ist der Proxy noch an.
Schritt 2: Caddy installieren
Als Webserver nutze ich Caddy. Der große Vorteil: Caddy besorgt und erneuert TLS-Zertifikate von Let’s Encrypt komplett automatisch, die Konfiguration ist kurz und lesbar, und Caddy ist – natürlich – Open Source.
Debian hat zwar ein eigenes Caddy-Paket, das ist aber oft deutlich älter. Ich nehme deshalb das offizielle Repository des Projekts, dann kommen Updates ganz normal über apt:
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install -y caddy
caddy version
Für den ersten Test lege ich ein Webverzeichnis mit einer Mini-Seite an:
sudo mkdir -p /var/www/kabelbruch
echo '<!doctype html><html lang="de"><meta charset="utf-8"><title>Kabelbruch</title><h1>Kabelbruch – hier entsteht was.</h1></html>' | sudo tee /var/www/kabelbruch/index.html
Schritt 3: Das Caddyfile
Die komplette Konfiguration steht in /etc/caddy/Caddyfile:
{
email admin@example.com
}
www.example.com {
header -Server
redir https://example.com{uri} permanent
}
example.com {
root * /var/www/kabelbruch
encode zstd gzip
file_server
header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=()"
Content-Security-Policy "default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
-Server
}
}
Was hier passiert:
emailist die Adresse für Let’s Encrypt. Dorthin kommt nur Post, wenn mit den Zertifikaten etwas nicht stimmt.wwwleitet dauerhaft (301) auf die Domain ohnewwwum. So gibt es die Seite nur unter einer Adresse.encode zstd gzipkomprimiert die Antworten.file_serverliefert die Dateien aus/var/www/kabelbruchaus. Mehr braucht eine statische Seite nicht.
Und die Header im Einzelnen:
| Header | Wirkung |
|---|---|
Strict-Transport-Security (HSTS) | Browser verbinden sich ein Jahr lang nur noch per HTTPS. Bewusst ohne preload – das lässt sich kaum rückgängig machen, also erst mal ohne. |
X-Content-Type-Options | Der Browser rät keine Dateitypen, sondern nimmt, was der Server sagt. |
Referrer-Policy | Andere Seiten erfahren beim Klick auf einen Link nur meine Domain, nicht die genaue Unterseite. |
Permissions-Policy | Kamera, Mikrofon und Standort sind für diese Seite grundsätzlich gesperrt. |
Content-Security-Policy (CSP) | Die Seite darf nur Inhalte von sich selbst laden. Keine fremden Skripte, Schriftarten oder Tracker – und wenn das Theme doch etwas Externes nachladen will, fällt es sofort auf. |
-Server | Der Server verrät nicht, welche Software läuft. |
Noch ein angenehmer Standard: Caddy schreibt von sich aus keine Zugriffsprotokolle. Für den Datenschutz ist das genau richtig.
Bevor eine neue Konfiguration aktiv wird, lasse ich sie immer erst prüfen:
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
Gescheitert: ein Link im Caddyfile
Mein erstes Caddyfile hatte ich aus einem Chatfenster kopiert. Beim Nachsehen stand in Zeile 5 plötzlich so etwas:
[www.example.com](https://www.example.com) {
Der Chat hatte den Hostnamen automatisch in einen Markdown-Link verwandelt – und genau so war er in der Datei gelandet. caddy validate hätte das zum Glück abgefangen, bevor etwas kaputtgeht.
Der eigentliche Spaß kam beim Reparieren: Jeder sed-Befehl, in dem der Hostname vorkam, wurde beim Kopieren wieder zu einem Link. Ich habe die Klammer also mehrfach „repariert“, indem ich sie neu hineinkopiert habe. Gelöst hat es ein Befehl, der den Hostnamen gar nicht erst enthält, sondern nur die überflüssigen Zeichen in Zeile 5 entfernt:
sudo sed -i -e '5s/^\[//' -e '5s/](.*)//' /etc/caddy/Caddyfile
Und dann die nächste Falle: Als ich die Ausgabe von grep zur Kontrolle zurück in den Chat kopiert habe, sah sie wieder kaputt aus – weil der Chat den Hostnamen beim Anzeigen erneut verlinkt hat. Die Datei war längst in Ordnung. Ein Screenshot vom Terminal hat das am Ende geklärt. Ein Test, der den Hostnamen nicht enthält, hätte es auch getan:
grep -c '\[' /etc/caddy/Caddyfile # 0 = keine eckigen Klammern mehr
Learning: Hostnamen in Befehlen, die man aus Chats oder Webseiten kopiert, immer kritisch anschauen. Und im Zweifel einen Screenshot vom Terminal machen statt Text zu kopieren.
Schritt 4: Testen
curl -I https://example.com
curl -I https://www.example.com
Die erste Anfrage liefert HTTP/2 200 mit allen Headern, die zweite eine 301-Weiterleitung. Ein gültiges Zertifikat gab es nach wenigen Sekunden, ohne dass ich irgendetwas dafür tun musste.
Dabei sind mir noch zwei Kleinigkeiten aufgefallen:
1. server: Caddy bei der Weiterleitung. Die Header-Regeln galten anfangs nur für den Hauptblock, nicht für den www-Block. Deshalb steht oben im Caddyfile auch dort ein header -Server.
2. HTTP/3 braucht UDP. In den Antworten steht alt-svc: h3=":443". Caddy bietet also HTTP/3 an, und das läuft über UDP Port 443. Die Firewall aus Teil 1 lässt aber nur TCP durch. Browser fallen dann still auf HTTP/2 zurück, es funktioniert also trotzdem – sauberer ist es aber, UDP auch freizugeben:
sudo ufw allow 443/udp
Und falls caddy validate meckert, die Datei sei „not formatted“: Das ist reine Kosmetik (Leerzeichen statt Tabs). sudo caddy fmt --overwrite /etc/caddy/Caddyfile räumt das auf.
Schritt 5: Mond und Sonne – die CSP schlägt zu
Ein paar Tage später – nun ja, ein paar Stunden – lief die echte Seite: gebaut mit Hugo und dem Theme PaperMod (wie sie dorthin kommt, zeige ich in Teil 3). Und oben neben dem Titel sah ich Mond und Sonne gleichzeitig. Eigentlich zeigt PaperMod nur eines der beiden Symbole, je nachdem, ob gerade das helle oder das dunkle Design aktiv ist. Umschalten ging auch nicht.
Die Browser-Konsole (F12) hat den Grund sofort verraten, viermal in Rot:
Executing inline script violates the following Content Security Policy
directive 'default-src 'self''. Either the 'unsafe-inline' keyword,
a hash ('sha256-…'), or a nonce ('nonce-…') is required to enable
inline execution.
PaperMod hat ein paar kleine Skripte direkt im HTML stehen („Inline-Skripte“), unter anderem für den Hell-Dunkel-Umschalter. Meine strenge CSP erlaubt aber nur Skripte aus eigenen Dateien. Lokal mit hugo server merkt man davon nichts, weil dort keine CSP gesetzt ist – erst auf dem echten Server.
Die bequeme Lösung – und warum ich sie nicht nehme
Am einfachsten wäre script-src 'self' 'unsafe-inline'. Damit wäre aber jedes Inline-Skript erlaubt, also auch eines, das jemand irgendwann einschleust. Das würde einen großen Teil des Schutzes der CSP aushebeln.
Die saubere Lösung: Hashes
Stattdessen erlaube ich genau diese Skripte über ihren SHA-256-Hash. Der Browser berechnet den Hash jedes Inline-Skripts und führt es nur aus, wenn er in der CSP steht. Ein verändertes oder fremdes Skript hat einen anderen Hash und wird weiter blockiert.
Die Hashes stehen zwar in der Fehlermeldung, aber abtippen wollte ich sie nicht: l und I, 0 und O – ein einziges falsches Zeichen, und der Hash ist wertlos. Also lasse ich sie aus einem lokalen Build berechnen. Das Ergebnis ist identisch, solange lokal und auf dem Server dieselbe Hugo-Version baut:
hugo --minify -D -d /tmp/kb-csp
python3 - <<'EOF'
import re, hashlib, base64, pathlib
hashes = set()
pattern = re.compile(r'<script(?![^>]*\bsrc=)(?![^>]*application/ld\+json)[^>]*>(.*?)</script>', re.S | re.I)
for f in pathlib.Path('/tmp/kb-csp').rglob('*.html'):
for m in pattern.finditer(f.read_text(encoding='utf-8')):
digest = hashlib.sha256(m.group(1).encode('utf-8')).digest()
hashes.add("'sha256-" + base64.b64encode(digest).decode() + "'")
print(" ".join(sorted(hashes)))
EOF
-Dbaut auch Entwürfe mit, damit auch Skripte erfasst werden, die nur auf Artikelseiten vorkommen.- JSON-LD-Blöcke (
application/ld+json) lasse ich aus. Das sind Daten für Suchmaschinen, die der Browser nicht ausführt.
Heraus kamen fünf Hashes, darunter die vier aus der Konsole. Die kommen in die CSP:
Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-<HASH_1>' 'sha256-<HASH_2>' 'sha256-<HASH_3>' 'sha256-<HASH_4>' 'sha256-<HASH_5>'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
Vorher sichere ich das Caddyfile, dann validate und reload:
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.bak
# CSP-Zeile anpassen, dann:
sudo caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy
Seite neu geladen (Strg+Shift+R) – und schon war nur noch ein Symbol zu sehen, der Umschalter funktionierte, und die Konsole war leer.
Der Haken: Ändert sich durch ein Update von PaperMod oder Hugo eines dieser Skripte, stimmt sein Hash nicht mehr. Das merkt man an genau demselben Symptom: Mond und Sonne gleichzeitig. Dann einfach das Skript oben noch einmal laufen lassen und die CSP aktualisieren. Das automatisch in der Pipeline zu prüfen, steht auf meiner Liste.
Stolperfallen & Learnings
- Cloudflare-Proxy bewusst entscheiden. Er ist standardmäßig an und hat Folgen für Datenschutz und direkte Server-zu-Server-Verbindungen.
caddy validatevor jedemreload. Das hat mich vor dem Link im Caddyfile bewahrt.- Kopierte Hostnamen prüfen. Chats und Webseiten verwandeln sie gern in Links – auch beim Zurückkopieren der Ausgabe.
- Header gelten pro Block. Der
www-Block braucht sein eigenesheader -Server. - HTTP/3 = UDP 443. In der Firewall freigeben.
- CSP mit Hashes statt
unsafe-inline. Die Hashes berechnen statt abtippen, und nach Theme-Updates neu prüfen.
Fazit & Ausblick
Mit einer Handvoll Zeilen Caddyfile liefert der Server die Seite jetzt per HTTPS aus, mit automatisch erneuerten Zertifikaten, ohne Zugriffsprotokolle und mit Security-Headern, die fremde Inhalte konsequent draußen halten. Die CSP hat mich zwar kurz das Theme gekostet, aber genau dafür ist sie da: Sie hat sofort gezeigt, was auf der Seite eigentlich alles ausgeführt wird.
Was als Nächstes kommt: In Teil 3 baue ich die Pipeline. Ich schreibe Artikel auf meinem Laptop, pushe sie in meinen eigenen Git-Server im Homelab, ein Runner baut die Seite und schiebt sie zum VPS – über einen SSH-Schlüssel, der dort nichts anderes darf, als genau dieses eine Verzeichnis zu beschreiben.