Mein Git-Hosting läuft seit Oktober 2026 auf Forgejo statt auf Gitea. Anlass war ein Sicherheitsvorfall auf der alten Instanz. Diese Notiz beschreibt das Was und Warum: die Entscheidung, die Härtung, den Migrationsweg, das Backup-Design und die Stolperfallen. Betriebsdetails (Hosts, Ports, Pfade) stehen bewusst nicht hier.
Auslöser
Eine ungepatchte Gitea-Version mit offener Registrierung ließ sich über einen Git-Hook-Mechanismus zu Code-Ausführung bringen. Der Angreifer kam an Konfigurationswerte mit Secrets. Die Instanz wurde gepatcht, alle Secrets, Tokens und die in den Repos jemals abgelegten Zugangsdaten wurden rotiert, die Logs ausgewertet (Logins, Bestellungen, Nutzung unauffällig), und der Fall wurde geschlossen.
Lesson: Eine Dienst-Instanz mit offener Registrierung im Internet ist eine Angriffsfläche, kein Komfortmerkmal. Und: Secrets, die einmal in einer Konfiguration lagen, die ein Angreifer lesen konnte, gelten als verbrannt, auch wenn „nichts passiert“ ist.
Warum Forgejo – und warum eine frische Instanz
- Ein In-Place-Upgrade von Gitea (neuer als 1.22) auf Forgejo ist nicht vorgesehen. Der Weg ist: neue Instanz, Repos einzeln herüberschieben.
- Das passte gut: Ich wollte ohnehin mit frischen Secrets und gehärteter Konfiguration von vorn beginnen, statt Altlasten mitzunehmen.
- Forgejo ist community-getrieben, API-kompatibel zu Gitea (Werkzeuge wie ein Gitea-MCP laufen weiter) und hat eine eigene Registry für Container-Images.
Aufbau nach der Migration
| Baustein | Umsetzung |
|---|---|
| SCM | Forgejo als Container, Postgres als eigener Container |
| Reverse Proxy | Nginx Proxy Manager für HTTPS; Git-SSH geht direkt auf einen Host-Port, nicht über den Proxy |
| Container-Registry | Forgejo-Packages (Images baut Komodo) |
| CI/CD | Komodo-Builds und -Procedures, per Webhook bei Push auf main (Infrastruktur) |
| Drone | entfällt, war ohnehin kaum noch im Einsatz |
| Backup | Pull-basiert, siehe unten |
Härtung (Lehren aus dem Vorfall)
- Registrierung deaktiviert, Ansicht nur mit Login (
REQUIRE_SIGNIN_VIEW), Installer gesperrt (INSTALL_LOCK). - Git-Hooks serverseitig abgeschaltet (
DISABLE_GIT_HOOKS). Genau dieser Mechanismus war der Einstieg. - Alle Secrets (Secret-Key, Internal-Token, JWT- und LFS-Secrets, DB-Passwort) frisch erzeugt, nicht übernommen.
- Die interne API (
/api/internal/) wird am Reverse Proxy mit 403 blockiert. Sie wird nur im Container selbst gebraucht, nie von außen. - 2FA für den Admin.
- Image-Version fest gepinnt, Updates bewusst per Release-Hinweis.
- Auf der Startseite zeigt die Registrierungsseite weiter „deaktiviert“ mit Status 200. Das ist normal und kein Hinweis auf eine offene Registrierung. Geprüft habe ich stattdessen an der Konfiguration und am fehlenden Anmeldeformular.
Migration in Schritten
- Inventur aller Verbraucher. Wer spricht mit dem alten Git? Komodo-Repos, -Stacks, -Builds, -Deployments und -Procedures, Git- und Registry-Konten, Runner-Pipelines, Klone auf der NAS, lokale Klone, MCP-Server, Dashboards.
Lesson:
docker psundgrepauf Image-Namen verpassen Container, die wegen eines verschobenen Tags nur noch ihre Image-ID zeigen.docker inspect -f '{{.Config.Image}}' <container>verrät die ursprüngliche Referenz. - Neue Instanz mit frischen Secrets und der Härtung von oben aufsetzen.
- Repos übertragen: pro Repo
git clone --bare, LFS-Objekte mitnehmen,push --allund--tags. Zur Kontrolle die Branch-Listen beider Seiten pergit ls-remote --headsvergleichen. Nicht mit herüber kommen: Webhooks, Deploy-Keys, Packages, Einstellungen. Diese müssen neu angelegt werden. - Images neu bauen statt kopieren. Alle Container-Images baut Komodo aus dem Quellcode neu. Lesson: Nach dem Wechsel der Git-Quelle am Build schob dieser sein Image trotzdem noch in die alte Registry. Die Registry ist ein eigenes Feld am Build („Image Registry“) und muss separat umgestellt werden.
- Webhooks neu anlegen. Forgejo blockiert Webhook-Ziele in internen Adressbereichen, solange sie nicht ausdrücklich erlaubt sind (
ALLOWED_HOST_LIST). - Git-SSH-Port tauschen, damit die vertraute Portnummer erhalten bleibt. Reihenfolge: erst die alte Instanz vom Port nehmen, dann die neue darauf legen, denn zwei Container können nicht denselben Host-Port belegen. Der alte Dienst bleibt für die Übergangszeit nur noch lokal erreichbar.
- Clients umhängen: ein kleines Skript durchsucht alle lokalen Klone nach der alten Remote, schreibt sie um und prüft den Zugriff (Trockenlauf zuerst). Klone mit Zugangsdaten in der URL werden bewusst übersprungen und von Hand behandelt, damit kein Token im Klartext in eine neue URL wandert. Der Port steht in der lokalen SSH-Konfiguration, nicht in jeder Remote.
Backup: bewusst ein Pull
Das NAS soll nicht aus dem Internet erreichbar sein. Also holt das NAS die Daten, statt dass der Server sie dorthin schiebt.
- Auf dem Server liegt ein kleines Export-Skript. Der SSH-Schlüssel des NAS darf nur dieses eine Skript ausführen (
command=undrestrictin den authorized keys). Er kann also lesen, aber nichts verändern. - Das NAS ruft drei Teile ab: den Datenbank-Dump (
pg_dump), das Daten-Volume (ohne Packages, die baut Komodo jederzeit neu) und die Stack-Dateien samt.env. Ohne den Secret-Key aus der.envwären 2FA und Tokens nach einem Restore unlesbar. - Alles landet verschlüsselt in einem restic-Repository (täglich, Aufbewahrung 7 täglich, 4 wöchentlich, 6 monatlich, stichprobenartiger Integritätscheck).
- Die Klartext-Zwischendateien auf dem NAS werden nach jedem Lauf gelöscht.
- Fehlschläge melden sich per ntfy auf dem Handy.
Den Restore habe ich getestet: Forgejo startet auf einem Testgerät mit den Original-Secrets aus dem Backup, der Admin-Login mit 2FA klappt (der eigentliche Beweis, dass Secret-Key und Datenbank zusammenpassen), alle Repos sind da, ein Clone funktioniert. Erst dann zählt ein Backup als Backup.
Stolperfallen beim Aufsetzen:
- Ein Platzhalter wie
<volume>im Skript wird von der Shell als Eingabeumleitung gelesen und endet mit einer irreführenden „No such file“-Meldung. - Skriptdateien mit Rechten
000lassen sich nicht ausführen, auch nicht per Aufgabenplaner. restic dumpkennt bei Einzeldatei-Snapshots den Pfad nur als Wurzel (/datei), nicht als vollen Pfad.- Ein abgebrochener restic-Lauf hinterlässt eine Sperre.
RESTIC_RETRY_LOCKlässt neue Läufe kurz warten, statt sofort zu scheitern. Eine Sperre prüft man, bevor man sie entfernt: läuft der Prozess noch?
Abschaltung der alten Instanz
Erst stoppen, nicht löschen: Container und Volumes bleiben für eine Übergangszeit liegen. Vor dem Stopp wurde ein verschlüsseltes Archiv aus Datenbank, Daten und Stack angelegt und geprüft. Gelöscht wird erst, wenn nichts mehr vermisst wird: Container, Volumes, Proxy-Eintrag, OAuth-Anwendung, alte Komodo-Konten, der alte CI-Deploy-Key.
Lessons Learned in Kürze
- Offene Registrierung plus ein mächtiges Feature wie serverseitige Git-Hooks ist eine gefährliche Kombination. Standardmäßig ausschalten, was man nicht braucht.
- Nach einer Kompromittierung alle Secrets rotieren, auch die in der Repo-Historie.
- Bei einer Migration die Verbraucher inventarisieren, bevor man etwas umzieht, und Container nach ihrer ursprünglichen Image-Referenz fragen.
- Webhooks, Deploy-Keys und Registries wandern bei einem Repo-Umzug nicht mit.
- Backup per Pull, mit einem Schlüssel, der nur ein Skript ausführen darf, und mit einem tatsächlich geübten Restore.
- Altes stoppen statt löschen. Das Löschen kommt, wenn die Übergangszeit ohne Beschwerden verstrichen ist.