1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6

Teil 4 von 6

Code: eine VM pro Dienst mit OpenTofu und Ansible

Was zwischen einer neuen Datei und einem laufenden Dienst passiert, und warum ich jeden Plan lese, bevor ich ihn anwende.

29. Oktober 2026 · 11 Min. Lesezeit

opentofuansibleproxmoxdebiandockerhetznernginxproxymanageruptimekuma

Aus einer Datei wird eine VMLinks eine kurze OpenTofu-Datei für paperless-01, darunter der Plan: eine VM neu, nichts geändert, nichts gelöscht, kein „must be replaced“. Eine Hand drückt auf apply, und rechts setzt sich die VM aus Schichten zusammen: Debian-Image, cloud-init und vier Ansible-Rollen als Bausteine. Oben läuft der Dienst.paperless-01.tfmodule"paperless_01"{source ="./modules/debian-vm"name ="paperless-01"cores =6memory =8192disk =30}tofu planlesen+ 1neu~ 0geändert- 0gelöschtkein „must be replaced“applyDebian-Imagecloud-initAnsible-Rollenbase · compose_stackbackup · updatepaperless-01SSH nach ~30 s

Kennst du das, wenn ein neuer Laptop kommt? Betriebssystem einrichten, Programme installieren, Einstellungen suchen. Auf dem alten Gerät hat irgendwann alles gestimmt, aber aufgeschrieben hast du nichts, und jetzt rätst du, was du damals wo eingestellt hattest.

Genau so habe ich früher meine Server eingerichtet. Ein Server ist in meinem Homelab fast immer eine VM, eine virtuelle Maschine: ein Computer, der nur als Software existiert, wie eine Wohnung in einem Mehrfamilienhaus. Das Haus ist mein einziger echter Server, und Proxmox ist die Software darauf, die die Wohnungen verwaltet. Eine neue VM hieß früher: in Proxmox zusammenklicken, Linux installieren, die nötigen Programme einrichten und hoffen, dass ich mir merke, was ich getan habe. Fünf VMs aus dieser Zeit gibt es noch.

Die ersten drei Teile dieser Serie haben gezeigt, was man von außen sieht: das große Bild in Teil 1, den Weg vom Handy ins Heimnetz in Teil 2 und Paperless, mein digitales Archiv für Papierkram, in Teil 3. Ab hier schauen wir hinter die Kulissen, in den Bereich, den die Serienkarte Betrieb nennt. Den Anfang macht Code, also die Frage, wie eine neue VM entsteht, ohne dass ich klicke.

Die Serienkarte: Fundament, Netz & Zugriff und Dienste im Homelab, daneben der Betrieb mit Code, Pipeline, Sicherung und Überwachung; hervorgehoben ist Code
Du bist hier: Code

Ein Bauplan statt Klicks

Heute beginnt jede neue VM mit einer kurzen Textdatei. Ich lese einen Plan, und wenig später läuft der Dienst unter <service>.home.example, gesichert und überwacht. Auch die VM für Paperless ist so entstanden.

Die Idee dahinter heißt Infrastruktur als Code. Statt eine VM von Hand einzurichten, schreibe ich auf, wie sie aussehen soll, so wie ein Architekt einen Bauplan zeichnet. Ein Programm liest den Plan und baut danach. Alle diese Dateien liegen in meinem Repo, einem Ordner, der jede Änderung mit Datum festhält, wie ein Dokument mit vollständigem Änderungsverlauf.

Bei mir teilen sich zwei Programme die Arbeit. OpenTofu ist der Rohbauer: Es spricht mit Proxmox und legt die VM an, mit Prozessorkernen, Arbeitsspeicher und Festplatten. Ansible ist der Innenausbauer: Es meldet sich über SSH, eine verschlüsselte Fernsteuerung, an der fertigen VM an und richtet ein, was der Dienst braucht. Dabei ändert es nur, was noch nicht stimmt.

Jeder neue Dienst entsteht in denselben vier Schritten. Das folgende Bild zeigt sie von oben nach unten.

Vier Stufen: OpenTofu (neue Datei, Plan lesen, Apply), Proxmox (Image importieren, cloud-init), VM einrichten (.env per SSH, Ansible) und Einbinden (Proxy-Host, Monitor, Doku)
Der Standardablauf für eine neue VM. Rund 30 Sekunden nach dem Apply ist SSH erreichbar.

Zuerst schreibe ich die Datei und lese den Plan, den OpenTofu daraus macht. Dann baut Proxmox die VM, Ansible richtet sie ein, und zum Schluss bekommt der Dienst seinen Namen, seinen Wächter und seine Sicherung. Gehen wir die Stufen der Reihe nach durch, angefangen bei der Datei.

