Zurück zu den Artikeln
Self-HostingDegoogleDockerTraefik

Ein VPS, sieben Dienste, ein Repo — mein Self-Hosting-Setup

12. August 2026

Diesen Beitrag habe ich zum Großteil von KI schreiben lassen. Dies ermöglicht es mir, ältere Projekte aufzuarbeiten und zu dokumentieren.

Kurzfassung: Ein einzelner Server bei einem deutschen Anbieter, 14 Container, sieben Dienste hinter einem Reverse Proxy. Mail, Fotos, Analytics, VPN, Monitoring, Backups — alles als Docker Compose, alles in einem Git-Repository, alles mit dokumentierter Begründung.

Das hier ist der Einstieg in eine Serie. Die einzelnen Dienste haben eigene Artikel; hier geht es um das, was sie gemeinsam haben — und darum, was so ein Setup tatsächlich kostet, nicht in Euro, sondern in Aufmerksamkeit.

1. Warum ein Server statt fünf Abos

Der Auslöser war nicht Ideologie, sondern eine Beobachtung beim Google-Export: Ich wusste nicht, wie viele Fotos ich habe. Ein Dienst, der alles automatisch schluckt, nimmt einem auch den Überblick darüber ab, was er schluckt.

Dazu kommt das Übliche — Preise, die still steigen, Funktionen, die verschwinden, und die Tatsache, dass ein gesperrtes Konto fünfzehn Jahre Daten mitnimmt.

Der ehrliche Grund darüber hinaus: Ich wollte verstehen, wie diese Dinge funktionieren. Ein gehosteter Dienst nimmt einem genau dieses Verständnis ab. Das ist meistens ein Vorteil und manchmal einer zu viel.

Was Self-Hosting nicht ist: billiger, bequemer oder sicherer per se. Es verlagert Vertrauen und Aufwand, es beseitigt sie nicht. Wer das nicht will, ist mit einem guten Anbieter besser bedient — und das ist keine Kapitulation, sondern eine Risikoabwägung.


2. Was läuft

Ein VPS mit Debian 13, 14 Container in sieben Diensten:

DienstWofürersetzt
TraefikReverse Proxy, TLS von Let's Encrypt
StalwartMail, Kalender, KontakteGmail, Google Kalender
ImmichFotos und VideosGoogle Photos
UmamiWeb-AnalyticsGoogle Analytics
wg-easyWireGuard-VPN als Exit-Nodekommerzielle VPN-Abos
Uptime KumaMonitoring aller Dienste
eigene AppBeispiel: eine Anwendung hinter dem Proxy

Dazu restic als gemeinsame Backup-Schicht — keine Container, nur Skripte und ein Cron.

Nicht alles davon ist gleich anspruchsvoll. Immich läuft seit März ohne einen einzigen Zwischenfall. Der Mailserver hat mich mehrere Wochen beschäftigt.


3. Eine Kante nach außen

Die wichtigste Entscheidung des ganzen Setups, und sie ist in einem Satz gesagt:

Kein Dienst publisht eigene Ports. Alles geht über Traefik.

Alle Container hängen in einem gemeinsamen Docker-Netzwerk und melden sich per Compose-Label beim Proxy an. Nach außen offen sind nur Traefik selbst — 80, 443 und die Mailports — und der WireGuard-Port.

Warum das mehr ist als Ordnungsliebe: Die Angriffsfläche wird an genau einer Stelle definiert, nämlich in Traefiks Entrypoint-Liste. Was dort nicht steht, ist von außen nicht erreichbar — unabhängig davon, was ein Container intern anbietet. Mein Mailserver hat serverseitig mehr Dienste laufen, als von außen ansprechbar sind. Das ist kein Widerspruch, das ist der Punkt.

Zwei Ausnahmen, beide begründet:

WireGuard läuft bewusst am Proxy vorbei. Es bringt seine eigene Verschlüsselung mit, braucht weder TLS-Terminierung noch die Weitergabe der Client-IP — die sieht es nativ. UDP durch einen HTTP-Proxy zwingen zu wollen wäre Aufwand ohne Gegenwert. Nur die Verwaltungsoberfläche läuft normal über Traefik.

Die Mailports laufen zwar über Traefik, aber als TCP-Durchreichung statt HTTP — mit PROXY Protocol, damit der Mailserver die echte Absender-IP sieht. Ohne das laufen SPF-Prüfung, Rate-Limiting und Auto-Banning gegen die Proxy-Adresse statt gegen den Absender.

Dass dieses Prinzip nicht selbstverständlich ist, habe ich beim Aufräumen gemerkt: Zwei Dienste publishten noch Ports aus einer früheren Phase — darunter eine Postgres-Datenbank, die damit offen im Internet stand. Beide Einträge waren Überbleibsel ohne Funktion.

