Eine Woche, 24 Dienste: mein Homelab nach Ansible migriert

August 10, 2026

AnsibleHomelabDockerDevOpsAI

Im ersten Teil habe ich beschrieben, was in meinem Homelab läuft: drei stromsparende Rechner, ein NAS, getrennte VLANs, WireGuard als einziger Weg von außen und rund 25 Docker-Dienste. Am Ende stand dort der Satz, dass alles in einem einzigen Ansible-Projekt liegt und der Alltag aus einem Kommando besteht.

Homelab mit Ansible

Dieser Artikel handelt davon, wie ich dahin gekommen bin. Also von der Woche, in der ich drei gewachsene Repositories in ein Projekt überführt habe, von den Dingen, die dabei hakten, und von der Frage, wer diesen Code eigentlich geschrieben hat.

Wie es vorher war

Angefangen hat das mit einem einzigen Server. Ein Git-Repository, darin pro Dienst ein Verzeichnis mit docker-compose.yml, .env und einem kleinen up.sh. Aktualisieren hieß: einloggen, ins Verzeichnis wechseln, ./up.sh ausführen. Ungefähr ein Jahr lief das so, und für einen einzelnen Server ist daran auch nichts falsch.

Unangenehm wurde es mit dem zweiten Host. Der bekam ein eigenes Repository, der dritte später auch. Am Ende hatte ich drei Stück, eines pro Maschine, und darin einen Zustand, den ich mir vorher nie am Stück angesehen hatte:

  • Ein Teil der Dienste lief über Docker Compose, ein anderer über rohes docker run aus einem Shell-Skript.
  • Die Secrets lagen auf dem einen Host als Klartext-.env, auf den beiden NixOS-Hosts age-verschlüsselt daneben.
  • Die Datenverzeichnisse hießen mal ./data neben der Compose-Datei, mal /data/<dienst>.

Einen Dienst von einer Maschine auf eine andere zu verschieben, hieß damit: Verzeichnis zwischen zwei Repositories kopieren und anschließend die Pfade geradebiegen. Und ein Dienst, der auf zwei Hosts lief, hatte zwei Compose-Dateien, die sich in zwei Zeilen unterschieden. Wenn sich daran etwas Grundsätzliches änderte, musste ich daran denken, es an beiden Stellen zu tun.

Am meisten gestört hat mich aber etwas anderes: Ich habe dieselben Handgriffe immer wieder von Hand gemacht, auf mehreren Rechnern. Beruflich beschreibe ich Server als Code, das ist mein Job. Zu Hause habe ich mich eingeloggt und Dinge angefasst. Dieser Widerspruch hat mich am Ende mehr geärgert als die doppelte Pflege.

Der Plan und was daraus wurde

Bevor ich anfange, schreibe ich bei größeren Umbauten kurz auf, was ich vorhabe. Im Design-Dokument zu dieser Änderung steht unter den Nicht-Zielen ein Satz, über den ich heute schmunzeln muss:

Migrating any service other than beszel-agent. The other ~21 services stay on the legacy repos until each gets its own follow-up change.

Der Plan war also vorsichtig: ein Pilotdienst, der auf allen drei Hosts läuft, danach einundzwanzig einzelne Folgeänderungen, jede für sich geplant und ausgerollt. Daran gehalten habe ich mich nicht. Ich hatte mir vorgenommen, die Sache innerhalb einer Woche durchzuziehen, und genau das habe ich dann gemacht: Dienst für Dienst, aber ohne Pause dazwischen. Der erste Commit im Repository ist vom 21. Mai, der Commit mit dem fertigen Framework und den migrierten Diensten vom 29.

Dass das funktioniert hat, lag weniger an mir als an der Ausgangslage. Der größte Teil der Dienste lief ohnehin schon über Docker Compose. Da bestand die Migration im Wesentlichen darin, eine bestehende Compose-Datei in ein Template zu überführen und die veränderlichen Stellen herauszuziehen. Und die Dienste, die noch über ein Shell-Skript hochgefahren wurden, waren die harmlosen: klein, teilweise ohne jede Persistenz. Die zu konvertieren war keine Sache.

Eine Rolle statt 25

Die Grundidee des Frameworks lässt sich kurz sagen: Die Unterschiede zwischen den Diensten sind Daten, kein Code. Es gibt deshalb keine 25 Rollen für 25 Dienste, sondern eine einzige, docker_compose_service.

Ein Dienst ist damit nur noch ein Verzeichnis mit höchstens vier Dateien:

services/paperless-ngx/
├── defaults.yml                     # ein service_defaults-Dict: Image, Tag, Ports, Env
├── templates/docker-compose.yml.j2  # die Compose-Datei
├── templates/env.j2                 # optional, wird zu .env mit mode 0600
└── vault.yml                        # optional, ansible-vault-verschlüsselt