Eine Datei pro VM

Die Datei für eine VM ist erstaunlich kurz, denn die eigentliche Arbeit steckt in einem Modul. Ein Modul ist bei OpenTofu ein wiederverwendbarer Bauplan, ähnlich wie ein Fertighaus aus dem Katalog: Alle VMs teilen denselben Grundriss, und die Datei sagt nur noch, wie groß das Haus wird. Mein Modul heißt debian-vm, weil jede VM Debian bekommt, ein verbreitetes Linux.

Die VM für Paperless nenne ich paperless-01. Ihre Datei sieht so aus, leicht gekürzt. Du musst die Sprache nicht kennen, jede Zeile ist einfach ein Wert:

module "paperless_01" {
  source = "./modules/debian-vm"

  name         = "paperless-01"
  vm_id        = 215
  cores        = 6
  memory       = 8192
  disk         = 30
  data_disk_gb = 100
  ip           = "<ip>"
  # … Benutzer, Passwort und SSH-Schlüssel kommen aus Variablen
}

Da stehen der Name, eine Nummer, sechs Prozessorkerne, 8 GB Arbeitsspeicher und zwei Festplatten, 30 GB für das System und 100 GB für die Daten. Die Nummer ist die VMID, unter der Proxmox die VM führt. Aus ihr ergibt sich auch die Adresse der VM im Heimnetz, die IP, denn deren letzte Zahl ist die VMID.

Was das Modul aus diesen Werten macht, zeigt das nächste Bild.

Die VM-Datei übergibt Variablen an das Modul debian-vm; das Modul erzeugt ein cloud-init-Snippet und eine VM mit System- und optionaler Datenplatte und gibt die IP aus, aus der inventory.tf das Ansible-Inventar schreibt
Das Modul debian-vm: ein Snippet, eine VM, eine IP. Das Ansible-Inventar fällt als Nebenprodukt ab.

Es legt genau zwei Dinge an, die VM selbst und einen kleinen Zettel mit Anweisungen für ihren ersten Start, das cloud-init-Snippet. Am Ende meldet es die IP der VM zurück. Daraus schreibt eine zweite Datei, inventory.tf, das Inventar für Ansible, also die Liste aller VMs mit ihren Adressen. Eine neue VM ist damit eine neue Datei plus zwei Zeilen in inventory.tf. Zwölf meiner VMs sind so entstanden.

Der erste Start

Bleiben wir kurz bei diesem Zettel, denn am ersten Start habe ich am meisten gelernt. cloud-init ist ein Programm, das schon im Debian-Abbild steckt und beim allerersten Start der VM einmal den Zettel abarbeitet, wie ein Hausmeister, der am Einzugstag die Schlüssel übergibt. Danach tut es nichts mehr.

Ganz am Anfang tat es allerdings gar nichts. Debian bietet sein fertiges Abbild in mehreren Varianten an, und ich hatte zuerst nocloud genommen. Nach dem Start saß ich direkt in einer root-Shell, also an der Konsole mit vollen Rechten, denn diese Variante ignoriert cloud-init vollständig. Die richtige heißt generic.

Das zweite Problem war grundsätzlicher. In meinem ersten Entwurf stand auf dem Zettel eine Menge: Docker installieren, das Programm, in dem später die Dienste laufen, NetBird anmelden, Tastatur und Sprache setzen. In jeder VM läuft außerdem ein kleines Hilfsprogramm, der Guest-Agent, der Proxmox unter anderem die IP der VM verrät. OpenTofu meldet „fertig“, sobald diese IP da ist. Ob cloud-init da drinnen noch arbeitet, weiß OpenTofu nicht, und so war Docker direkt danach manchmal noch gar nicht installiert.

Heute steht auf dem Zettel fast nichts mehr. cloud-init legt den Benutzer mit seinem SSH-Schlüssel an und startet den Guest-Agent. Alles andere macht Ansible, wo ich sehe, was passiert, und es beliebig oft wiederholen kann. Auf der Test-VM stand die Maschine danach nach 28 Sekunden, cloud-init war nach 17 Sekunden fertig.

Der Plan, den ich lese

Zurück zu OpenTofu. Bevor es irgendetwas baut, kommt der Schritt, auf den es mir am meisten ankommt. Dafür braucht OpenTofu ein Gedächtnis, den State. Das ist eine Art Inventarbuch, in dem steht, welche echte VM zu welcher Datei gehört. Meines liegt in einem Online-Speicher bei Hetzner, der alte Fassungen aufbewahrt, sodass ich ein kaputtes Inventarbuch zurückholen kann. Wie dieser Speicher funktioniert, erzähle ich in Teil 6.