Dazu ein Detail, das viele übersehen: Docker schreibt seine Weiterleitungen direkt in iptables und umgeht ufw dabei. Eine Firewallregel schützt also nicht vor einem versehentlich publizierten Container-Port. Wirksam ist nur die Bindung in der Compose-Datei.


4. Das Repo ist die Wahrheit

Das gesamte Setup liegt in einem Git-Repository. Die Ordner darin entsprechen 1:1 den Verzeichnissen auf dem Server — /opt ist selbst ein Checkout dieses Repos.

Der Grund ist nicht Bequemlichkeit, sondern Drift-Erkennung. Weicht die Konfiguration auf dem Server von der dokumentierten ab, zeigt git diff das.

Genau dieser Fall war der Auslöser: Ein Backup-Skript hatte auf dem Server einen anderen Stand als in der Dokumentation, weil Skripte bis dahin per Copy-and-Paste über SSH gepflegt wurden. Aufgefallen ist es zufällig bei einem Audit — nach Wochen.

Wichtig dabei: Zugangsdaten liegen außerhalb dieses Checkouts, in einem Verzeichnis mit anderer Berechtigungsstufe. Das Passwort des Backup-Repositorys ist der alleinige Schlüssel zu allen Sicherungen; es hat in keinem Git-Verzeichnis etwas verloren. Im Repo stehen nur Beispieldateien mit Platzhaltern.

Und die Dokumentation liegt neben dem Code, nicht anderswo. Jeder Ordner hat ein README, das nicht nur beschreibt, was konfiguriert ist, sondern warum — inklusive der Fehldiagnosen, die zu der Konfiguration geführt haben. Zusammen rund 22.000 Wörter, mehr als die Artikel dieser Serie.

Das klingt nach viel für einen einzelnen Server. Es ist aber genau der Teil, den man in sechs Monaten braucht, wenn man wissen will, warum eine Zeile so aussieht, wie sie aussieht.


5. Fünf Regeln, die für jeden Dienst gelten

Über die Zeit haben sich Muster herausgebildet, die für alle Dienste gleich sind. Sie klingen banal, und jede einzelne davon geht auf einen konkreten Fehler zurück.

Versionen festnageln, nicht latest. Ein beiläufiges docker compose pull soll keine Hauptversion mit Datenbankmigrationen einspielen. Bei einem Dienst zeigt latest sogar auf eine ältere Version als der Versions-Tag — wer das nicht weiß, installiert ahnungslos die vorletzte.

Für jede .env eine .env.example im Repo. Damit ist dokumentiert, welche Variablen es gibt, ohne dass je ein echter Wert in Git landet.

Keine $-Zeichen in generierten Passwörtern. Docker Compose interpoliert sie in .env-Dateien — aus einem Passwort wird stillschweigend eine undefinierte Variable. Eine halbe Stunde Fehlersuche für ein Zeichen.

Vor einem eigenen Healthcheck ins Image schauen. Die meisten bringen einen mit. Ein selbst geschriebener überschreibt ihn — und benutzt dann womöglich ein Werkzeug, das im Container gar nicht existiert. Genau so passiert.

Nichts als erledigt markieren, was nicht einmal manuell gelaufen ist. Gilt für jeden Cron-Job. Ein Skript, das nur nachts unbeaufsichtigt startet, hat seinen ersten echten Test im ungünstigsten Moment.


6. Backups: die Guard-Bedingung ist der wichtigste Teil

Vier Dienste sichern in ein gemeinsames verschlüsseltes Repository bei einem Anbieter in einem anderen Land — geografisch getrennt vom Server, denn eine Kopie im selben Rechenzentrum ist keine Sicherung.

Alle vier Skripte folgen derselben Struktur. Der entscheidende Teil ist nicht der Backup-Befehl, sondern die Bedingung davor:

DienstPrüft vor dem Sichern
Fotosliegt ein Datenbank-Dump jünger als 24 Stunden vor?
Mailist der Datenbank-Dump wirklich erzeugt worden und nicht leer?
Dateienist der vorgeschaltete Sync sauber durchgelaufen?
Monitoringläuft der Container nach dem Stop wieder, und ist die Datenbank in der Kopie?

Ohne diese Bedingungen sichert man wochenlang unbrauchbare Daten — einen leeren Dump, eine veraltete Datenbank, einen abgebrochenen Sync. Ein stilles, halb kaputtes Backup ist schlimmer als gar keins, weil man sich in Sicherheit wiegt.

Wie subtil das sein kann, zeigt ein Fehler, den ich erst beim Schreiben dieser Serie gefunden habe: Beim Mailserver prüfte die Bedingung, ob die Dump-Datei nicht leer ist. Schlägt der Dump aber fehl, erzeugt die nachgeschaltete Komprimierung trotzdem eine 20 Byte große, formal gültige Datei. Die Prüfung ließ sie durch, das Backup lief, die Überwachung meldete Erfolg. Der Wächter, der stille Fehler verhindern sollte, war selbst einer.

