Blog
Einen eigenen Git-Server aufsetzen?
Was dabei zu beachten ist
Auf meiner Festplatte liegen Projekte aus zwanzig Jahren. Als RAR-Archiv, als ZIP mit Datum im Namen, als Ordner, den jemand vor dem Rechnerwechsel gesichert hat.
Manches davon ist Kundenarbeit, und genau die soll nicht bei einem beliebigen Anbieter liegen. Der Rest sind eigene Werkzeuge, die niemanden etwas angehen.
Ein eigener Git-Server löst beides. Dieser Beitrag beschreibt, wie ich dabei vorgegangen bin und wo ich unterwegs in Fallen getreten bin.
Warum überhaupt selbst hosten?
Der naheliegende Weg wäre ein privates Repository bei einem der großen Anbieter. Für eigene Basteleien ist das auch völlig in Ordnung.
Bei Kundencode sieht es anders aus. Wem gehört der Code, wer darf ihn sehen, und was steht dazu im Vertrag? Diese Fragen beantwortet man nicht dadurch, dass man auf ein Häkchen bei “privat” zeigt. Ein Server im eigenen Netz macht die Antwort einfach.
Dazu kommt ein praktischer Punkt: Ein Archiv soll in zehn Jahren noch lesbar sein. Ein Git-Repository ist dafür ein gutes Format, weil es aus sich heraus funktioniert und keinen laufenden Dienst braucht.
Warum Forgejo und nicht GitLab?
Forgejo ist ein Hard-Fork von Gitea, getragen von Codeberg e.V. und unter GPLv3. Es läuft als eine einzige Binary mit SQLite, auf meinem lokalen Linux-Server, der rund um die Uhr läuft, belegt der Container rund 106 MB Arbeitsspeicher.
GitLab kann deutlich mehr, und an Ressourcen wäre es auf der Maschine auch nicht gescheitert. Drei Punkte haben trotzdem dagegen gesprochen, und der erste ist der wichtigste.
Das Backup lässt die Konfiguration weg. In der GitLab-Dokumentation steht der Satz wörtlich: “The backup Rake task GitLab provides does not store your configuration files.” Wer nur das Backup hat, aber nicht /etc/gitlab/gitlab-secrets.json, bekommt die verschlüsselten Datenbankfelder beim Wiederherstellen nicht zurück. Das merkt man in dem Moment, in dem es zu spät ist (Quelle: GitLab-Dokumentation, Back up GitLab).
Bei Forgejo erzeugt forgejo dump ein Archiv mit Repositories, Datenbank und Konfiguration in einem Stück.
Der Upgrade-Pfad ist bei GitLab vorgeschrieben. Man kann nicht beliebig weit springen, sondern muss über bestimmte Zwischenversionen. Bei einem Archiv, das man monatelang nicht anfasst, heißt das: erst drei Upgrade-Stufen fahren, dann eine Datei ansehen.
Und der dritte Punkt: Hinter Forgejo steht kein Unternehmen, sondern ein Verein, Codeberg e.V. Es gibt keine kostenpflichtige Enterprise-Version, in die einzelne Funktionen irgendwann abwandern könnten. Für etwas, das zwanzig Jahre halten soll, finde ich das beruhigend.
Organisationen sind die Gliederung, nicht Projects
Ein Begriff, über den ich gestolpert bin: Was in Forgejo “Projects” heißt, ist etwas anderes, als der Name vermuten lässt. Die Dokumentation sagt es in einem Satz: “A project is a kanban board to organize issues.” Die Gliederung, die man für Kunden oder Themen sucht, sind dagegen Organisationen (Quelle: Forgejo-Dokumentation, Projects). Eine Organisation je Kunde, darunter beliebig viele Repositories, mit eigener Sichtbarkeit.
Das ist schnell eingerichtet und trägt danach die ganze Ablage.
Drei Fallen beim Import
Der eigentliche Aufwand lag nicht am Server, sondern beim Hineinholen der alten Stände. Drei Dinge hätten dabei stillschweigend Daten verschluckt.
git push --all überträgt nicht alle Zweige
Ein Archiv enthielt ein vollständiges .git, also die echte Historie statt einer Momentaufnahme. Darin lagen 38 Zweige.
Lokal ausgecheckt waren davon fünf. Die übrigen 33 existierten nur als refs/remotes/origin/*, weil sie damals nie lokal angelegt wurden. git push --all schiebt genau die nicht mit, denn es überträgt refs/heads.
Wer also ein altes Repository umzieht und --all für vollständig hält, verliert jeden Zweig, den der damalige Entwickler nie ausgecheckt hat. Feature-Zweige mit halbfertiger Arbeit sind genau solche Kandidaten.
Der Weg dahin ist, vor dem Push für jeden origin/X einen lokalen Kopf zu setzen. Und dabei lohnt ein zweiter Blick: In demselben Repository lag der lokale master 55 Commits hinter seinem Gegenstück auf dem Server. Die Arbeitskopie war beim Archivieren schlicht nicht aktuell.
git bundle verify beweist nicht, dass man klonen kann
Für die Sicherung erzeugt mein Skript je Repository ein git bundle. Das ist eine einzelne Datei, aus der sich direkt klonen lässt, und sie braucht in zehn Jahren nichts außer git.
Dass ein Bundle heil ist, prüft git bundle verify. Genau daran hätte ich mich beinahe gewöhnt.
Der Test, der den Fehler gefunden hat, war ein echter Klon. Ergebnis: leeres Arbeitsverzeichnis, null Commits. Die Meldung lautete remote HEAD refers to nonexistent ref, unable to checkout.
Die Ursache ist unspektakulär und trifft trotzdem jeden, der bare Repositories sichert: git bundle create --all nimmt HEAD nicht mit. Zeigt HEAD auf einen Zweig, den es nicht gibt, ist das Bundle formal gültig und praktisch wertlos. Richtig ist --all HEAD, und zwar nur dann, wenn HEAD auflösbar ist.
Die Lehre daraus ist größer als der eine Befehl: Eine Integritätsprüfung ist keine Brauchbarkeitsprüfung. Ein Backup gilt erst als geprüft, wenn daraus wiederhergestellt wurde.
Ein .gitignore-Muster mit Schrägstrich ist verankert
Beim Sichern einer alten Arbeitskopie wollte ich Build-Ausgaben weglassen und schrieb bin/Debug/ in die .gitignore.
Getroffen hat das nichts. Ein Muster, das innerhalb einen Schrägstrich enthält, gilt relativ zum Wurzelverzeichnis des Repositories. bin/Debug/ trifft also /bin/Debug/, aber nicht PowerShell/bin/Debug/.
Aufgefallen ist es nur, weil die Zahl im geplanten Commit nicht zur Datenmenge passte: 337 Dateien bei 249 MB. Mit den verzeichnisweiten Formen bin/ und obj/ waren es 166 Dateien und 113 MB. Die Gegenprobe dafür heißt git check-ignore -v <pfad>, sie sagt, ob und welche Regel greift.
Dazu gehört die zweite Hälfte derselben Sache: Auf bereits versionierte Dateien wirkt .gitignore gar nicht. In demselben Repository lagen rund tausend Build-Dateien seit 2017 im Index und kamen weiter mit.
Die Falle, die mich am meisten gekostet hat
Diese hat mit git nichts zu tun, und sie dürfte jeden treffen, der mehrere Dienste auf einer Maschine betreibt.
Auf meinem Server laufen mehrere Anwendungen unter demselben Hostnamen, jede auf einem eigenen Port. Nach der Installation quittierte Forgejo jeden Anmeldeversuch mit einem internen Serverfehler.
Im Protokoll stand:
RegenerateSession: regenerate session: invalid 'sid': eyJfZnJlc2giOmZhbHNlLCJjc3JmX3Rva2VuIjoi... 131 != 16
Der Wert ist base64-kodiertes JSON mit den Feldern _fresh und csrf_token. Das ist die Handschrift einer Flask-Anwendung, also von Python, nicht von Forgejo, das in Go geschrieben ist.
Die Erklärung steht in RFC 6265, Abschnitt 8.5, und sie ist eindeutig: “Cookies do not provide isolation by port. If a cookie is readable by a service running on one port, the cookie is also readable by a service running on another port of the same server.” Alle Dienste unter demselben Hostnamen teilen sich also einen Namensraum, egal auf welchem Port sie lauschen. Eine andere Anwendung auf der Maschine setzte ein Cookie namens session, der Browser schickte es brav mit, und Forgejo las es als eigene Sitzungskennung.
Belegt habe ich es mit einer Gegenprobe, die scheitern konnte: derselbe Anmeldevorgang ohne das fremde Cookie lieferte HTTP 303, also die Weiterleitung nach erfolgreicher Anmeldung. Mit dem Cookie kam HTTP 500. Nach dem Setzen eines eigenen Cookie-Namens lieferten beide Fälle 303.
Der Fehler geht in beide Richtungen. Forgejos eigenes Cookie hätte die Anmeldung der anderen Anwendung überschrieben.
Wer mehrere Dienste unter einem Hostnamen betreibt, sollte also jedem einen eindeutigen Cookie-Namen geben. Bei Forgejo ist das FORGEJO__session__COOKIE_NAME.
Zwei Kleinigkeiten, die trotzdem zählen
Der Standardzweig wird alphabetisch gewählt. Nach dem ersten Push stand bei mir ein Zweig als Standard, den ein Kollege vor Jahren nach sich selbst benannt hatte. Forgejo nimmt den alphabetisch ersten, wenn nichts anderes gesetzt ist. Das kostet einen Aufruf und ist danach richtig.
Das Commit-Datum gehört dem Archiv, nicht dem Importtag. Wer einen alten Stand als Momentaufnahme einspielt, sollte GIT_AUTHOR_DATE und GIT_COMMITTER_DATE auf das Datum des Archivs setzen. Sonst behauptet die Historie, der Code sei dieses Jahr entstanden. Und bei einer nachgetragenen älteren Datei nimmt das Autor-Datum den Zeitpunkt der Entstehung, das Commit-Datum den der Archivierung. Genau dafür hat git die zwei Felder.
Was ich daraus gelernt habe
Der Server war an einem Abend eingerichtet. Die Zeit ging für die Fragen drauf, die man sich vorher nicht stellt.
Die wichtigste davon: Woher weiß ich, dass die Sicherung trägt? Ein Prüfbefehl, der “gültig” meldet, beantwortet sie nicht. Bei mir hat erst der Versuch, aus dem Backup tatsächlich zu klonen, den Fehler gezeigt.
Das gilt über git hinaus. Jede Sicherung, aus der noch nie etwas zurückgeholt wurde, ist eine Vermutung. Wer eine anlegt, sollte einmal den umgekehrten Weg gehen, bevor er sich darauf verlässt.
Quellen: Forgejo · Forgejo-Dokumentation zur Konfiguration · GitLab-Dokumentation, Back up GitLab · RFC 6265, Abschnitt 8.5 zu Cookies und Ports · git-bundle · gitignore-Format