Aus den Dateien und dem State macht OpenTofu zuerst einen Plan, so wie ein Handwerker vor dem Auftrag einen Kostenvoranschlag schreibt. Darin steht, was es anlegen, ändern oder ersetzen würde. Erst wenn ich zustimme, setzt der Apply den Plan um. Ersetzen klingt harmlos, heißt bei einer VM aber löschen und neu bauen, samt Systemplatte. Steht bei einer laufenden VM „must be replaced“, ist etwas faul, und es gibt keinen Apply.

Dass ich da so streng bin, hat eine Vorgeschichte. Für sie brauchst du noch ein Wort aus Teil 5. OpenTofu läuft nicht nur auf meinem Rechner, sondern meist in einer Pipeline, einem automatischen Ablauf auf GitLab, der Plattform, auf der mein Repo liegt. Beide sollten für dieselbe VM exakt denselben cloud-init-Zettel schreiben.

Aus der Praxis: Am Morgen des 8. September wollte ich auf meinem Rechner nur das Inventar für Ansible neu schreiben lassen. Der Apply hat dabei drei VMs gelöscht und neu gebaut, darunter die für DNS, das Telefonbuch meines Netzes. Der Grund steckte im Passwort der VMs. Es enthält ein Zeichen, das GitLab in den Variablen der Pipeline als Verweis auf eine andere Variable liest. Die Pipeline hatte deshalb einen anderen Zettel geschrieben als mein Rechner, für OpenTofu war der Zettel geändert, und ein geänderter Zettel hieß damals: VM ersetzen. DNS lief über die Reserve auf einem Raspberry Pi weiter, aber AdGuard, das DNS-Programm, musste ich neu aufbauen. Seitdem reicht die Pipeline die Variable unverändert durch, und das Passwort landet so maskiert im Zettel, dass kein Zeichen mehr umgedeutet wird.

Seitdem stehen im Modul zwei Schutzregeln. Die eine sagt, dass ein geänderter cloud-init-Zettel kein Grund ist, eine VM zu ersetzen. Er wird ja ohnehin nur beim ersten Start gelesen. Die andere sagt, dass eine geänderte Einstellung kein Grund für einen Neustart ist (reboot_after_update = false). Sie stammt vom Nachmittag desselben Tages, und den erzählt Teil 5.

Und die fünf alten VMs vom Anfang? Die hat OpenTofu nachträglich in sein Inventarbuch aufgenommen. Bei ihnen verwaltet es nur Name, Kerne, Arbeitsspeicher und Startverhalten, und die wichtigen sind gegen versehentliches Löschen gesperrt.

Ansible richtet ein

Steht die VM, ist sie noch ziemlich leer, ein frisches Debian mit einem Benutzer und einem Schlüssel. Jetzt kommt der Innenausbauer. Ansible liest das Inventar und arbeitet eine Anleitung ab, das Playbook site.yml. Es besteht aus Rollen. Eine Rolle ist ein Rezept für genau eine Aufgabe, und das Playbook legt fest, welches Rezept auf welcher VM gekocht wird.

Ein Wort brauchen wir dafür noch. Meine Dienste laufen in Docker. Docker packt ein Programm samt allem, was es braucht, in einen Container, eine verschlossene Kiste, die überall gleich läuft. Ein Dienst besteht oft aus mehreren Kisten, etwa dem Programm und seiner Datenbank. Zusammen nenne ich sie einen Stack, und eine Datei namens compose.yaml beschreibt, welche Kisten dazugehören.

Das nächste Bild zeigt die Reihenfolge der Rollen: erst die Grundausstattung für alle VMs, dann der eigentliche Dienst, zum Schluss Sicherung und Update.

site.yml liest das von OpenTofu erzeugte Inventar und spielt nacheinander base auf alle VMs, die Dienst-Rolle je Gruppe (adguard oder compose_stack), compose_backup und compose_update
Reihenfolge in site.yml: Basis, Dienst, dann Backup und Update auf jeder VM.

Was jede der fünf Rollen tut, steht in der Tabelle. Die letzten beiden tauchen in Teil 6 wieder auf.

RolleLäuft aufWas sie tut
baseallen VMsGrundausstattung: Updates, auch automatische, Docker, NetBird aus Teil 2, die Datenplatte einhängen
adguardder DNS-VMAdGuard Home einrichten, samt Abgleich mit der Reserve auf dem Raspberry Pi
compose_stackjeder Dienst-VMden Stack aus dem Repo holen, starten und prüfen, ob er antwortet
compose_backupallen VMsjede Nacht um 03:30 alle Stacks sichern, verschlüsselt
compose_updateallen VMssonntags sichern, dann aktualisieren und per Nachricht Bescheid geben