Und jeder Host bekommt eine Registry, in der steht, was er ausführen soll:

# inventory/host_vars/<host>/services.yml
services:
  paperless-ngx:
    enabled: true
  vaultwarden:
    enabled: true
  webdav:
    enabled: false

Die interessantere Frage bei so einem Aufbau ist nicht, wie man ein Compose-File rendert. Sie lautet: Wie überschreibt ein einzelner Host eine Einstellung, ohne dass die Konfiguration doppelt gepflegt wird? Bei mir wird dafür vor dem Rendern genau einmal gemerged. Die Rolle nimmt die Defaults des Dienstes, legt den Eintrag des Hosts darüber und schreibt das Ergebnis in ein Fact namens svc:

- name: Compose effective service config
  ansible.builtin.set_fact:
    svc: "{{ service_defaults | default({}) | combine(docker_compose_service_svc, recursive=true) }}"

Ab da liest jedes Template nur noch svc.*, ohne Fallbacks und ohne Verzweigungen:

image: {{ svc.image }}:{{ svc.image_tag }}

Ein Feld wird also genau einmal benannt, nämlich in defaults.yml, und ein Host überschreibt es mit demselben Schlüssel. Der Gewinn zeigt sich beim nächsten Dienst: Der ist ein neues Verzeichnis und ein Boolean. Keine neue Rolle, kein Copy-Paste eines bestehenden Playbooks.

Passwörter und Tokens liegen in ansible-vault-verschlüsselten Dateien direkt neben dem jeweiligen Dienst. Das Vault-Passwort selbst liegt nirgends auf der Platte, Ansible holt es bei jedem Aufruf über ein kleines Skript aus meinem Passwortmanager. Damit gibt es keine Datei, die man versehentlich committen kann, und kein Passwort in der Shell-History. Dazu eine Konvention, die sich im Alltag bezahlt macht: Jede Variable aus einem Vault beginnt mit vault_. Wenn ich in einem Template {{ vault_paperless_admin_password }} lese, weiß ich sofort, woher der Wert kommt.

Eine Kleinigkeit, die mehr bringt als erwartet: Die Rolle legt in jedes Dienstverzeichnis auf dem Host auch ein up.sh. Wenn ich doch einmal direkt auf der Maschine bin, starte ich den Stack damit genauso, wie Ansible es täte. Und weil die Datei bei jedem Lauf neu geschrieben wird, ist der Versuch, sie auf dem Host anzupassen, von vornherein zwecklos.

Warum bei mir immer erst der Diff kommt

provision.sh ist ein dünner Wrapper um ansible-playbook, der immer --diff mitgibt. Zusätzlich lasse ich bei allem, was nicht ganz trivial ist, vorher einen Durchgang mit --check. Das habe ich mir über Jahre so angewöhnt, und es hat einen konkreten Grund.

Das hehre Ziel einer Ansible-Rolle ist Idempotenz: Man kann sie jederzeit laufen lassen, und sie ändert nichts außer dem, was sie ohnehin ändern wollte. Liegt auf dem System ein anderer Zustand vor, dann ist genau das das Problem, nicht das, was die Rolle daraus macht. Nur lässt sich das bei Systemen, die über Jahre laufen und weiterentwickelt werden, leider nicht immer garantieren. Und anders als etwa Puppet bekommt Ansible so eine Abweichung von sich aus nicht mit. Es fällt einem erst auf, wenn man die Rolle laufen lässt und sie plötzlich Änderungen vornimmt, mit denen man nicht gerechnet hat.

Deshalb schaue ich mir vorher an, was passieren würde, und stelle mir dieselbe Frage: Ist das genau das, was ich erwarte? Wenn mehr passiert als erwartet, gehe ich der Sache nach, bevor ich den Lauf scharf schalte.

Was dabei hakte

Wirklich geärgert hat mich während der Migration genau eine Sache, und die betraf die Dienste, die vorher über ein Shell-Skript liefen. Ich wollte sie auf Docker Compose vereinheitlichen, und das funktioniert nicht dadurch, dass man einfach Compose startet und darauf hofft, dass der bestehende Container heruntergefahren wird.

Ein Container aus docker run trägt keine Compose-Projekt-Labels. Compose erkennt ihn deshalb nicht als etwas, das es verwaltet, will aber denselben Namen benutzen und bricht ab:

Conflict. The container name "/<name>" is already in use by container "<id>"

