Nach Teil 3 war meine Seite technisch fertig: Ein git push, und zehn Sekunden später ist ein Artikel online. Fehlten nur noch die Dinge, die man gern bis zum Schluss aufschiebt. Eine Website in Deutschland braucht ein Impressum und eine Datenschutzerklärung, und beide brauchen eine Kontaktadresse, die auch funktioniert. Und im Browser-Tab hing statt eines Symbols eine leere Fläche – die Konsole beschwerte sich über fehlende Favicons.
In diesem letzten Teil der Serie geht es um genau diese Dinge. Und um einen Mailserver, den es nicht mehr gab, an den mein DNS aber weiterhin brav alle E-Mails geschickt hat.
Voraussetzungen
- Ein eigener Mailserver – bei mir mailcow, eine Open-Source-Mailserver-Suite auf Docker-Basis, die schon länger für eine andere Domain läuft
- Zugriff auf das DNS der Domain (bei mir Cloudflare)
- Das Hugo-Projekt aus Teil 3
Beispielwerte: example.com ist die Blog-Domain, mail.example.net mein mailcow-Server und ich@example.net mein persönliches Postfach.
Teil A: Eine Kontaktadresse, die wirklich ankommt
Die Entscheidung
Für die Kontaktadresse gab es drei Möglichkeiten: meine normale Adresse, eine Weiterleitung auf meiner anderen Domain oder eine Adresse auf der Blog-Domain selbst. Ich habe mich für die dritte entschieden: kontakt@example.com. Sie passt zur Seite, verrät nichts über meine anderen Domains, und ich kann sie jederzeit umleiten, ohne das Impressum zu ändern.
Ein eigenes Postfach braucht es dafür nicht. Ein Alias (eine Weiterleitungsadresse) auf mein bestehendes Postfach genügt.
Die Domain in mailcow anlegen
In mailcow: E-Mail → Konfiguration → Domains → Domain hinzufügen. Beschreibung, Limits – für einen Alias spielen die Standardwerte keine Rolle. Neuere mailcow-Versionen erzeugen dabei gleich den DKIM-Schlüssel mit (Selektor dkim, 2048 Bit). Die Relay-Optionen bleiben leer.
Danach zeigt mailcow bei jeder Domain über den Button „DNS“ eine Übersicht: welche DNS-Einträge es erwartet und was im DNS tatsächlich steht.
Gescheitert, aber rechtzeitig gemerkt: der Geister-Mailserver
Diese Übersicht hat mich kurz stutzen lassen. Die Spalte „Current State“ war nämlich nicht leer:
| Eintrag | mailcow erwartet | Tatsächlich im DNS |
|---|---|---|
| MX | mail.example.net | mail.example.com |
| autodiscover / autoconfig | mail.example.net | mail.example.com |
| SPF | – | v=spf1 mx ip4:198.51.100.7 -all |
Für meine Blog-Domain gab es also schon Mail-Einträge – und die zeigten auf einen alten Mailserver, den es nicht mehr gab. Das heißt: Jede E-Mail an eine Adresse dieser Domain lief ins Leere.
Wäre der alte Server noch in Betrieb gewesen, hätte ich jetzt sehr vorsichtig sein müssen: erst alle Adressen in mailcow anlegen, alte Mails übernehmen (mailcow kann das per Sync-Job direkt vom alten Server), dann erst umstellen. So konnte ich direkt aufräumen.
Learning: Vor jeder DNS-Änderung erst nachsehen, was jetzt drinsteht. Die DNS-Übersicht von mailcow ist dafür ideal, dig tut es auch:
dig +short MX example.com @1.1.1.1
dig +short TXT example.com @1.1.1.1
Aufräumen bei Cloudflare
In der Cloudflare-Übersicht fanden sich noch mehr Reste. Hier die Liste, Zeile für Zeile:
Gelöscht: die A- und AAAA-Einträge für mail – sie zeigten auf den alten Server.
Geändert:
| Eintrag | neu |
|---|---|
MX @ | mail.example.net, Priorität 10 |
CNAME autoconfig | mail.example.net |
CNAME autodiscover | mail.example.net |
TXT @ (SPF) | v=spf1 mx -all |
TXT www (SPF) | v=spf1 -all |
TXT * (SPF) | v=spf1 -all |
Neu angelegt:
| Typ | Name | Inhalt |
|---|---|---|
| TXT | _dmarc | v=DMARC1; p=quarantine; adkim=s; aspf=s |
| TXT | dkim._domainkey | der DKIM-Schlüssel aus mailcow (v=DKIM1;k=rsa;…) |
| SRV | _autodiscover._tcp | Priorität 0, Gewicht 1, Port 443, Ziel mail.example.net |
Alle Einträge stehen auf DNS only, also ohne Cloudflare-Proxy. Zwei weitere Altlasten eines früheren Domain-Anbieters (ein Eintrag für dessen Konfigurationsdienst und eine alte Domain-Verifizierung) habe ich stehen lassen – sie stören nicht.
SPF, DKIM und DMARC in drei Sätzen
Diese drei Einträge sorgen dafür, dass niemand so leicht E-Mails im Namen meiner Domain fälschen kann:
- SPF (Sender Policy Framework) legt fest, welche Server Mails für die Domain verschicken dürfen.
v=spf1 mx -allheißt: nur der Server aus dem MX-Eintrag, alle anderen werden abgelehnt. Fürwwwund alle anderen Subdomains (*) stehtv=spf1 -all– von dort kommt nie eine Mail, also darf auch niemand so tun. - DKIM (DomainKeys Identified Mail) signiert jede ausgehende Mail. Den öffentlichen Schlüssel zum Prüfen veröffentlicht der
dkim._domainkey-Eintrag. - DMARC sagt anderen Mailservern, was sie tun sollen, wenn SPF oder DKIM nicht passen.
p=quarantineheißt: in den Spam damit.adkim=sundaspf=sverlangen eine exakte Übereinstimmung der Domain. Weil meine Domain ausschließlich über mailcow mit DKIM verschickt, kann ich von Anfang an streng sein.
Ein Tipp zum DKIM-Schlüssel: nicht aus einem Screenshot abtippen. Der Schlüssel ist eine lange Zeichenkette, in der l und I fröhlich durcheinandergehen. In mailcow steht er unter Konfiguration → ARC/DKIM-Schlüssel als markierbarer Text.
Nach ein paar Minuten zeigte die DNS-Übersicht von mailcow überall grüne Haken – auch beim DKIM-Schlüssel. Damit ist auch geprüft, dass er exakt stimmt.
Der Alias
In mailcow: E-Mail → Konfiguration → Aliasse → Alias hinzufügen:
- Alias-Adresse:
kontakt@example.com - Ziel:
ich@example.net - „Allow to send as this alias“ ✓ – damit kann ich auch als
kontakt@antworten, sauber mit DKIM signiert - „Internal“ leer lassen – sonst könnte mir von außen niemand schreiben
Dazu habe ich gleich noch postmaster@example.com auf dasselbe Ziel angelegt. Eine postmaster-Adresse sollte jede Maildomain haben, dorthin schreiben andere Mailserver bei Problemen.
Der Test: eine Mail von einem fremden Anbieter an kontakt@example.com – nach wenigen Sekunden war sie in meinem Postfach.
Teil B: Impressum
Brauche ich das als privater Blog?
Kurz: ja. Ich bin kein Anwalt, aber die Lage ist ziemlich klar. Ein Blog, der für die Öffentlichkeit gedacht ist, braucht nach § 18 Abs. 1 Medienstaatsvertrag (MStV) mindestens Name und Anschrift. Sobald er „geschäftsmäßig“ betrieben wird – und dafür reichen schon Werbebanner oder Affiliate-Links –, gelten zusätzlich die Pflichten aus § 5 Digitale-Dienste-Gesetz (DDG), dem Nachfolger des alten Telemediengesetzes. Dann braucht es unter anderem einen schnellen elektronischen Kontaktweg und einen zweiten Weg zur Kontaktaufnahme.
Die Anschrift muss ladungsfähig sein, also eine Adresse, an die Behörden Schriftstücke zustellen können. Ein Postfach reicht dafür nicht.
Meine Entscheidungen
- Adresse: Ich habe meine Privatanschrift genommen. Die Alternative wäre ein Impressum-Service, der eine eigene Adresse stellt. Das schützt die Wohnadresse, ist rechtlich aber nicht ganz unumstritten. Für mich war die sichere Variante wichtiger.
- Aufbau: Werbung gibt es hier nicht, Affiliate-Links schließe ich für später aber nicht aus. Deshalb ist das Impressum gleich so aufgebaut, dass es auch nach § 5 DDG passt. Eine Telefonnummer als zweiter Kontaktweg kommt dazu, falls es so weit ist.
- Kontakt: die neue Adresse
kontakt@example.com.
In Hugo ist das Impressum einfach eine Markdown-Seite, content/impressum.md:
---
title: "Impressum"
url: "/impressum/"
ShowReadingTime: false
ShowToc: false
ShowBreadCrumbs: false
ShowPostNavLinks: false
---
## Angaben gemäß § 5 DDG und § 18 Abs. 1 MStV
Vorname Nachname\
Straße Hausnummer\
PLZ Ort\
Deutschland
## Kontakt
E-Mail: kontakt@example.com
## Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV
Vorname Nachname, Anschrift wie oben
Der Backslash am Zeilenende erzwingt in Markdown einen Zeilenumbruch, ohne dass ein neuer Absatz entsteht – praktisch für Adressen. Dazu kommt bei mir noch ein kurzer Absatz zur Haftung für externe Links.
Teil C: Eine Datenschutzerklärung, die stimmt
Erst die Technik, dann der Text
Mein Grundsatz für die Datenschutzerklärung: Es steht nur drin, was technisch auch so ist. Klingt selbstverständlich, ist es aber nicht – viele Vorlagen versprechen Dinge, die der eigene Server gar nicht tut. Oder sie verschweigen Dinge, die er sehr wohl tut.
Bei mir ist die Lage dank der Entscheidungen aus den vorherigen Teilen übersichtlich:
- Keine Cookies, kein Tracking, keine externen Inhalte. Das erzwingt die Content-Security-Policy aus Teil 2 sogar technisch.
- Keine Zugriffsprotokolle – Caddy schreibt sie standardmäßig nicht.
- Kein Cloudflare-Proxy – Cloudflare beantwortet nur DNS-Anfragen.
Ein Punkt war aber offen: Auch ohne Zugriffsprotokolle schreibt das System Logs, in denen IP-Adressen stehen können – Fehlermeldungen des Webservers, abgewiesene Verbindungen an der Firewall, SSH-Anmeldeversuche. Wie lange werden die aufbewahrt? Bei Debian standardmäßig, bis das Journal eine bestimmte Größe erreicht. Das ist keine Frist, die man in eine Datenschutzerklärung schreiben kann.
Also habe ich eine festgelegt:
sudo mkdir -p /etc/systemd/journald.conf.d
sudo tee /etc/systemd/journald.conf.d/10-retention.conf > /dev/null <<'EOF'
[Journal]
MaxRetentionSec=14day
EOF
sudo systemctl restart systemd-journald
Und geprüft, dass es keine klassischen Logdateien daneben gibt, für die das nicht gelten würde:
ls -la /var/log/syslog /var/log/auth.log /var/log/ufw.log 2>&1
dpkg -l rsyslog 2>/dev/null | grep ^ii
Bei Debian 13 gibt es standardmäßig kein rsyslog mehr – alles landet im Journal, und die 14 Tage gelten für alles. Jetzt kann in der Datenschutzerklärung stehen: „wird nach 14 Tagen automatisch gelöscht“. Und es stimmt.
Was drinsteht
Meine Datenschutzerklärung (content/datenschutz.md) ist in der Du-Form geschrieben, passend zum Rest des Blogs, und gliedert sich so:
- Verantwortlicher – Name, Anschrift, Kontaktadresse
- Hosting – der VPS-Anbieter mit Anschrift, Rechenzentrum in Deutschland, Vertrag zur Auftragsverarbeitung
- Aufruf der Website – welche Daten der Browser technisch überträgt, keine Zugriffsprotokolle, Systemprotokolle mit 14 Tagen Aufbewahrung, Rechtsgrundlage Art. 6 Abs. 1 lit. f DSGVO
- Verschlüsselung – TLS
- Speicherung im Browser – PaperMod merkt sich die Wahl zwischen hellem und dunklem Design im Local Storage des Browsers (Eintrag
pref-theme). Der Wert bleibt auf dem Gerät. Weil er für eine ausdrücklich gewünschte Funktion nötig ist, braucht es keine Einwilligung (§ 25 Abs. 2 Nr. 2 TDDDG). - Kontakt per E-Mail – was mit Mails passiert und wo der Mailserver steht
- Deine Rechte – Auskunft, Berichtigung, Löschung usw. und die zuständige Datenschutz-Aufsichtsbehörde
- Stand
Ein Satz am Anfang fasst das Wichtigste zusammen: keine Cookies, kein Tracking, keine Inhalte von Drittanbietern. Wer nur den liest, weiß schon fast alles.
Wichtig: Kommt später etwas dazu – Affiliate-Links, ein Fediverse-Server, ein Kommentarsystem –, muss die Datenschutzerklärung mitwachsen.
Ins Menü
Beide Seiten müssen von überall leicht erreichbar sein. Bei PaperMod kommen sie einfach ins Hauptmenü, in die hugo.toml:
[[menu.main]]
identifier = "impressum"
name = "Impressum"
url = "/impressum/"
weight = 90
[[menu.main]]
identifier = "datenschutz"
name = "Datenschutz"
url = "/datenschutz/"
weight = 91
Teil D: Ein Favicon – das K mit dem Funken
Zum Schluss das Kleinste, aber Sichtbarste: das Symbol im Browser-Tab. Die Konsole hatte sich schon länger beschwert:
GET https://example.com/favicon.ico 404 (Not Found)
GET https://example.com/favicon-32x32.png 404 (Not Found)
GET https://example.com/favicon-16x16.png 404 (Not Found)
Drei Entwürfe
Für das Favicon gab es drei Entwürfe, alle in den Farben der Seite – dunkler Grund, helles Kabel, ein Funke in Bernstein:
- A: ein waagerechtes Kabel mit Bruchstelle, blankem Kupfer an den Enden und einem Funken dazwischen
- B: dieselbe Idee, diagonal
- C: ein „K“, dessen Bein gebrochen ist, mit dem Funken in der Lücke
![]()
A und B erzählen die Geschichte des Namens schöner. Aber ein Favicon wird in 16 × 16 Pixeln angezeigt, und da bleibt von einem Kabel mit Bruchstelle nur ein Strich mit einem orangen Fleck. Das K blieb selbst in dieser Größe eindeutig lesbar. Also wurde es C. Der wichtigste Test bei Favicons ist deshalb: die kleinste Größe ansehen, nicht die größte.
Die Dateien
PaperMod verlinkt im <head> standardmäßig diese Dateien, die einfach in den Ordner static/ kommen:
| Datei | Wofür |
|---|---|
favicon.ico | Klassisches Favicon, enthält 16, 32 und 48 Pixel |
favicon-16x16.png, favicon-32x32.png | Browser-Tabs |
apple-touch-icon.png | 180 Pixel, wenn jemand die Seite auf den Homescreen von iPhone oder iPad legt |
safari-pinned-tab.svg | einfarbige Version für angeheftete Tabs in Safari |
Als Grundlage dient eine einzige SVG-Datei. Daraus lassen sich alle Größen mit zwei Python-Bibliotheken erzeugen – CairoSVG zum Rendern und Pillow für die ICO-Datei:
import io, cairosvg
from PIL import Image
def render(svg, size):
png = cairosvg.svg2png(url=svg, output_width=size, output_height=size)
return Image.open(io.BytesIO(png)).convert("RGBA")
render("favicon.svg", 16).save("static/favicon-16x16.png")
render("favicon.svg", 32).save("static/favicon-32x32.png")
render("favicon-apple.svg", 180).convert("RGB").save("static/apple-touch-icon.png")
render("favicon.svg", 48).save("static/favicon.ico", sizes=[(16, 16), (32, 32), (48, 48)])
Für das Apple-Icon nehme ich eine Variante ohne abgerundete Ecken – iOS rundet selbst ab, sonst gibt es doppelte Rundungen. Und die Safari-Version ist einfarbig schwarz ohne Hintergrund, die Farbe setzt Safari selbst.
Nach dem Push waren die 404-Meldungen weg, und im Tab leuchtet seitdem das K mit dem Funken. Manche Browser halten ein altes Favicon übrigens hartnäckig im Cache – ein privates Fenster hilft beim Testen.
Nebenbei: languageCode ist veraltet
Bei jedem Build meldete Hugo zuletzt:
WARN deprecated: project config key languageCode was deprecated in Hugo v0.158.0 [...] Use locale instead.
Die Lösung ist eine Zeile in der hugo.toml:
locale = "de-DE" # statt: languageCode = "de-de"
PaperMod liest die Sprache für RSS und OpenGraph inzwischen ohnehin aus locale. Die Warnung war damit weg, sonst hat sich nichts geändert.
Stolperfallen & Learnings
- Vor DNS-Änderungen den Ist-Zustand ansehen. Bei mir zeigte der MX auf einen Server, den es nicht mehr gab.
- Einen alten Mailserver erst ablösen, wenn alle Adressen und Mails umgezogen sind – falls er noch läuft.
- SPF auch für Subdomains setzen, von denen nie Mail kommt:
v=spf1 -all. - DKIM-Schlüssel als Text kopieren, nicht abtippen.
- Impressum: ladungsfähige Anschrift, kein Postfach. Mit Werbung oder Affiliate-Links gelten die strengeren DDG-Pflichten.
- Die Datenschutzerklärung beschreibt die Technik – nicht umgekehrt. Erst die Log-Aufbewahrung festlegen, dann die Frist hineinschreiben.
- Favicons in 16 Pixeln beurteilen.
Fazit der Serie
Vier Teile, ein Abend – zugegeben, ein langer –, und aus einer Idee ist eine Website geworden, die ich verstehe und gern betreibe:
- ein kleiner, gehärteter VPS, auf dem sich niemand mit Passwort oder als root anmelden kann,
- Caddy mit automatischem HTTPS und Security-Headern, die fremde Inhalte konsequent draußen halten,
- eine Pipeline, die aus jedem
git pushin zehn Sekunden eine neue Seite macht – mit einem Runner im isolierten Netz und einem Schlüssel, der nur rsync darf, - eine Kontaktadresse mit sauberem SPF, DKIM und DMARC,
- Impressum und Datenschutzerklärung, die zur Technik passen,
- und ein K mit Funken im Browser-Tab.
Alles mit Open-Source-Software, alles selbst betrieben. Und, ganz im Sinne des Mottos: mit einer ganzen Reihe von Fehlern unterwegs, aus denen ich mehr gelernt habe als aus den Schritten, die auf Anhieb geklappt haben.
Was als Nächstes kommt: Auf meiner Liste stehen eine automatische Prüfung der Content-Security-Policy in der Pipeline, damit mich das nächste Theme-Update nicht wieder mit Mond und Sonne überrascht, und ein Backup-Konzept für den VPS. Und die Idee, die ich in Teil 1 angedeutet habe: ein eigener kleiner Fediverse-Server. Aber das ist eine andere Geschichte.