Alle Rollen sind idempotent, ein sperriges Wort für ein einfaches Versprechen. Hat ein Lauf geklappt, ändert der nächste nichts mehr, wie ein Lichtschalter, auf dem „an“ steht und nicht „umschalten“. Auf der Test-VM meldete der erste Lauf 15 Änderungen, der zweite keine. Der erste Dienst, der komplett über die Pipeline entstand, war DNS. Seine VM war 30 Sekunden nach dem Apply erreichbar, und Ansible war nach etwa zwei Minuten fertig.

Wohin mit den Passwörtern?

Bei so viel Code im Repo liegt die Frage nahe, wo die Passwörter stehen. Im Repo jedenfalls nie. Jeder Dienst bekommt auf seiner VM eine .env-Datei, eine schlichte Textdatei mit seinen Zugangsdaten, die nur der Administrator lesen darf. Die schicke ich per SSH hinüber, bevor Ansible läuft. Fehlt sie, legt Ansible den Stack zwar an, startet ihn aber nicht und gibt einen Hinweis aus. Zugangsdaten für die Pipeline liegen als geschützte Variablen in GitLab.

Damit trotzdem nichts durchrutscht, sucht gitleaks, ein Scanner für versehentlich gespeicherte Geheimnisse, bei jedem Hochladen ins Repo danach. Ganz grundlos ist das nicht, denn ein älterer Versuch in diesem Repo enthielt ein Zugangstoken im Klartext, das inzwischen widerrufen ist.

Immer der Reihe nach

Ändere ich ein Rezept, muss die Änderung auf alle VMs. Das mache ich nie auf allen gleichzeitig, sondern auf einer oder zwei nach der anderen. Als ich das Playbook einmal auf alle VMs zugleich losließ, ging der kleinsten VM mit 1 GB Arbeitsspeicher beim großen Systemupdate der Speicher aus, bis sie nur noch auf Ping antwortete, das einfachste Lebenszeichen im Netz. Dazu kommt, dass die SSD in meinem Server bremst, wenn sie unter Dauerlast heiß wird.

Seitdem bekommt auch die kleinste VM 2 GB. Und eine Änderung gilt erst als fertig, wenn sie auf jeder VM angekommen ist, denn was im Repo steht, läuft deshalb noch lange nicht überall.

Die letzten Handgriffe

Läuft der Stack, ist der Dienst trotzdem erst auf seiner eigenen VM erreichbar. Damit er unter seinem Namen auftaucht, braucht er einen Eintrag beim Proxy. Das ist der Türsteher aus Teil 2, der alle Anfragen annimmt und an den richtigen Dienst weiterreicht. Ein Skript trägt dort <service>.home.example ein. DNS muss ich nicht anfassen, denn es schickt jeden Namen unter home.example ohnehin zum Proxy.

Dann bekommt der Dienst einen Wächter. Uptime Kuma fragt jede Minute nach, ob er antwortet, und schlägt Alarm, wenn nicht; mehr dazu in Teil 6. Auch diesen Eintrag legt ein Skript an, genauso wie die gemeinsame Anmeldung über Authentik aus Teil 2, wenn der Dienst sie unterstützt. Dazu kommen die neue VM im nächtlichen Sicherungsjob und eine Seite in meiner Doku.

Abbauen geht in umgekehrter Richtung. Am Ende muss ein voller Plan „No changes“ melden, erst dann stimmen Code und Wirklichkeit wieder überein.

Vom Klicken zum Lesen

Damit ist der Dienst da: eine Datei, ein gelesener Plan, ein paar Minuten Ansible, und er antwortet unter seinem Namen. Anders als beim neuen Laptop vom Anfang steht diesmal alles aufgeschrieben, und meine Arbeit hat sich vom Klicken zum Lesen verschoben. Das Lesen ist der wichtigere Teil. Ein Werkzeug, das von sich aus VMs ersetzt oder neu startet, braucht Leitplanken, bevor es eine Maschine sieht, auf die ich mich verlasse, und einen Menschen, der den Plan liest.

Bisher habe ich allerdings so getan, als säße ich selbst an der Tastatur. Meist laufen Plan und Apply in der Pipeline, auf Rechnern, die selbst VMs in diesem Homelab sind. Was am Nachmittag des 8. September mit genau so einem Rechner passierte und warum ein nächtlicher Test plötzlich zu langsam war, erzählt Teil 5.