Das war kein Drama. Die Fehlermeldung ist eindeutig, ich wusste, was auf meinen Hosts läuft, und die Lösung bestand darin, die betroffenen Container vor dem ersten Ansible-Lauf einmal von Hand zu stoppen und zu entfernen. Bei den Diensten, die vorher schon Compose benutzten, war das nicht nötig, dort passen Projekt- und Container-Labels und der Stack wird sauber neu erzeugt.

Datenverlust oder nennenswerte Ausfallzeiten hatte ich keine. Ich hätte hier gerne eine dramatischere Geschichte, aber es gibt keine.

Was dabei herausfiel

Die unangenehmste Erkenntnis der Woche war eine andere: wie viel sich über die Jahre angesammelt hatte, und dass ich einiges davon gar nicht mitgenommen habe.

Am deutlichsten erinnere ich mich an einen Dienst für Single Sign-On, den ich irgendwann aufgesetzt hatte, weil ich dachte, ich könnte so etwas für n8n und ein paar andere Anwendungen gebrauchen. Im Nachhinein war das für ein Homelab schlicht Quatsch. Der ist bei der Migration nicht mitgekommen, sondern weggeflogen.

Bei anderen Diensten hatte ich den Container längst gestoppt, die Konfiguration lag aber noch im Repository. Beim Durchgehen bin ich dann jedes Mal an derselben Stelle hängengeblieben: Das benutze ich nicht mehr, also brauche ich es auch nicht mehr. So etwas passiert bei mir regelmäßig, weil ich zu Hause Dinge einfach ausprobiere und wieder lösche, wenn sie nichts taugen. Nur das Löschen vergesse ich manchmal. 😄

Eine Migration ist dafür ein brauchbarer Anlass. Man kommt an jedem einzelnen Dienst noch einmal vorbei und muss sich für jeden bewusst entscheiden.

Wer den Code geschrieben hat

An dieser Stelle wäre es unehrlich, den Eindruck stehen zu lassen, ich hätte diese Woche durchprogrammiert. Der Code kam im Großen und Ganzen von Claude. Ich habe ihn reviewt.

Das funktioniert allerdings nur, weil ich weiß, was ich da lese. Wir haben die Entscheidungen gemeinsam getroffen und uns dabei gegenseitig infrage gestellt, und ich habe bei der Gelegenheit selbst wieder einiges dazugelernt. Das ist der Unterschied, auf den es mir ankommt: begleitetes Entwickeln statt „Claude, mach mal“ und am Ende kommt irgendetwas heraus, das man so nie wollte.

Ich glaube, so wird das in Zukunft laufen. Code schreiben macht eine KI, reviewen tut jemand, der weiß, was er zu reviewen hat. Hoffentlich bleibt es noch eine Weile so. Wobei ich glaube, dass wir Menschen längerfristig das, was KI produziert, nicht mehr kontrollieren können, weil es schlicht und einfach zu viel für uns wird.

Ganz nebenbei ist während dieser Woche noch etwas anderes entstanden. Weil sich der Ablauf pro Dienst ständig wiederholte, also alte Konfiguration einlesen, Secrets übernehmen, Templates anlegen, Vault verschlüsseln, Host-Variablen setzen, haben wir daraus einen eigenen Skill gebaut, der genau diesen Ablauf abbildet. Der liegt im selben Commit wie die Migration. Ab dem dritten oder vierten Dienst war die Migration damit eher Fließbandarbeit als Handwerk.

Debian patchen, ohne mich einzuloggen

Dass ich denselben Ärger woanders auch noch hatte, ist mir erst später aufgefallen. Meine Debian-Hosts wollten regelmäßig aktualisiert werden, und dafür habe ich mich auf jedem einzeln angemeldet, apt laufen lassen und danach überlegt, ob ein Neustart nötig ist. Die Informationen dafür hatte ich längst, die MOTD zeigt auf jedem Host beim Einloggen an, wie viele Pakete anstehen und ob ein Reboot aussteht. Nur getan werden musste es weiterhin von Hand.

Das hat mich eine ganze Weile gestört, ohne dass es je genug Priorität bekommen hätte. Irgendwann habe ich mir gesagt: Jetzt fixe ich das einfach. Und um ehrlich zu sein, ist genau das der Punkt, an dem sich für mich etwas verändert hat. Die Hemmschwelle, so eine Sache anzugehen, ist deutlich niedriger als früher. Ich muss im Grunde nur sagen, dass ich das brauche, und bekomme es gebaut. Angesehen habe ich es mir hinterher trotzdem.

Herausgekommen ist eine Rolle, die in drei getrennten Aufrufen arbeitet, weil es drei Entscheidungen mit sehr unterschiedlichen Konsequenzen sind:

./provision.sh                 # berichtet, was ansteht, ändert nichts
./provision.sh -t apt-upgrade  # spielt die Updates ein, startet nie neu
./provision.sh -t reboot       # startet nur die Hosts neu, die es brauchen

