Der Spickzettel zur ersten Hälfte des Seminars Terraform und OpenTofu in der Praxis: Plan lesen, Projekt aufbauen, Typen, Ressourcen, Module und State. Er bringt nichts davon bei, dafür sind die Module da. Secrets, Umgebungen, Import, Tests, Pipelines, Betrieb und Migration stehen auf dem zweiten Blatt: Terraform und OpenTofu: Teambetrieb.
Maßgeblich für Argumente, Flags und Versionsstände sind die
Terraform-Dokumentation und die
OpenTofu-Dokumentation. Dieses Blatt trifft eine
Auswahl. Wo die beiden Werkzeuge auseinandergehen, steht Nur OpenTofu dabei;
die Befehle sind als terraform geschrieben, bei OpenTofu heißt es tofu.
Plan-Symbole: was mit einer Ressource passiert
| Zeichen | Aktion |
|---|---|
+ | erstellen |
~ | an Ort und Stelle ändern |
-/+ | ersetzen: entfernen, dann neu erstellen |
- | entfernen |
Ob geändert oder ersetzt wird, legt der Provider je Argument fest. Im Plan steht
es als # forces replacement.
# docker_container.web must be replaced
-/+ resource "docker_container" "web" {
name = "saatplan-web"
~ ports {
~ external = 8008 -> 8080 # forces replacement
internal = 80
}
}
Plan: 1 to add, 0 to change, 1 to destroy.
Vor jedem apply: Was wird ersetzt, was entfernt, und ist das gewollt? Der
State ordnet jeder Ressource genau ein reales Objekt zu; fehlt er, will das
Werkzeug alles neu anlegen.
Module: Infrastructure as Code einordnen · CLI-Workflow und Projektstruktur
Terraform oder OpenTofu
| Terraform | OpenTofu | |
|---|---|---|
| Herkunft | HashiCorp, seit 2025 Teil von IBM | Fork von Terraform 1.5 aus dem Jahr 2023 |
| Lizenz | Business Source License 1.1 | Mozilla Public License 2.0 |
| Träger | ein Hersteller | Linux Foundation, CNCF-Sandbox-Projekt |
| Kommando | terraform | tofu |
| Registry | registry.terraform.io | registry.opentofu.org |
| Stand 09/2026 | 1.16, Version 1.17 in der Beta | 1.13 |
Interne Nutzung erlauben beide, die BSL schließt nur konkurrierende Angebote aus.
Die Unterschiede aus diesem Teil des Seminars (— heißt: dort nicht vorhanden):
| Thema | Terraform | OpenTofu |
|---|---|---|
| Konfigurationsdateien | .tf | zusätzlich .tofu, die Vorrang haben, etwa versions.tofu |
| CLI-Konfiguration | ~/.terraformrc | ~/.tofurc |
| Language Server | terraform-ls | tofu-ls |
| Lock File für alle Plattformen | terraform providers lock -platform=… | ab 1.12 schreibt tofu init die Prüfsummen selbst |
| Plan lesbar und als JSON | show und show -json getrennt | ab 1.12 -json-into=DATEI in einem Lauf |
| Ressourcen auslassen | — | -exclude |
| Modulquelle OCI-Registry | — | oci://…?tag=… |
Variablen in source/version | ab 1.15 mit const = true | erkennt Konstanten meist selbst |
| pg-Backend: Tabellen- und Indexname | fest vorgegeben | table_name, index_name frei wählbar |
| Verschlüsselung von State und Plan | — | im Werkzeug |
for_each für Provider-Konfigurationen | — | ja |
| Plattform und Support | Stacks und HCP Terraform, Herstellervertrag | Community und Dienstleister |
Modul: Infrastructure as Code einordnen
Werkzeuge abgrenzen
| Werkzeug | Zuständig für |
|---|---|
| Terraform / OpenTofu | Lebenszyklus von Infrastruktur-Objekten |
| Packer | vorgefertigte Maschinen- und Container-Images |
| Ansible | Konfiguration bestehender Systeme |
| cloud-init | Einrichtung beim ersten Start einer VM |
| Image-Build | Inhalt und Abhängigkeiten der Anwendung |
| Kubernetes-Werkzeuge | Workloads im Cluster, oft per GitOps |
Die Grenze folgt dem Lebenszyklus: Was gemeinsam entsteht und vergeht, gehört zu einem Werkzeug.
Modul: Infrastructure as Code einordnen
Projektdateien und was in Git gehört
| Datei | Inhalt | In Git? |
|---|---|---|
| versions.tf | required_version, required_providers | ja |
| providers.tf | alle provider-Blöcke | ja |
| main.tf | Ressourcen und Data Sources | ja |
| variables.tf | alle Variablen, alphabetisch | ja |
| locals.tf | lokale Werte | ja |
| outputs.tf | alle Outputs, alphabetisch | ja |
| .terraform.lock.hcl | gewählte Provider-Versionen, Prüfsummen | ja |
| .terraform/ | heruntergeladene Provider und Module | nein |
| terraform.tfstate | lokaler State, kann Geheimnisse enthalten | nein |
| *.tfplan | gespeicherte Pläne | nein |
Alle .tf-Dateien eines Verzeichnisses bilden zusammen eine Konfiguration, die
Aufteilung ist Konvention. Jede Variable bekommt type und description.
terraform {
required_version = ">= 1.16.0"
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 4.6"
}
}
}
Ohne Internet: plugin_cache_dir und filesystem_mirror im Block
provider_installation der CLI-Konfiguration; den Spiegel füllt
terraform providers mirror.
Module: CLI-Workflow und Projektstruktur · HCL, Datentypen und robuste Konfigurationen
CLI-Zyklus: acht Befehle
| Befehl | Was er tut | Zielsystem? |
|---|---|---|
| init | lädt Provider, richtet das Backend ein, schreibt das Lock File | nein |
| fmt | bringt .tf-Dateien ins kanonische Format, mit -check nur prüfen | nein |
| validate | prüft Syntax und innere Stimmigkeit, ohne Variablen und State | nein |
| plan | liest den Ist-Zustand und berechnet die Änderungen | lesend |
| apply | führt die Änderungen nach Bestätigung aus | ja |
| show | zeigt den State oder einen gespeicherten Plan | nein |
| output | gibt die Output-Werte aus dem State aus | nein |
| destroy | plant und entfernt alle verwalteten Objekte | ja |
fmt arbeitet nur im aktuellen Verzeichnis, für Unterordner braucht es
-recursive.
terraform plan -out=saatplan.tfplan
terraform show saatplan.tfplan
terraform show -json saatplan.tfplan > plan.json
terraform apply saatplan.tfplan
apply mit Plandatei fragt nicht mehr nach. Die Datei enthält sensible Werte im
Klartext.
Exit-Code bei plan -detailed-exitcode | Bedeutung |
|---|---|
| 0 | Erfolg, keine Änderungen |
| 1 | Fehler |
| 2 | Erfolg, Änderungen stehen aus, also Plan zur Freigabe vorlegen |
Ohne -detailed-exitcode endet plan auch bei ausstehenden Änderungen mit 0.
Modul: CLI-Workflow und Projektstruktur
HCL: resource oder data, Variablen, Locals, Outputs
| resource | data | |
|---|---|---|
| Wirkung | legt an, ändert und löscht ein Objekt | liest ein vorhandenes Objekt nur aus |
| Adresse | docker_network.saatplan | data.docker_network.saatplan |
| Lebenszyklus | gehört zur Konfiguration | liefert Werte, ändert nie etwas |
variable "umgebung" {
type = string
description = "Zielumgebung, test oder prod"
}
locals {
praefix = "saatplan-${var.umgebung}"
restart = var.umgebung == "prod" ? "always" : "no"
api_env = [for k, v in var.api.env : "${k}=${v}"]
}
output "web_name" {
description = "Name des Web-Containers"
value = docker_container.web.name
}
Gelesen wird local.praefix, nicht locals. Ein Argument mit null verhält
sich, als fehlte es. JSON und YAML baut man mit jsonencode und yamlencode,
nicht per Template.
Modul: HCL, Datentypen und robuste Konfigurationen
Typen, optionale Attribute und Validierung
| Familie | Typen | Wofür |
|---|---|---|
| primitiv | string, number, bool | Umgebung, Port, Schalter |
| Sammlung | list, set, map | Standorte, Umgebungsvariablen |
| strukturiert | object, tuple | Einstellungen eines Containers |
| Platzhalter | any | nur für wirklich dynamische Daten |
Die Doku ist deutlich: any nie verwenden, nur um sich die Typangabe zu sparen.
variable "api" {
type = object({
image = string
port = number
restart = optional(string, "unless-stopped")
env = optional(map(string), {})
})
description = "Einstellungen des API-Containers"
}
variable "api_port" {
type = number
description = "Externer Port der Saatplan-API"
validation {
condition = var.api_port >= 1024 && var.api_port <= 65535
error_message = "Port 1024 bis 65535 erwartet."
}
}
Fehlt ein optionales Attribut oder ist es null, gilt der Standardwert. Mehrere
validation-Blöcke je Variable sind erlaubt; seit Terraform 1.9 und OpenTofu
1.9 dürfen sie auch andere Variablen einbeziehen.
Modul: HCL, Datentypen und robuste Konfigurationen
count, for_each und depends_on
| count über eine Liste | for_each mit Schlüssel | |
|---|---|---|
| Adressen | api[0], api[1], api[2] | api["erlenbruch"] und so weiter |
| Ein Eintrag fällt weg | Indizes rücken nach, Objekte werden ersetzt | nur der eine Schlüssel verschwindet |
resource "docker_container" "api" {
for_each = toset(var.standorte) # list(string)
name = "saatplan-api-${each.key}"
image = docker_image.api.image_id
env = ["STANDORT=${each.key}"]
}
for_each braucht eine Map oder ein Set aus Strings mit bekannten Schlüsseln,
nicht „known after apply“.
Abhängigkeiten entstehen über Verweise, nicht über depends_on. Das kostet:
mehr unbekannte Werte, mehr Ersetzungen, Data Sources erst beim apply
gelesen. Und Reihenfolge ist nicht Bereitschaft.
Modul: Ressourcen modellieren und Änderungen steuern
Lifecycle-Regeln
| Regel | senkt das Risiko | Kehrseite |
|---|---|---|
| create_before_destroy | Ausfall beim Ersetzen | alt und neu existieren gleichzeitig, feste Namen kollidieren |
| prevent_destroy | versehentliches Löschen | wirkt nicht mehr, sobald der Block aus der Konfiguration fliegt |
| ignore_changes | Dauerdiff durch Änderungen von außen | gilt nur für Updates, beim Anlegen zählt der Wert |
| replace_triggered_by | veraltete Objekte nach einer Änderung | nur Ressourcen-Verweise, Variablen über terraform_data |
Werte im lifecycle-Block müssen Literale sein, weil sie vor allen anderen
Ausdrücken ausgewertet werden.
Modul: Ressourcen modellieren und Änderungen steuern
Precondition, Postcondition und Check
| Mittel | geprüft | bei Fehlschlag |
|---|---|---|
| validation | an der Variablen, vor dem Plan | kein Plan |
| precondition | vor der Änderung am Objekt | die geplante Änderung unterbleibt |
| postcondition | nach Anlegen oder Lesen | stoppt Folgeschritte, macht nichts rückgängig |
| check | am Ende von plan und apply | nur eine Warnung, der Lauf geht weiter |
lifecycle {
postcondition {
condition = length(self.ports) == 0
error_message = "db veröffentlicht einen Port."
}
}
In einer postcondition steht self für das Objekt selbst.
Modul: Ressourcen modellieren und Änderungen steuern
Gezielt eingreifen: -replace, -target, -exclude
| Mittel | wofür | Preis |
|---|---|---|
-replace | ein Objekt ist beschädigt, die Konfiguration stimmt | keiner, der Plan zeigt den Austausch offen |
-target | Fehler beheben, Grenzen umgehen | der Rest bleibt ungeplant, Drift fällt nicht auf |
-exclude | Nur OpenTofu: alles außer einer Adresse | dieselbe Vorsicht wie bei -target |
| Provisioner | letzter Ausweg | ihr Verhalten lässt sich nicht im Plan abbilden |
terraform plan -out=saatplan.tfplan -replace='docker_container.api["sonnhalde"]'
terraform plan -target=docker_network.saatplan
tofu plan -exclude='docker_container.api["muehlwiese"]'
Statt Provisionern: upload-Block, Software ins Image, healthcheck und
wait. Von count auf for_each zieht je Instanz ein moved-Block um.
Modul: Ressourcen modellieren und Änderungen steuern
Module: Schnittstelle und Provider
| Bestandteil | Inhalt |
|---|---|
| README.md | Zweck des Moduls, Annahmen, Beispielaufruf |
| main.tf, variables.tf, outputs.tf | empfohlene Mindeststruktur, auch wenn eine Datei leer bleibt |
| description | ein bis zwei Sätze an jeder Variable und jedem Output |
| examples/ | Aufrufe mit der Adresse, die ein externer Aufrufer nutzen würde |
| deprecated | Warnung bei Variablen und Outputs, die entfallen sollen (Terraform ab 1.15, OpenTofu ebenso) |
| CHANGELOG.md | Upgrade-Hinweise je Version, Konvention |
Ein Output ist ein Versprechen an alle Aufrufer; was nicht hinausgeht, darf sich ändern.
provider "docker" {
alias = "test"
host = "ssh://deploy@docker-test.lindenhof.example"
}
module "saatplan_test" {
source = "./modules/saatplan-app"
providers = { docker = docker.test }
}
Das Child Module deklariert nur required_providers mit source und
Untergrenze (>= 4.6), keinen provider-Block: Sonst verträgt es kein count,
for_each oder depends_on, und beim Entfernen fehlt der Provider zum Löschen.
Ohne source nimmt das Werkzeug hashicorp/docker an.
Modul: Wiederverwendbare Module entwickeln
Modulquellen und Versionsbindung
| Quelle | Beispiel für source | Version |
|---|---|---|
| lokaler Pfad | ./modules/saatplan-app | wie der Aufrufer |
| Git mit Tag | git::https://…/saatplan-app.git?ref=v1.2.0 | über ref |
| Unterordner | git::…/saatplan-infra.git//modules/saatplan-app?ref=v1.2.0 | über ref |
| Registry | lindenhof/saatplan-app/docker | Argument version |
| OCI-Registry | oci://registry.lindenhof.example/saatplan-app?tag=v1.2.0 | Nur OpenTofu |
| Module | Provider |
|---|---|
Version im module-Block oder per ref | Version in required_providers |
keine Einträge in .terraform.lock.hcl | Auswahl samt Prüfsummen im Lock File |
| fremde Module exakt pinnen | in wiederverwendbaren Modulen nur >= |
neue Version erst nach init aktiv | Upgrade bewusst mit init -upgrade |
version wirkt nur bei Registry-Quellen. Für die Registry heißt das Repository
terraform-PROVIDER-NAME, Tags folgen Semantic Versioning (v1.0.4).
Modul: Wiederverwendbare Module entwickeln
State-Kommandos
| Kommando | Zweck und Vorsicht |
|---|---|
| state list, state show | lesen, ändern nichts |
| state pull | aktuellen Stand als JSON ausgeben, etwa für eine Sicherung |
| state push | Stand ins Backend schreiben, mit Prüfung von lineage und serial |
| state mv | Instanz umadressieren, besser ein moved-Block im Code |
| state rm | Bindung lösen, das Objekt läuft weiter, besser ein removed-Block |
| state replace-provider | Provider-Quelle im State austauschen |
| force-unlock LOCK_ID | fremden Lock lösen, nur wenn sicher kein Lauf mehr schreibt |
Ändernde state-Kommandos schreiben immer ein Backup. Werkzeuge lesen den
State über show -json und output -json, das Format kann sich ändern.
terraform_remote_state liest nur Root-Outputs, braucht aber Lesezugriff auf
den ganzen State samt sensibler Werte.
Modul: State, Backends und Zusammenarbeit
Remote Backend pg, Locking und Migration
| Merkmal | Verhalten |
|---|---|
| Voraussetzung | PostgreSQL ab Version 10, die Datenbank muss vor init existieren |
| Ablage | Tabelle states im Schema terraform_remote_state oder schema_name |
| Workspaces | eine Zeile je Workspace-Name, ohne Workspaces heißt sie default |
| Locking | Advisory Locks, gelöst mit Ende der Sitzung oder Verbindung |
| force-unlock | nicht unterstützt, offene Locks zeigt die Systemsicht pg_locks |
| Rechte | eigenes Schema und Schema public, dort liegt die Sequenz für die IDs |
| Nur OpenTofu | table_name und index_name frei wählbar, Lock beim Anlegen je Schema |
Jedes Root Module braucht ein eigenes Schema. Zugangsdaten über die Umgebung
(PG_CONN_STR, PGUSER, PGPASSWORD): Werte aus Code oder -backend-config
landen in .terraform und in Plandateien.
terraform plan -lock-timeout=2m # warten statt abbrechen
terraform init -migrate-state # lokalen State ins Backend übernehmen
-reconfigure statt -migrate-state lässt den alten State zurück. Das
pg-Backend führt keine Historie; gesichert wird per pg_dump.
Modul: State, Backends und Zusammenarbeit
Typische Fallen
- State und Plandatei im Commit. Beide tragen sensible Werte im Klartext ins
Repository,
plan.jsonebenso. - State gelöscht, weil die Container ja laufen. Der nächste Plan will alles
neu anlegen. Von Hand editieren ist genauso falsch, dafür gibt es die
state-Kommandos. - Veralteter Plan. Hat sich der State seit dem Planen geändert, lehnt
applydie Plandatei ab. - Bequemer default. Ein Standardwert für Werte, die je Umgebung anders sind, verdeckt die vergessene Angabe.
- Validierung prüft nur die Form. Ob ein Port auf dem Host frei ist, weiß
sie nicht. Unbekannte Werte verschieben die Prüfung bis zum
apply. - create_before_destroy mit festem Namen. Docker vergibt Containernamen nur einmal, das Ersetzen scheitert.
- ignore_changes = all. Friert die Ressource ein, auch gegen gewollte Änderungen.
- prevent_destroy überall. Blockiert auch gewollte Umbauten. Es gehört an Daten, die nicht neu entstehen können.
- replace_triggered_by auf ein wechselndes Attribut. Ersetzt das Objekt bei jedem Lauf.
- destroy löscht auch das Volume. Mit allen Daten darin, in prod nie beiläufig. Verwaltet wird das Volume, nicht die Daten darin.
- Ports aus Gewohnheit. Ein „nur zum Testen“ veröffentlichter Datenbankport ist danach von außen erreichbar.
- Modul reicht alles durch. Jedes Ressourcenargument als Variable heißt, das Modul verbirgt nichts mehr. Ganze Ressourcenobjekte als Output machen interne Attribute zum Vertrag.
- Variable ohne type. Sie nimmt jeden Wert an und scheitert erst tief im Plan.
- Umbenennen ohne deprecated. Aufrufer brechen ohne Vorwarnung.
- source zeigt auf einen Branch. Derselbe Aufruf liefert morgen anderen Code. Ein Breaking Change ohne neue Hauptversion wirkt genauso.
- Sicherung im selben Volume. Sie fällt mit dem State aus.
- Alter Stand mit -force zurückgespielt. Die Container sehen inzwischen anders aus.
- Gemischte OpenTofu-Versionen am pg-Backend. Ab OpenTofu 1.10 sperrt das Anlegen anders, also nie parallel auf eine Datenbank.
Dieses Thema als Schulung für Ihr Team
Dieser Beitrag erklärt das Thema. Damit Ihr Team es danach auch anwendet, gibt es Terraform und OpenTofu in der Praxis als Schulung — an Ihrem eigenen Code, mit den Fragen, die ein Text nicht beantwortet. Sie wählen die Module, wir bauen daraus ein Programm.
5 Tage·ab 900 EUR netto pro Tag (bis 3 Teilnehmende) ·Termin nach Vereinbarung
Als Team-Schulung anfragenZum Seminar Terraform und OpenTofu in der Praxis →