6 Min. Lesezeit

Wie diese Seite gebaut ist

Wer ich bin, warum es diese Seite gibt und wie sie technisch funktioniert, von Astro über nginx bis zum Deployment mit GitHub Actions.

InfrastrukturAstroSelf-Hosting

Ich heiße Luca Stephan Kohls, lebe in Hamburg und verbringe den größten Teil meiner Zeit damit, Software zu bauen. Seit diesem Jahr mache ich das mit eigenem Unternehmen: Mit der Servecta UG (haftungsbeschränkt) habe ich eine Marketingagentur gegründet, deren Schwerpunkt nicht auf Kampagnen liegt, sondern auf der Software, ohne die moderne Kommunikation nicht funktioniert. Websites, Schnittstellen, Automatisierungen, interne Werkzeuge.

Daneben kümmere ich mich seit 2017 ehrenamtlich um Technik bei den Jungen Liberalen. Bis 2026 hatte ich die technische Leitung im Landesverband Schleswig-Holstein, inzwischen betreue ich technische Themen beim Bundesverband. Ehrenamtliche IT ist eine gute Schule. Es gibt wenig Geld, wenig Zeit und niemanden, der nachts Bereitschaft hat. Was dort überlebt, ist fast immer das Einfache.

Mit genau diesem Anspruch ist auch diese Seite entstanden. Sie soll mich vorstellen, Platz für längere Texte bieten und dabei so wenig Wartung brauchen wie möglich. Wie das technisch umgesetzt ist, beschreibe ich in diesem ersten Beitrag etwas ausführlicher.

Statisch, weil nichts dagegen spricht

Eine persönliche Website ändert sich selten. Zwischen zwei Beiträgen liegen Tage oder Wochen, und in dieser Zeit muss der Server nichts anderes tun, als dieselben Dateien immer wieder auszuliefern. Eine Datenbank, ein Admin-Login oder eine PHP-Laufzeit wären hier nur zusätzliche Angriffsfläche, die gepflegt werden will.

Deshalb ist die Seite mit Astro gebaut und wird beim Build vollständig in statisches HTML übersetzt. Astro ist dafür gut geeignet, weil es standardmäßig kein JavaScript an den Browser schickt. Komponenten werden zur Build-Zeit gerendert, übrig bleiben HTML und CSS. Auf dieser Seite gibt es tatsächlich keine einzige Zeile clientseitiges JavaScript.

Blogbeiträge sind Markdown-Dateien im Repository. Astro liest sie über eine sogenannte Content Collection ein und prüft beim Build jeden Beitrag gegen ein Schema:

const blog = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    date: z.coerce.date(),
    draft: z.boolean().default(false),
  }),
});

Fehlt einem Beitrag die Beschreibung oder steht im Datum Unsinn, bricht der Build ab, bevor irgendetwas online geht. Beiträge mit draft: true sehe ich lokal im Entwicklungsmodus, in der veröffentlichten Version tauchen sie nicht auf. Aus denselben Daten entstehen außerdem ein RSS-Feed und eine Sitemap, beides ebenfalls statische Dateien.

Gestaltung ohne Framework

Für das Layout verwende ich kein CSS-Framework. Die gesamte Gestaltung steckt in einer Handvoll CSS-Variablen für Farben, Schriften und Abstände. Der Dunkelmodus ist nichts weiter als ein zweiter Satz dieser Variablen, der über prefers-color-scheme greift. Es gibt keinen Umschalter, die Seite folgt einfach der Einstellung des Systems.

Die Schriften sind Newsreader für den Fließtext und IBM Plex Mono für Daten und Beschriftungen. Newsreader ist eine variable Schrift mit optischer Größenachse: Dieselbe Datei zeichnet Überschriften etwas feiner und kontrastreicher als Fließtext, ohne dass ich dafür mehrere Schnitte laden muss. Beide Schriften werden über Fontsource als Paket installiert und von meinem eigenen Server ausgeliefert. Kein Aufruf an Google Fonts, keine IP-Adresse, die an Dritte geht. Über unicode-range lädt der Browser zudem nur die Zeichensätze, die er tatsächlich braucht, im Normalfall also nur den lateinischen.

Codebeispiele wie das oben werden schon beim Build von Shiki eingefärbt, mit je einem Farbschema für hellen und dunklen Modus. Auch dafür braucht es im Browser kein JavaScript.

Das Ergebnis lässt sich gut messen. Die Startseite besteht aus fünf Anfragen und rund 156 KB, davon entfallen etwa 143 KB auf die beiden Schriftdateien. HTML und CSS sind komprimiert jeweils unter 2 KB groß.

Auslieferung mit nginx hinter Nginx Proxy Manager

Die Seite läuft auf meinem eigenen Server bei Hetzner. Dort betreibe ich Nginx Proxy Manager als zentralen Einstiegspunkt für alle Dienste. Er nimmt die Anfragen auf Port 80 und 443 entgegen, kümmert sich um die Zertifikate von Let’s Encrypt und leitet anhand des Hostnamens an den richtigen Container weiter.

Für diese Seite ist das ein schlanker nginx-Container, der die gebauten Dateien aus /opt/blog/site ausliefert. Er veröffentlicht selbst keinen Port nach außen, sondern hängt nur im Docker-Netzwerk des Proxy Managers:

services:
  blog:
    image: nginx:1.27-alpine
    restart: unless-stopped
    volumes:
      - ./site:/usr/share/nginx/html:ro
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    networks:
      - proxy

networks:
  proxy:
    external: true
    name: ${NPM_NETWORK}

Die Dateien sind schreibgeschützt eingebunden. Selbst wenn jemand den nginx-Prozess kompromittieren würde, könnte er an der Seite nichts verändern.

Die nginx-Konfiguration selbst ist kurz, enthält aber ein paar Details, die sich lohnen. Astro versieht CSS- und Schriftdateien mit einem Hash im Dateinamen. Ändert sich der Inhalt, ändert sich der Name. Diese Dateien darf der Browser deshalb ein Jahr lang cachen, ohne je nachzufragen:

location /_astro/ {
    add_header Cache-Control "public, max-age=31536000, immutable" always;
}

Die HTML-Seiten dagegen werden bei jedem Aufruf frisch ausgeliefert, damit neue Beiträge sofort sichtbar sind. Außerdem steht absolute_redirect off in der Konfiguration. Ruft jemand /blog ohne abschließenden Schrägstrich auf, leitet nginx auf /blog/ weiter. Ohne diese Einstellung würde nginx dabei eine absolute URL mit http:// erzeugen, denn der Container weiß nichts von der verschlüsselten Verbindung, die der Proxy davor entgegengenommen hat. Mit ihr bleibt die Weiterleitung relativ und der Besucher auf HTTPS.

Deployment mit GitHub Actions und rsync

Der Quellcode liegt auf GitHub. Bei jedem Push auf den Hauptzweig startet ein Workflow, der die Abhängigkeiten mit npm ci exakt nach Lockfile installiert, die Seite baut und das Ergebnis per rsync über SSH auf den Server überträgt:

rsync -rlz --delete --delay-updates --chmod=D755,F644 \
  -e "ssh -i ~/.ssh/deploy_key -p $PORT \
      -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes -o BatchMode=yes" \
  dist/ "$USER@$HOST:"

Jede Option hat hier einen Zweck. --delete entfernt Dateien, die es im neuen Build nicht mehr gibt, damit keine verwaisten Seiten auf dem Server liegen bleiben. --delay-updates lädt zunächst alle geänderten Dateien in ein temporäres Verzeichnis und tauscht sie erst am Ende gemeinsam aus. Das Zeitfenster, in dem ein Besucher eine Mischung aus alter und neuer Version erwischen könnte, schrumpft dadurch auf einen Augenblick. --chmod setzt die Dateirechte unabhängig davon, wie sie auf dem Build-Server aussahen, sodass nginx im Container sie lesen kann.

Auf SSH-Seite prüft StrictHostKeyChecking den Server gegen einen hinterlegten Fingerabdruck. Ein untergeschobener Server würde die Verbindung sofort scheitern lassen. BatchMode verhindert, dass SSH jemals nach einem Passwort fragt, und IdentitiesOnly sorgt dafür, dass ausschließlich der vorgesehene Schlüssel verwendet wird.

Der wichtigste Teil liegt aber auf dem Server. Der Deploy-Schlüssel gehört einem eigenen Benutzer ohne weitere Rechte, und in dessen authorized_keys ist er fest an einen einzigen Befehl gebunden:

command="/usr/bin/rrsync /opt/blog/site",restrict ssh-ed25519 AAAA… github-actions-blog

rrsync ist ein kleines Hilfsskript aus dem rsync-Projekt.1 Es lässt nur rsync-Übertragungen zu und beschränkt sie auf das angegebene Verzeichnis. Pfade außerhalb davon werden abgelehnt. restrict schaltet zusätzlich Terminal, Port-Weiterleitung und alle anderen SSH-Funktionen ab. Der Schlüssel kann also genau eine Sache: Dateien in /opt/blog/site schreiben. Eine Shell bekommt er nicht, auf Docker oder andere Dienste hat er keinen Zugriff. Sollte er jemals in falsche Hände geraten, ließe sich damit diese Website überschreiben, aber nicht der Server übernehmen.

Datenschutz als Nebeneffekt

Weil die Seite keine Cookies setzt, keine Analyse-Tools einbindet und keine Inhalte von fremden Servern lädt, braucht sie auch keinen Cookie-Banner. Das war keine nachträgliche Entscheidung, sondern ergibt sich aus dem Aufbau. Was nicht eingebaut ist, muss auch nicht erklärt, abgewogen oder per Einwilligung abgefragt werden.

Was hier künftig erscheint

In diesem Blog werde ich unregelmäßig über Dinge schreiben, die mich bei der Arbeit beschäftigen. Das sind technische Entscheidungen aus Kundenprojekten bei Servecta, Erfahrungen mit Infrastruktur im Ehrenamt und Gedanken zum Aufbau eines eigenen Unternehmens. Lieber seltener und gründlich als regelmäßig und oberflächlich.

Wer mitlesen möchte, kann den RSS-Feed abonnieren. Fragen, Anmerkungen oder Widerspruch nehme ich gern per Mail an post@lucakohls.de entgegen.

Fußnoten

  1. Auf aktuellen Debian- und Ubuntu-Versionen liegt es nach apt install rsync direkt unter /usr/bin/rrsync. Ältere Pakete enthalten es nur als Beispielskript unter /usr/share/doc/rsync/scripts/. ↩

Anmerkungen, Fragen oder Widerspruch?Antworten per Mail