Dazu ein Dead-Man's-Switch: Jedes Skript meldet sich nach erfolgreichem Lauf bei einem externen Dienst. Bleibt die Meldung aus, kommt eine Benachrichtigung. Das Prinzip ist wichtig — er meldet Abwesenheit, nicht Fehler. Deshalb funktioniert er auch dann, wenn der Server komplett weg ist und kein Skript mehr laufen kann, um einen Fehler zu melden.

Und der Punkt, den man nicht überspringen darf: Jeder Backup-Pfad hat inzwischen einen real durchgespielten Restore. Beim Testen kam ein Fehler heraus, der im Ernstfall unter Zeitdruck aufgefallen wäre. Ein ungetestetes Backup ist eine Vermutung.


7. Monitoring in zwei Schichten

Lange bestand die gesamte Überwachung aus den Backup-Pings. Die beantworten genau eine Frage — lief ein Cron — und das frühestens nach 24 Stunden.

Zwei Vorfälle haben gezeigt, dass das nicht reicht. Ein DNS-Eintrag stand zwei Wochen lang falsch, ohne dass es jemand bemerkte. Und bei einer Selbstaussperrung war die Verwaltungsoberfläche des Mailservers komplett unerreichbar, während alle Backups fröhlich weiter grün meldeten.

Jetzt gibt es zwei Schichten:

  • Außen der externe Dead-Man's-Switch: „lebt die Kiste überhaupt“.
  • Innen ein Monitoring-Dienst auf dem Server: „welcher Dienst genau hat ein Problem“ — Erreichbarkeit, Zertifikatslaufzeiten, und DNS-Einträge.

Warum beide nötig sind: Ein Monitor auf der überwachten Maschine kann ihren Ausfall nicht melden. Stirbt der Server, stirbt der Monitor mit. Das ist keine Schwäche der Software, sondern Logik.

Der Trick, der die Lücke schließt, ohne einen dritten Dienst zu brauchen: Der innere Monitor meldet sich selbst regelmäßig beim äußeren Dead-Man's-Switch. Bleibt das aus, ist entweder der Monitor oder der ganze Server weg — und die äußere Schicht sagt es.

Zwei Dinge, die dabei zählen: Der Alarmkanal darf nicht am eigenen Mailserver hängen — ist der das Problem, kommt die Alarm-Mail nie an. Und der Kanal selbst wird bewusst nicht selbst gehostet, denn er soll genau dann funktionieren, wenn der eigene Server es nicht tut.


8. Was es wirklich kostet

Geld: ein VPS-Tarif, Blockspeicher für die Fotos, ein Backup-Kontingent, eine Domain. Zusammen deutlich weniger als die Abos, die es ersetzt. Das ist der uninteressante Teil.

Zeit: Der Aufbau war über Monate verteilt. Der Mailserver allein hat mehrere Wochen gebraucht — nicht durchgehend, aber mit Cutover, Beobachtungsphase und mehreren Fehlersuchen. Immich war ein Nachmittag plus ein zweiter für das Backup.

Aufmerksamkeit — und das ist die eigentliche Währung. Updates alle paar Wochen. DNS-Einträge stichprobenartig prüfen, weil eine Automatik veröffentlichte Werte nicht laufend abgleicht. Backups sind nur dann welche, wenn man den Restore gelegentlich wiederholt.

Der ehrlichste Satz, den ich zu diesem Setup schreiben kann: Ein einziger Audit-Tag brachte einen API-Schlüssel in der Git-Historie, einen Zertifikats-Reload, der nie lief, eine Datenbank offen im Internet und den kaputten Backup-Wächter von oben ans Licht. Alles davon lief still, teils monatelang.

Self-Hosting bedeutet nicht, dass nichts kaputtgeht. Es bedeutet, dass man selbst merken muss, wenn etwas kaputt ist. Backups und Monitoring sind deshalb keine Ergänzung zum Betrieb, sondern seine Voraussetzung — ein Dienst ohne beides ist ein Datenverlust mit Vorlaufzeit.


9. Die Serie

Fünf Artikel, die zusammen eine Geschichte erzählen — erst die Daten zurückholen und ordnen, dann selbst betreiben, dann absichern:

  1. Mein Johnny-Decimal-System für Dateien und Ordner — der Google-Export und was man damit anfängt. Kein Server, aber der Anfang.
  2. Johnny Decimal trifft Obsidian — Ordner oder Metadaten, und warum die Frage falsch gestellt ist.
  3. Immich Fotobackup einrichten — Google Photos ersetzen, inklusive getestetem Restore.
  4. Stalwart Mail Server einrichten — der aufwendigste Teil. Mail, Kalender und Kontakte selbst betreiben.
  5. WireGuard VPN mit wg-easy einrichten — eigener Exit-Node, und ein Fehler, der den Tunnel funktionierend aussehen lässt.

Wer nur einen lesen will: Der Mailserver-Artikel enthält das meiste Übertragbare, weil dort am meisten schiefgegangen ist.


Quellen