Der Bericht ist read-only und läuft bei jedem normalen Durchgang mit. Alles, was Zustand verändert, ist so markiert, dass es ausschließlich läuft, wenn ich seinen Tag ausdrücklich verwende. Ein -t apt-upgrade startet also unter keinen Umständen einen Host neu, und ein -t reboot spielt keine Pakete ein.

Zwei Details, die ich bewusst so gebaut habe:

Wer gepatcht wird, steht in der Gruppenzugehörigkeit. Mein NAS ist absichtlich nicht in der Gruppe, denn der Hersteller rät von apt upgrade auf dem Gerät ab und liefert ein eigenes Update-Werkzeug mit. Ich hätte das auch über eine Bedingung in den Aufgaben lösen können. Der Ausschluss über die Gruppe ist mir lieber, weil eine später hinzugefügte Aufgabe, bei der jemand die Bedingung vergisst, das Gerät so gar nicht erst erreichen kann.

Neustarten ist pro Host ausdrücklich zu erlauben. Der Vorgabewert ist „nein“. Nur ein Host darf sich selbst neu starten. Zwei weitere betreiben Proxmox-Gäste und nehmen die beim Neustart mit, die fasse ich weiterhin von Hand an. Ein Host, an den niemand gedacht hat, startet damit nicht von selbst neu, und ein ausgelassener Neustart meldet sich beim nächsten Lauf ohnehin wieder.

Den Reboot-Status ermittelt die Rolle übrigens nicht selbst. Den holt sie sich aus derselben Datei, die auch die MOTD benutzt. Diese Logik gab es schon, inklusive der Eigenheiten der Proxmox-Kernelpakete, und sie ein zweites Mal zu schreiben wäre die schlechtere Idee gewesen.

Was noch offen ist

  • enabled: false heißt bei mir „nicht verwaltet“, nicht „abgebaut“. Deaktiviere ich einen Dienst, läuft der Container weiter, bis ich ihn von Hand stoppe. Ein richtiges Teardown fehlt. Das ist die Stelle, an der das Framework noch nicht zu Ende gedacht ist.
  • Beim Aktualisieren der Images bin ich noch nicht da, wo ich hinwill. Ich betreibe mit Dockhand einen Dienst, der meine Container automatisch aktualisiert, und das will ich auch nicht mehr missen. Nur hat sich latest dabei als schlechte Idee erwiesen, das kann einem böse ins Auge gehen. Ich muss also gezielter einstellen, welche Versionssprünge erlaubt sind, Minor ja, Major nein. Umgesetzt habe ich das noch nicht bei allen Services, im Repository stehen Dockhand und sein Agent selbst auf latest.
  • Es gibt keine Tests. Beruflich arbeite ich mit getrennten Umgebungen und teste meine Rollen mit Molecule, das ist mein Anspruch und die Qualität schulde ich meinem Arbeitgeber. Hier habe ich bewusst darauf verzichtet: Es liegt alles in einem Projekt statt in einzelnen, separat ausgelieferten Rollen, und ich konnte während der Entwicklung jeden Schritt vorher durchsprechen und im --check-Modus laufen lassen. Im Job würde ich das nicht so machen.

Fazit

Was ich aus dieser Woche mitgenommen habe, gilt nicht nur für mein Homelab: Es lohnt sich, auch privat über Infrastructure as Code nachzudenken, und zwar früher als man denkt. Sonst wird daraus über die Jahre ein Haufen von Diensten, den man irgendwann nicht mehr unter Kontrolle bekommt.

Der Grund dafür ist banal. Als Privatperson habe ich keine hochverfügbaren Systeme. Wenn mir eine Maschine abhandenkommt, ist die Frage nicht, ob etwas übernimmt, sondern wie schnell ich denselben Stand woanders wieder hinbekomme. Wer alles von Hand zusammengebaut hat, muss das dann aus dem Gedächtnis rekonstruieren, und zwar für Dienste, die er vor zwei Jahren zuletzt angefasst hat. Das kann schwierig werden.

Deshalb lieber am Anfang kurz überlegen: Was will ich erreichen, wie setze ich meine Dienste auf, und was mache ich, wenn ein Server ausfällt. Der Umbau hat mich eine Woche gekostet. Das Wiederherstellen kostet mich jetzt ein Kommando.

Ach ja, bevor ich es vergesse: Der allerwichtigste Punkt ist ein gutes und funktionierendes Backup, eines, das man auch wiederherstellen kann. Im optimalen Fall hat man sogar zwei davon, geografisch getrennt natürlich. Wie ich das bei mir gelöst habe, steht im ersten Teil.