Ein Portfolio auf GitHub Pages oder Vercel deployen — das geht in fünf Minuten. Trotzdem habe ich mich für einen eigenen Server entschieden. Nicht weil es einfacher ist, sondern weil das Portfolio nicht nur die Projekte zeigt, die darauf laufen — es ist selbst eines. Server-Administration, Netzwerk-Architektur, CI/CD und Security-Hardening sind Fähigkeiten, die man am besten zeigt, indem man sie anwendet.
Dieser Artikel dokumentiert die Infrastruktur hinter mathis-adler.dev: Wie die Server aufgebaut sind, wie der Traffic fließt und warum bestimmte Entscheidungen so getroffen wurden.
Zwei Server, ein privates Netz
Die Infrastruktur läuft auf zwei Hetzner Cloud Servern in Nürnberg, verbunden durch ein privates Hetzner-Netzwerk (10.0.0.0/16):
┌─────────────────────────────────────┐
│ Hetzner Privat-Netz │
│ 10.0.0.0/16 │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Webserver │ │ VPN-Server │ │
│ │ 10.0.0.2 │ │ 10.0.0.3 │ │
│ │ │ │ │ │
│ │ Nginx │ │ WireGuard │ │
│ │ Astro │ │ dnsmasq │ │
│ │ go-redirect │ │ wireguard-ui│ │
│ │ once │ │ │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────┘
Warum zwei Server? Separation of Concerns. Der Webserver hostet die Anwendungen, der VPN-Server regelt den Zugang. Wenn der VPN-Server kompromittiert wird, hat der Angreifer keinen direkten Zugriff auf die Webanwendungen — und umgekehrt. Außerdem lassen sich beide Server unabhängig voneinander aktualisieren und neustarten.
Das Hetzner-Privatnetz ermöglicht die Kommunikation zwischen den Servern, ohne dass Traffic über das öffentliche Internet geht. Keine zusätzlichen Kosten, keine Verschlüsselung nötig (der Traffic verlässt das Rechenzentrum nicht), minimale Latenz.
VPN: WireGuard mit Split-Tunnel
Der Zugang zur Portfolio-Seite läuft aktuell ausschließlich über WireGuard VPN. Das ist eine bewusste Entscheidung: Solange die Seite in Entwicklung ist, soll sie nicht öffentlich erreichbar sein. Sobald sie fertig ist, wird der VPN-Schutz entfernt — eine Zeile in der Nginx-Config.
Die VPN-Konfiguration nutzt Split-Tunneling : Nur Traffic zu den definierten Subnetzen (10.0.0.0/16 und 172.30.0.0/24) geht durch den Tunnel. Normales Browsen, Streaming, Downloads — alles läuft weiterhin direkt über die normale Internetverbindung. Kein unnötiger Umweg über den VPN-Server, keine reduzierte Bandbreite.
DNS im VPN
Ein Problem bei VPN-geschützten Webservices: Der Browser löst mathis-adler.dev über öffentliches DNS auf und erhält die öffentliche IP 46.225.230.154. Nginx auf dem Webserver erlaubt aber nur Zugriffe aus dem privaten Netz und dem VPN-Subnetz. Die Anfrage wird geblockt.
Lösung: Auf dem VPN-Server läuft dnsmasq als DNS-Resolver, der für mathis-adler.dev und die Subdomains die private IP 10.0.0.2 zurückgibt:
address=/mathis-adler.dev/10.0.0.2
address=/go.mathis-adler.dev/10.0.0.2
address=/once.mathis-adler.dev/10.0.0.2
VPN-Clients nutzen 172.30.0.1 (das WireGuard-Interface des VPN-Servers) als DNS-Server. So wird die Domain im VPN-Kontext korrekt auf die private IP aufgelöst und der Traffic fließt durch das Hetzner-Privatnetz — ohne das öffentliche Internet zu berühren.
Nginx: Mehr als ein Webserver
Nginx fungiert als zentrale Eingangsschicht für alle drei Dienste. Jeder Dienst hat eine eigene Nginx-Site-Konfiguration:
| Domain | Backend | Zugang |
|---|---|---|
mathis-adler.dev | Statische Dateien (/var/www/.../dist/) | VPN-only |
go.mathis-adler.dev | Docker → 127.0.0.1:3001 | Öffentlich (API nur VPN) |
once.mathis-adler.dev | Docker → 127.0.0.1:3002 | Öffentlich (Admin nur VPN) |
SSL mit Let’s Encrypt
Alle drei Domains nutzen Let’s Encrypt Zertifikate, die automatisch per Certbot erneuert werden. HTTP wird auf HTTPS umgeleitet, HSTS ist aktiviert. Kein manuelles Zertifikatsmanagement, keine Kosten.
Zugriffskontrolle
Nginx übernimmt die Zugriffskontrolle auf Netzwerkebene:
# Portfolio: komplett VPN-only
allow 10.0.0.0/16;
allow 172.30.0.0/24;
deny all;
Für go.mathis-adler.dev und once.mathis-adler.dev ist der öffentliche Zugang erlaubt — die Redirect-Links und Reveal-Seiten sollen schließlich ohne VPN erreichbar sein. Aber administrative Endpunkte (/api/ bei go-redirect, /api/admin/ bei once) sind auf VPN-Zugriff beschränkt. So kann kein Außenstehender Kurzlinks erstellen oder Secrets verwalten.
Rate Limiting
Öffentlich erreichbare Dienste brauchen Schutz gegen Missbrauch. Nginx begrenzt die Anfragerate auf zwei Ebenen:
limit_req_zone $binary_remote_addr zone=go_general:10m rate=30r/m;
limit_req_zone $binary_remote_addr zone=go_api:10m rate=10r/m;
30 Requests pro Minute für normale Zugriffe, 10 für API-Endpunkte. Rate Limiting auf Nginx-Ebene statt in der Anwendung hat den Vorteil, dass die Requests gar nicht erst den Application-Server erreichen — weniger Last, schnellere Ablehnung.
Die drei Dienste
Auf dem Webserver laufen drei voneinander unabhängige Dienste:
mathis-adler.dev — Das Portfolio
Eine statische Website, gebaut mit Astro. Kein Server-Side Rendering, kein Backend, kein Framework-JavaScript im Bundle. Nginx liefert die fertigen HTML-, CSS- und JS-Dateien direkt aus /var/www/mathis-adler.dev/dist/ aus. Mehr dazu im Abschnitt Astro-Architektur.
go.mathis-adler.dev — Redirect-Service
Ein selbst gehosteter URL-Shortener mit Node.js, Express und SQLite. Läuft als Docker-Container auf Port 3001. Kurzlinks werden über eine CLI mit API-Key verwaltet — kein Web-Interface, kein Registrierungsformular, kein Abuse-Potenzial. Details dazu im Blogpost über URL-Shortener.
once.mathis-adler.dev — One-Time Secrets
Ein One-Time-Secret-Service mit Ende-zu-Ende-Verschlüsselung. Ebenfalls Node.js, Express, SQLite, Docker-Container auf Port 3002. Der Server sieht nie den Klartext — Verschlüsselung und Entschlüsselung passieren im Browser. Die technischen Details beschreibt der Blogpost über E2E One-Time Secrets.
Beide Services laufen in Docker-Containern, um sie vom Host-System zu isolieren. SQLite-Datenbanken und .env-Dateien liegen außerhalb des Containers auf dem Host und werden per Volume gemountet. So überleben Daten einen Container-Neustart, und Secrets landen nie im Git-Repository.
CI/CD: Push und fertig
Jedes der drei Projekte hat eine GitHub Actions Pipeline , die bei Push auf main automatisch deployt. Der Ablauf ist für alle gleich:
Push auf main
│
├─► Portfolio: npm ci → npm run build → rsync dist/ → fertig
│
├─► go-redirect: rsync Projektdateien → docker compose up -d --build
│
└─► once: rsync Projektdateien → docker compose up -d --build
Alle drei Pipelines nutzen denselben Deploy-Key (Ed25519) und dieselben GitHub Secrets. rsync kopiert nur geänderte Dateien — ein typisches Deployment dauert wenige Sekunden.
Wichtig: Die rsync-Befehle für go-redirect und once exkludieren .env und data/. Umgebungsvariablen (API-Keys, Secrets) werden einmalig auf dem Server konfiguriert und nie über die Pipeline überschrieben. Datenbanken bleiben ebenfalls unberührt.
Falls die Pipeline aus irgendeinem Grund fehlschlägt, funktioniert auch ein manuelles Deployment über zwei Befehle:
npm run build
rsync -avz --delete dist/ root@mathis-adler.dev:/var/www/mathis-adler.dev/dist/
Astro-Architektur
Das Portfolio nutzt Astro 5 mit Static Site Generation — alle Seiten werden zum Build-Zeitpunkt zu HTML kompiliert. Kein Node.js-Prozess auf dem Server, keine Datenbank, kein serverseitiges Rendering. Das minimiert die Angriffsfläche und vereinfacht das Hosting: Nginx liefert statische Dateien aus, fertig.
Content Collections
Blogposts und Projekte werden als MDX-Dateien in Content Collections verwaltet. Astro validiert das Frontmatter jeder Datei mit Zod-Schemas:
const blog = defineCollection({
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
tags: z.array(z.string()).default([]),
glossary: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
Draft-Posts werden beim Build gefiltert und tauchen auf der Seite nicht auf. Typfehler im Frontmatter — falsches Datum, fehlender Titel — werden beim Build erkannt, nicht erst in der Produktion.
Glossar-System
Jeder Blogpost und jedes Projekt kann Glossar-Begriffe im Frontmatter referenzieren. Im Text werden sie mit einer <Term>-Komponente markiert, die den Begriff als klickbare Referenz rendert. Auf dem Desktop erscheint eine Sidebar mit allen Definitionen, auf Mobile ein Dialog über einen Button in der Toolbar.
Das Glossar ist eine einfache TypeScript-Datei mit key-value-Paaren — kein CMS, kein Headless-Backend. Ein neuer Begriff ist eine Zeile Code.
Projektstruktur
src/
├── components/ ContentDetail, GlossarySidebar, Term, WikiQrShowcase
├── content/ MDX-Dateien (blog/ + projects/)
├── data/ Glossar-Einträge (glossary.ts)
├── layouts/ Base.astro (Nav, Toolbar, Theme-Init)
├── pages/ 7 Routen (index, blog, projekte, kontakt, qr-generator)
├── scripts/ toolbar, mobile-menu, color-picker, scroll-reveal
├── styles/ 7 CSS-Module (variables, base, nav, components, toolbar, prose, animations)
└── utils/ Collection-Helpers (getPublishedPosts, getPublishedProjects)
Blog- und Projekt-Detailseiten teilen sich ContentDetail.astro — die jeweiligen Slug-Dateien sind nur ~15 Zeilen lang. DRY, ohne Abstraktion um der Abstraktion willen.
Security-Entscheidungen im Überblick
Sicherheit ist kein Feature, das man am Ende draufschraubt. Sie zieht sich durch jede Ebene der Architektur:
- Netzwerk: Zwei getrennte Server, privates Netz, VPN für Admin-Zugang
- Transport: TLS überall, HSTS, automatische Zertifikatserneuerung
- Zugriff: Nginx-ACLs nach IP-Bereichen, API-Endpunkte nur über VPN
- SSH: Password-Auth deaktiviert, fail2ban, MaxStartups gehärtet — Details im Blogpost über SSH-Brute-Force
- Rate Limiting: Auf Nginx-Ebene, bevor Requests die Anwendung erreichen
- Anwendung: Content Security Policy , kein Inline-JavaScript, kein Tracking
- Deployment: Deploy-Key mit minimalen Rechten, Secrets nie im Repository
- Daten: SQLite-Datenbanken und
.env-Dateien außerhalb der Container, vom Deployment exkludiert
Keine dieser Maßnahmen allein macht das System sicher. Zusammen bilden sie eine Defense-in-Depth-Strategie, bei der jede Schicht die Schwächen der anderen kompensiert.
Fazit
Die Infrastruktur hinter diesem Portfolio ist bewusst einfach gehalten — zwei Server, ein VPN, ein Reverse Proxy, drei Dienste. Keine Kubernetes-Cluster, keine Microservice-Architektur, kein Over-Engineering. Die Komplexität liegt in den Details: Wie der DNS im VPN funktioniert, warum Rate Limiting auf Nginx-Ebene statt in der Anwendung läuft, warum .env-Dateien vom Deployment exkludiert werden.
Das ist kein Setup für eine Anwendung mit tausenden Nutzern. Es ist ein Setup für ein Portfolio, das zeigt, wie ich Infrastruktur-Entscheidungen treffe — und dass ich sie auch umsetzen kann.