
🌐 Auch auf: English · Français · Español
Mir steht eine Migration bevor, um die ich schon eine Weile herumschleiche. Eine Nextcloud-Instanz, die ich betreue, läuft noch als ganz normale native Installation – Pakete, Webserver, PHP-FPM, alles von Hand gepflegt auf einer Kiste. Sie zieht auf den K3s-Stack um, den ich in Ansible aufgebaut habe.
Der ehrliche Grund für den Umzug: Die native Installation ist in die Jahre gekommen. Sie funktioniert noch, hat aber ein Jahrzehnt kleiner Entscheidungen angesammelt, die niemand aufgeschrieben hat, und jedes Update ist ein Ereignis statt Routine. Was ich mir von der K3s-Seite erhoffe, ist eine langweiligere Art von Upgrade: die Nextcloud-Version und jede Container-Version im Ansible-Code festgepinnt, sodass ein Update zu einem geänderten Tag in einer Datei wird, den ich reviewen, zweimal identisch ausrollen und zurückdrehen kann, wenn es schiefgeht. Anwendung und Daten landen an wirklich getrennten Orten, statt in einem einzigen Verzeichnisbaum ineinander verwoben zu sein. Und Collabora wird ein eigener Container, der über eine definierte Schnittstelle mit Nextcloud spricht, statt in dieselbe Installation verdrahtet zu sein – eine Sache weniger, die kaputtgeht, weil sich zwei Anwendungen eine Laufzeitumgebung teilen.
Nichts davon ist geraten, und das ist der Hauptgrund, warum ich mich darauf einlasse. Dieser Blog läuft schon eine ganze Weile auf demselben K3s-Stack, und Updates sind genau so langweilig geworden, wie ich gehofft hatte: Container-Tag ändern, Playbook erneut laufen lassen, fertig. Weil das Playbook idempotent ist, ist ein erneuter Lauf ein Nicht-Ereignis – es ist dieselbe Operation, egal ob der Server frisch, halb konfiguriert oder schon korrekt ist, und das nimmt einem die meisten Gründe, vor dem Start zu zögern. Alles andere, was dieser Blog unterwegs angesammelt hat – die Härtung, das Monitoring, Ingress und Zertifikatsverwaltung, das Performance-Tuning, die kleinen Korrekturen, die jeweils einen Abend gekostet haben –, ist Code, den ich einfach auf die Nextcloud-Seite richten kann. Genau das ist diese Migration eigentlich: das, was mir das Projekt mit wenig Fallhöhe beigebracht hat, in das Projekt zu investieren, auf das es ankommt.
Bevor ich das einer Instanz mit echten Nutzern antue, habe ich das Ganze zuerst komplett auf einem Wegwerf-Server durchgespielt: Daten wiederherstellen, Datenbank wiederherstellen, den Versionspfad abgehen, Dinge kaputt machen, herausfinden warum. Sechsunddreißig Dateien und elfhundert geänderte Zeilen später ist der Ansible-Code spürbar besser als zu Beginn.
Alles davon liegt auf GitHub – Playbooks, Rollen, Härtung, Verifikation. Besonders die Nextcloud-Seite hat zuletzt viel Aufmerksamkeit bekommen.
Worüber ich eigentlich schreiben will, sind aber nicht die Fixes. Sondern die Gestalt der Probleme.
🎭 Alles ist gescheitert, indem es sich als etwas anderes ausgab
Kein einziges dieser Probleme hat sich angekündigt. Jedes kam verkleidet als ein anderes, plausibleres Problem daher – meist eines, das mich an völlig der falschen Stelle hätte suchen lassen.
Eine Paketinstallation scheiterte mit einem Rechtefehler, der nichts mit Rechten zu tun hatte. chmod: Operation not permitted, als root, mit allen Capabilities, auf einem ganz normal gemounteten Dateisystem. Ich habe alles geprüft, worauf die Meldung zeigte, und alles war in Ordnung. Die eigentliche Ursache war eine systemd-Härtungsdirektive am SSH-Daemon – RestrictSUIDSGID=yes –, deren seccomp-Filter an jeden Prozess vererbt wird, der von sshd abstammt. Also an jede SSH-Sitzung und jeden Befehl, den Ansible darüber ausführt.
Das ist die Lektion, die man behalten sollte: Härtung an sshd ist keine Härtung auf Dienstebene. sshd ist eine Prozessfabrik für interaktive Sitzungen – was du dort einschränkst, schränkst du für jeden Menschen und jedes Tool ein, das durch diese Tür kommt. Eine Zeit lang hatte ich ernsthaft den Hoster im Verdacht, irgendetwas dichtgemacht zu haben.
Ein Playbook-Lauf „hing“ beim Firewall-Schritt. Kein Fehler, kein Timeout, minutenlang einfach Stille. Die Rolle stellt SSH auf einen Nicht-Standard-Port um und aktiviert nftables – und zwar auf genau der Verbindung, die sie gerade umkonfiguriert. Das neue Regelwerk akzeptiert bestehende Verbindungen, was vollständig klingt – nur hat conntrack beim allerersten Start von nftables keinen Eintrag für eine Verbindung, die älter ist als es selbst. Die Sitzung passte also auf gar keine Regel, und ihre Pakete wurden einfach verworfen. Nicht abgelehnt – verworfen. Für Ansible sieht das exakt so aus wie ein Task, der eben lange dauert.
Eine Seite war komplett unerreichbar, und der Browser sagte nichts Brauchbares. Dieser Ingress-Controller beantwortet einen Hostnamen, dessen TLS-Zertifikat noch nicht bereit ist, indem er den Handshake rundweg verweigert – gar kein Zertifikat, nicht mal ein selbstsigniertes. Von außen ist das nicht davon zu unterscheiden, dass der Server down ist. Die wahre Ursache lag drei Schichten entfernt: Eine Image-Allowlist blockierte den Challenge-Pod von cert-manager, sodass das Zertifikat nie ausgestellt werden konnte.
Und mein Liebling: ein Brute-Force-Schutz, der nichts schützte. Die fail2ban-Jails für Web-Logins lesen das Ingress-Log über einen Pfad, der die ID des Pods enthält. fail2ban löst diesen Pfad einmal auf, beim Start. Jedes Mal, wenn der Ingress-Pod neu erstellt wird – Image-Update, Helm-Upgrade, Reboot –, laufen die Jails gegen einen Pfad weiter, den es nicht mehr gibt:
Status for the jail: nginx-k3s-nc-login
|- Filter
| |- Currently failed: 0
| `- File list:Eine leere Dateiliste. Aktiviert, laufend und strukturell unfähig, irgendwen zu sperren. Der vorhandene Monitoring-Check fragte nur, ob der fail2ban-Prozess lebte – und das tat er bestens.
🩹 Was dabei herauskam
Die meisten Fixes sind unspektakulär, sobald man weiß, was das Problem war – so läuft das meistens:
- Die Umstellung von SSH und Firewall passiert auf einem frischen Server jetzt über einen einzigen Reboot, sodass beide gemeinsam aus einem sauberen Boot hochkommen, statt unter einer laufenden Verbindung geändert zu werden.
- Jeder Hostname ohne echtes Zertifikat bekommt vorerst einen kurzlebigen selbstsignierten Platzhalter, sodass eine Browserwarnung an die Stelle eines toten Sockets tritt. Eine Warnung sagt dir, dass der Server lebt und das Zertifikat das Problem ist; ein toter Socket sagt dir gar nichts.
- Zwei neue Monitoring-Checks: einer für Zertifikate kurz vor dem Ablauf, einer, der prüft, ob die fail2ban-Jails tatsächlich eine Datei beobachten, statt bloß zu laufen.
- Dazu die langweilige Hälfte – ein Mail-Absender, der aus der falschen Hälfte einer Adresse zusammengebaut wurde (was Grafana beim Start komplett lahmlegte), eine Verifikationsrolle, die das ganze Play mit einem Parser-Fehler abbrach, und zwar genau dann, wenn etwas kaputt war, und Security-Header, die zweimal mit zwei verschiedenen Werten gesetzt wurden.
⬆️ Was du wissen solltest, bevor du ein Wartungsfenster planst
Nextcloud weigert sich, Major-Versionen zu überspringen. Eine wiederhergestellte Instanz von 32 auf 34 zu bringen heißt, dazwischen bei 33 haltzumachen, mit einem Upgrade-Schritt an jeder Station.
Zwei Details, die die Doku nicht betont: Der Container führt beim Start sein eigenes Upgrade aus und nimmt keinen Traffic an, bis es fertig ist – ein manuell gestartetes Upgrade liefert sich also nur ein Wettrennen mit dem, das schon läuft. Image-Tag ändern und warten. Und prüf die App-Liste nach jeder Major-Version, nicht einmal am Ende. Manche Apps deaktivieren sich vorübergehend und kommen zurück; andere sind wirklich inkompatibel und bleiben aus. Eine in meinem Bestand wird komplett entfernt statt aktualisiert, weil sie per Direktdownload statt über den App Store installiert wurde.
🧱 Was ich bewusst nicht geändert habe
Eine Weile war ich versucht, das Upgrade von AlmaLinux 9 auf 10 im selben Fenster zu erledigen. Neuer Server, frischer Start, da kann man doch gleich mit der aktuellen Major-Version anfangen, oder?
Ich habe es mir ausgeredet, und ich glaube, die Begründung lässt sich verallgemeinern. Diese Migration ändert ohnehin schon viel auf einmal: das Deployment-Modell (Bare Metal zu K3s), die Nextcloud-Version, den Betrieb von Collabora und die Maschine unter alldem. Ein OS-Major-Sprung bedeutet eine zweite, völlig unabhängige Variable. Wenn sich dann zwei Wochen später etwas seltsam verhält – eine Performance-Merkwürdigkeit, ein subtiles Rechteproblem, irgendwas mit SELinux –, hätte ich keine Chance zu sagen, welche der beiden Änderungen schuld ist. Debugging funktioniert, indem man Dinge konstant hält, und eine Migration ist genau der Moment, in dem einem die wenigsten Konstanten bleiben.
Außerdem drängt keine Uhr. AlmaLinux 9 wird bis 2032 unterstützt. Das ist keine Deadline, um die ich herumplanen muss.
Der interessantere Grund ist aber der, der erst beim Aufbau dieses Stacks sichtbar wurde. In einem Container-Setup hat das Host-Betriebssystem viel weniger Gewicht als früher. Alles, was tatsächlich bestimmt, wie sich der Dienst verhält – die Nextcloud-Version, PHP, der Webserver, Collabora –, steckt in Container-Images, deren Versionen ich im Ansible-Code pinne. Der Host liefert einen Kernel, systemd, K3s und die Härtung drumherum. Die interessanten Entscheidungen sind eine Schicht nach oben gewandert, und was darunter liegt, ist vergleichsweise austauschbar geworden.
Eine etwas seltsame Erkenntnis: Einer der Vorteile dieser Migration ist, dass sie die nächste Migration unwichtiger macht. Das OS-Upgrade wird zu einer separaten, langweiligen Übung, die ich zu meinen eigenen Bedingungen erledigen kann, an einem ruhigen Abend, ohne dass sonst etwas in der Luft hängt – genau so, wie ein OS-Upgrade sein sollte.
🔒 Das eine, was die Generalprobe nicht lösen konnte
Ein Problem konnte ich auf dem Testserver überhaupt nicht umgehen: gültige Let’s-Encrypt-Zertifikate zu bekommen.
Der Grund ist eher struktureller als technischer Natur. Die HTTP-01-Challenge funktioniert so, dass Let’s Encrypt die Domain auflöst und ein Token per HTTP abholt – von der Adresse, auf die der öffentliche A-Record zeigt. Und das ist immer noch der alte Server, bis zu dem Moment, in dem ich den DNS umstelle. Lokal habe ich DNS mit einem /etc/hosts-Eintrag auf die neue Maschine gefaked, was reicht, um meinen eigenen Browser zu überzeugen, für Let’s Encrypt aber exakt gar nichts bringt – das macht seinen eigenen Lookup gegen das öffentliche DNS. Der neue Server kann also kein gültiges Zertifikat für einen Hostnamen haben, für den er offiziell noch gar nicht zuständig ist.
Dafür gibt es eine saubere Lösung: Die DNS-01-Challenge beweist die Kontrolle über die Domain, indem ein TXT-Record unter _acme-challenge.<domain> veröffentlicht wird, statt eine Datei auszuliefern. Ihr ist egal, wohin der A-Record zeigt, sodass Zertifikate vorab ausgestellt werden und am ersten Tag bereitliegen könnten. Dafür braucht es einen DNS-Anbieter mit einer API, die der ACME-Client ansteuern kann, und das zu verdrahten geht über das hinaus, was ich mir für diese Migration aufladen will.
Ich nehme die Lücke also in Kauf: Ein paar Minuten nach der DNS-Umstellung ist der neue Server erreichbar, ohne schon ein gültiges Zertifikat zu haben. Genau für diese Situation wurde das Platzhalter-Zertifikat von weiter oben gebaut – statt eines verweigerten Handshakes, der wie ein Ausfall aussieht, bekommen Besucher eine Browserwarnung, die immerhin sagt: Der Server ist da, und das Zertifikat ist noch nicht so weit. Nicht elegant. Aber eine bekannte, begrenzte, sichtbare Lücke schlägt eine unsichtbare – was mehr oder weniger das Thema von allem oben ist.
📅 Wie es weitergeht
Die echte Migration findet in den nächsten Tagen statt.
Der Plan für den Tag selbst ist bewusst unspektakulär:
- Ein, zwei Tage vorher: die DNS-TTL senken, damit sich die Umstellung später in Minuten statt Stunden verbreitet.
- Alten Server in den Wartungsmodus. Ab hier ändert sich nichts mehr unter mir, und kein Client kann mehr in die Instanz schreiben, die ich gleich kopiere.
- Letztes Backup des alten Servers – Daten und Datenbank –, bevor ich irgendetwas anfasse.
- Letzter Sync auf den neuen Server, um die Lücke seit der Kopie für die Generalprobe zu schließen.
- DNS umstellen – A und AAAA. Den IPv6-Record zu vergessen ist eine hervorragende Methode, die Hälfte deiner Nutzer auf der alten Kiste zu lassen, während vom eigenen Laptop aus alles bestens aussieht.
- Auf die Zertifikate warten, die erst jetzt ausgestellt werden können (siehe oben).
- Testen: Login, Desktop-Sync in beide Richtungen, Freigaben noch intakt, Collabora öffnet ein Dokument, ausgehende Mails.
- Den alten Server genau da lassen, wo er ist, im Wartungsmodus, als Rollback.
Diese Reihenfolge ist wichtiger, als sie aussieht. Weil der alte Server in den Wartungsmodus geht, statt abgeschaltet oder plattgemacht zu werden, steht er vollständig und unverändert da – Daten, Datenbank, Konfiguration, alles exakt so wie in dem Moment, in dem ich ihn angehalten habe. Falls sich der neue Stack aus irgendeinem Grund, den ich nicht vorhergesehen habe, als Desaster entpuppt, besteht der Rückweg darin, die DNS-Records wieder auf die alte Maschine zu richten und den Wartungsmodus aufzuheben. Kein Restore, kein Datenverlust, nichts wieder zusammenzusetzen, weil nie etwas auseinandergenommen wurde. Der alte Server wird am Migrationstag nicht stillgelegt; er ist mein Rollback und bleibt es, bis sich der neue eine Weile bewährt hat.
Der eine Teil, den ich wirklich nicht vorhersagen kann, ist die DNS-Propagation. Die TTL sagt Resolvern, wie lange sie einen Record cachen dürfen, und sie ein, zwei Tage vorher zu senken verkürzt den Nachlauf erheblich – aber sie ist eine Obergrenze, kein Versprechen. Viele Resolver runden auf, manche ignorieren sie, und ein paar Clients halten an einer Adresse fest, bis irgendetwas neu startet. In der Praxis gibt es also ein Zeitfenster, in dem manche Nutzer den neuen Server erreichen und manche noch auf dem alten landen. Gerade für einen Datei-Sync-Dienst ist das unangenehm, weil Schreibzugriffe auf beiden Seiten landen könnten. Das ist der Hauptgrund, warum die alte Instanz im Wartungsmodus bleibt, statt einfach weiterzulaufen: Wer noch nicht umgestellt ist, sieht eine Wartungsseite, was nervig, aber harmlos ist, statt erfolgreich eine Datei auf einen Server hochzuladen, den niemand mehr beachtet.
Ich würde gern sagen, dank der Generalprobe wird alles glattlaufen, und ich hoffe es auch. Aber ehrlich gesagt ist eine Generalprobe auf einem Wegwerf-Server mit kopierten Daten nicht dasselbe wie die Live-Instanz mit echten Nutzern, echten Freigaben und echten Sync-Clients, die eine Meinung dazu haben, was sich geändert hat, während sie nicht hingeschaut haben. Irgendwas wird kommen. Kommt immer.
Also: Ich berichte hier danach – was tatsächlich schiefging, was die Generalprobe nicht vorhergesehen hat und was ich ändern musste. Und was auch immer das sein wird, wandert direkt zurück in den Ansible-Code im Repo, denn ein Playbook, das nur auf dem Server funktioniert, an dem man es getestet hat, ist nicht fertig.
Wenn der nächste Beitrag kurz und langweilig ist, ist das der gute Ausgang.
Das Muster darunter
Rückblickend gibt es eine Sache, die all das verbindet.
Nichts davon waren Fehler. Es waren Abwesenheiten – ein Jail ohne Datei, ein Zertifikat, das nie ausgestellt wurde, eine Verbindung, die nicht mehr antwortete, statt abzulehnen, ein Filter, der einen Syscall blockierte, nach dem niemand zu suchen dachte. Jedes davon erzeugte eine Ausgabe, die nicht davon zu unterscheiden war, dass alles in Ordnung ist.
Genau das kauft dir eine Generalprobe. Nicht die Gewissheit, dass es klappt – die kann man nicht haben. Nur eine billige Gelegenheit, die stillen Probleme zu treffen, bevor sie etwas kosten.




