Server ohne offene Ports: WireGuard und OpenTofu auf Hetzner
25.08.2026
Thema: alle Beiträge zu OpenTofu
Ein Hetzner-Server, der von außen unsichtbar ist: WireGuard als einziger Admin-Zugang, cloud-init statt SSH-Provisioner, verschlüsselter State. Und warum tofu destroy -target und ssh-keygen -R zum Alltag gehören.
Inhaltsverzeichnis
- Ein Server, der von außen nicht existiert
- Warum kein offener SSH-Port mehr
- Zwei Regeln in der Firewall
- Der Tunnel: ein Interface, ein Peer
- cloud-init statt remote-exec
- Ich dachte, der Private Key liegt im State
- Abbauen und wieder hochziehen, ohne den Endpoint zu verlieren
- Die Sache mit den Host-Keys
- Der Nachweis von außen
- Fazit
Ein Server, der von außen nicht existiert
Der trutz.io-Server hat in seiner Grundausstattung genau zwei Türen nach außen: Ping und WireGuard. Kein SSH auf Port 22, keine Kubernetes-API auf 6443. Wer die Adresse scannt, bekommt auf jedem TCP-Port dasselbe: Stille.
nc -z -w3 203.0.113.10 22 # Timeout
nc -z -w3 203.0.113.10 443 # Timeout
nc -z -w3 203.0.113.10 6443 # Timeout
Und trotzdem administriere ich diesen Server jeden Tag per SSH. Der Widerspruch löst sich mit einem Wort auf: WireGuard. Dieser Beitrag zeigt den kompletten Aufbau mit OpenTofu auf Hetzner: die Firewall, den Tunnel, die Provisionierung ohne SSH-Zugang, die Verschlüsselung des State. Und zwei Dinge, die dir der Aufbau erst beibringt, wenn du ihn wirklich durchziehst: warum tofu destroy -target zum Werkzeugkasten gehört und was es mit der SSH-Warnung nach jedem Neuaufbau auf sich hat.
Der Server ist der erste Baustein für eine Internal Developer Platform auf offenen Werkzeugen. Bevor Kubernetes, GitOps und Secrets-Management darauf einziehen, muss das Fundament stimmen: ein Server, der reproduzierbar aus Code entsteht und dessen Angriffsfläche so klein ist wie möglich.
Warum kein offener SSH-Port mehr
Der Vorgänger dieses Servers hatte Port 22 und 6443 für die ganze Welt offen, mit Firewall-Regeln wie aus dem Lehrbuch für schnelle Ergebnisse:
rule {
description = "ssh"
direction = "in"
protocol = "tcp"
port = "22"
source_ips = ["0.0.0.0/0", "::/0"]
}
Funktioniert. Was es kostet, habe ich für das Video zu diesem Beitrag gemessen: ein zweiter, nackter Hetzner-Server, Debian, Port 22 offen für die ganze Welt, und dann nur ins Journal geschaut. Der erste Angriff kam nach 113 Sekunden. In knapp vierzehn Stunden sammelten sich 2013 fehlgeschlagene Login-Versuche, im Schnitt 145 pro Stunde, aus 63 verschiedenen IP-Adressen, rund um die Uhr, auch nachts um drei. Probiert wird alles: root, admin, ubuntu, postgres, sogar der Benutzername trutz, denn die frisch zugeteilte IP hatte eine Vorgeschichte, und Bot-Wörterbücher vergessen nichts.
Mit Key-only-Login sind diese Versuche chancenlos. Aber die Tür bleibt eine dauerhaft exponierte Fläche, die gepflegt, gepatcht und beobachtet werden will. Bei der Kubernetes-API auf 6443 gilt dasselbe mit höherem Einsatz.
Die Alternative ist nicht, die Tür besser zu bewachen. Die Alternative ist, von außen gar keine Tür zu haben.
Zwei Regeln in der Firewall
Die komplette Hetzner-Firewall des neuen Servers:
resource "hcloud_firewall" "next" {
name = "next"
rule {
description = "ping"
direction = "in"
protocol = "icmp"
source_ips = ["0.0.0.0/0", "::/0"]
}
rule {
description = "wireguard"
direction = "in"
protocol = "udp"
port = "51820"
source_ips = ["0.0.0.0/0", "::/0"]
}
}
Eingehend: ICMP und ein einziger UDP-Port. SSH und alles Weitere laufen durch den WireGuard-Tunnel. HTTP und HTTPS fehlen hier bewusst, die kommen erst dazu, wenn auf dem Server tatsächlich etwas lauscht. Inzwischen ist genau das passiert: Seit auf dem Server ein Gateway lauscht, sind auch 80 und 443 offen. An der Regel ändert das nichts, sie hat sich bestätigt: Gestartet wird mit zwei Regeln, geöffnet wird, was läuft. Der Admin-Zugang bleibt dabei dauerhaft unsichtbar.
WireGuard hat für diese Rolle eine Eigenschaft, die kaum ein anderer Dienst mitbringt: Es antwortet auf Pakete ohne gültigen Schlüssel überhaupt nicht. Kein Banner, kein Handshake-Versuch, keine Fehlermeldung. Die Entwickler nennen das im Protokoll-Design wörtlich „silent and invisible”, als Schutz gegen Denial-of-Service: Der Server gibt keinem Paket Rechenzeit, das sich nicht kryptografisch ausweisen kann. Das lässt sich messen, mit einem UDP-Scan auf den WireGuard-Port und zum Vergleich auf einen Port, auf dem garantiert nichts läuft:
sudo nmap -sU -p 51820,33333 -Pn 203.0.113.10
PORT STATE SERVICE
51820/udp open|filtered unknown
33333/udp open|filtered unknown
Beide Ports melden dasselbe. Der Port, hinter dem der komplette Admin-Zugang hängt, ist von einem toten Port nicht zu unterscheiden. Das ist kein Versteckspiel als Sicherheitskonzept, die Sicherheit kommt aus der Kryptografie. Aber es bedeutet: Der Server taucht in keinem Scan-Ergebnis als interessantes Ziel auf.
Ein Wort zur Ehrlichkeit, bevor daraus ein falsches Versprechen wird: Null Angriffsfläche gibt es bei einem erreichbaren System nicht. Der Kernel parst jedes ankommende UDP-Paket, WireGuard und das Hostsystem bleiben Pflegefläche und wollen gepatcht werden. Der Gewinn liegt in der Qualität der Restfläche: eine winzige Codebasis von wenigen tausend Zeilen statt eines kompletten SSH-Stacks, ein formal verifizierter Handshake, und vor der Authentifizierung keine Antwort an niemanden. Klein und pflegbar statt groß und exponiert, das ist der Tausch.
Der Tunnel: ein Interface, ein Peer
Auf dem Server sieht die WireGuard-Konfiguration so aus:
[Interface]
Address = 10.100.0.1/24
ListenPort = 51820
PrivateKey = <server-private-key>
[Peer]
PublicKey = <client-public-key>
AllowedIPs = 10.100.0.2/32
Genau ein Peer: mein Rechner. Auf der Client-Seite das Spiegelbild, mit dem Server als Peer und AllowedIPs = 10.100.0.1/32, damit nur der Verkehr zum Server durch den Tunnel geht und nicht der gesamte Internet-Traffic. Danach ist der Alltag unspektakulär:
sudo wg-quick up trutzio-next
ssh root@10.100.0.1
SSH spricht die Tunnel-Adresse an, nicht die öffentliche IP. Von außen existiert der SSH-Port nicht, durch den Tunnel ist er ganz normal erreichbar.
cloud-init statt remote-exec
Der Vorgänger wurde nach dem Anlegen per SSH fertig konfiguriert, mit einem remote-exec-Provisioner in OpenTofu. Das funktioniert, hat aber zwei Haken: OpenTofu braucht den privaten SSH-Key und eine offene Tür zum Server, und die Konfiguration ist imperativ, eine Liste von Kommandos statt eines beschriebenen Zustands.
Der neue Server bekommt stattdessen alles beim ersten Boot über cloud-init mit:
resource "hcloud_server" "next" {
name = "next"
image = "debian-13"
server_type = "cpx32"
location = "fsn1"
firewall_ids = [hcloud_firewall.next.id]
user_data = templatefile("${path.module}/cloud-init.yaml.tftpl", {
wg_server_private_key = var.wg_server_private_key
wg_client_public_key = var.wg_client_public_key
wg_port = var.wg_port
})
}
Das cloud-init-Template installiert WireGuard, schreibt die Tunnel-Konfiguration, härtet SSH per Drop-in (kein Passwort-Login, root nur mit Key) und schaltet unattended-upgrades scharf. OpenTofu berührt den Server nie per SSH. Es beschreibt ihn nur, der Server baut sich selbst. Das passt auch zur Firewall: Es muss nie ein Port offen sein, damit die Provisionierung durchläuft.
Eines löst cloud-init dabei nicht: Es konfiguriert genau den ersten Boot, danach ist es fertig. Gegen Drift, also die schleichende Abweichung eines laufenden Servers von seiner Beschreibung, gibt es zwei ehrliche Wege. Entweder Konfigurationsmanagement wie Ansible, das den laufenden Server dauerhaft pflegt, oder konsequent wegwerfbare Server, bei denen jede Änderung in die Vorlage wandert und der Server neu gebaut wird. Dieser Aufbau wählt bewusst den zweiten Weg: Der Server wird nach dem ersten Boot nie wieder von Hand angefasst, und die Übergabe per API macht den Neubau billiger als die Pflege. Drift kann so gar nicht erst entstehen.
Das Kriterium ist dabei nicht das Werkzeug, sondern die Übergabe per API. Talos Linux treibt dieselbe Idee eine Stufe weiter: keine Shell, kein SSH-Dienst, die komplette Konfiguration kommt als Machine Config über denselben Kanal. Für die Maschine unter dem Cluster, um die es hier geht, bleibt cloud-init der Standard.
Ich dachte, der Private Key liegt im State
Beim Aufbau fällt irgendwann auf: Der WireGuard-Private-Key des Servers wandert als Variable in user_data, und user_data gehört zur Server-Ressource. Der State liegt in einem S3-Bucket bei Hetzner. Die naheliegende Schlussfolgerung: Ohne Gegenmaßnahme läge der private Schlüssel des einzigen Admin-Zugangs dort im Klartext.
Die Schlussfolgerung klingt so plausibel, dass ich sie fast aufgeschrieben hätte, ohne nachzumessen. Der Messwert sagt etwas anderes:
tofu state pull | grep -c "$TF_VAR_wg_server_private_key"
0
Der Key steht nicht im State. Warum, zeigt der Quellcode des hcloud-Providers: das user_data-Attribut hat eine StateFunc, die statt des Inhalts nur eine Prüfsumme ablegt.
func userDataHashSum(userData string) string {
sum := sha1.Sum([]byte(userData))
return base64.StdEncoding.EncodeToString(sum[:])
}
Im State steht also nicht mein cloud-init-Dokument, sondern 28 Zeichen SHA-1-Hash. Entwarnung? Nein. Erstens ist das ein Implementierungsdetail dieses einen Providers, kein Versprechen von OpenTofu. Ressourcen wie tls_private_key oder random_password legen ihre Secrets vollständig in den State, und sobald auf diesem Server Kubernetes einzieht, liegen dort echte Token. Zweitens gibt es einen Ort, an dem die Variablenwerte garantiert im Klartext landen, auch die sensitiven: gespeicherte Plan-Dateien. Eine Plan-Datei ist ein Zip-Archiv, und im entpackten Inhalt ist der Wert greppbar:
tofu plan -out=demo.tfplan
unzip -p demo.tfplan tfplan | grep -c "$TF_VAR_secret"
1
Das Beispiel läuft in einem Modul ohne Verschlüsselung. Mit dem encryption-Block von unten ist dieselbe Plan-Datei nicht einmal mehr ein Zip-Archiv, unzip bricht sofort ab.
Wer erst verschlüsselt, wenn das erste echte Secret im State liegt, verschlüsselt zu spät. OpenTofu kann seit Version 1.7 State und Plan clientseitig verschlüsseln, bevor sie das eigene System verlassen. Terraform kann das bis heute nicht, das Feature ist einer der handfesten Unterschiede zwischen den beiden Projekten:
terraform {
encryption {
key_provider "pbkdf2" "passphrase" {
passphrase = var.state_passphrase
}
method "aes_gcm" "default" {
keys = key_provider.pbkdf2.passphrase
}
state {
method = method.aes_gcm.default
enforced = true
}
plan {
method = method.aes_gcm.default
enforced = true
}
}
}
enforced = true sorgt dafür, dass niemals ein unverschlüsselter State und keine unverschlüsselte Plan-Datei geschrieben wird, auch nicht als Fallback. Im Bucket liegt danach nur noch ein Umschlag aus Metadaten und AES-GCM-Ciphertext, keine Klartext-Ressourcen. Die Passphrase kommt aus der Umgebungsvariable TF_VAR_state_passphrase und gehört in den Passwortmanager. Das ist der eine Preis dieser Lösung: Wer die Passphrase verliert, kann den State nicht mehr lesen.
Abbauen und wieder hochziehen, ohne den Endpoint zu verlieren
Ein Server, der komplett aus Code entsteht, lädt dazu ein, ihn nicht durchlaufen zu lassen. Abends abbauen, morgens neu hochziehen, für ein Experiment zwischendurch eine frische Instanz. Dem steht ein Detail im Weg: Bei jedem Neuaufbau vergibt Hetzner eine neue öffentliche IP, und die steht als Endpoint in der WireGuard-Konfiguration auf dem Client. Jeder Neuaufbau hieße: Client-Konfiguration anfassen.
Die Lösung ist eine Primary IP mit eigenem Lebenszyklus:
resource "hcloud_primary_ip" "next" {
name = "next"
type = "ipv4"
location = "fsn1"
auto_delete = false
}
Der Server bindet diese IP über public_net, und auto_delete = false verhindert, dass Hetzner sie beim Löschen des Servers mit entsorgt. Zum Abbauen zerstörst du dann gezielt nur den Server, nicht das ganze Modul:
tofu destroy -target=hcloud_server.next
-target ist in der täglichen Arbeit mit Recht verpönt, weil es den State selektiv verändert und Abhängigkeiten umgeht. Genau hier ist es aber das richtige Werkzeug: Es gibt eine Ressource mit kurzem Lebenszyklus (der Server) und eine mit langem (die IP), und beide leben bewusst im selben Modul. Ein tofu destroy ohne -target würde die IP mit löschen, und der nächste Aufbau bekäme eine neue Adresse. Unassigned kostet die Primary IP ein paar Cent im Monat. Das ist der Preis für einen Endpoint, der sich nie ändert.
Der Wiederaufbau ist danach ein einziges tofu apply. Die WireGuard-Keys kommen aus den Umgebungsvariablen und bleiben dieselben, der Tunnel auf dem Client verbindet sich von selbst neu, sobald der Server wieder antwortet.
Die Sache mit den Host-Keys
Eine Überraschung bleibt beim ersten Neuaufbau trotzdem. SSH begrüßt dich mit seiner dramatischsten Warnung:
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
Was ist passiert? Jeder frisch installierte Server generiert beim ersten Boot eigene SSH-Host-Keys. Der neue Server ist für SSH ein fremder Rechner, der zufällig unter der alten Adresse antwortet. Genau das Szenario, vor dem diese Warnung schützen soll: Unter einer bekannten Adresse meldet sich plötzlich eine andere Maschine. SSH kann nicht wissen, dass du das selbst warst.
Die Warnung ist also kein Fehler im Setup, sondern SSH, das seinen Job macht. Bei Servern mit Wegwerf-Lebenszyklus gehört der Griff zu ssh-keygen -R deshalb zum Alltag:
ssh-keygen -R 10.100.0.1
Das entfernt den alten Host-Key-Eintrag aus ~/.ssh/known_hosts. Beim nächsten Login bestätigst du den neuen Fingerprint, und ab dann ist wieder Ruhe, bis zum nächsten Neuaufbau. Wichtig ist zu verstehen, was du da bestätigst: SSH arbeitet nach dem Prinzip Trust On First Use. Der erste Kontakt legt fest, welchem Schlüssel künftig vertraut wird. In einem privaten Tunnel, in dem außer dem Server niemand auf 10.100.0.1 antworten kann, ist diese Bestätigung unkritisch. Bei einer öffentlichen Adresse wäre der Moment, den Fingerprint ungeprüft durchzuwinken, genau der falsche.
Der Nachweis von außen
Am Ende steht die Frage: Stimmt die Behauptung vom unsichtbaren Server? Das lässt sich von jedem Rechner außerhalb des Tunnels prüfen, und dabei lohnt ein Blick auf ein Detail, das oft übersehen wird. Es gibt zwei Arten, wie ein Port zu sein kann:
nc -z -w3 203.0.113.10 22
Kommt die Antwort sofort als connection refused, hat der Server selbst geantwortet: Kein Dienst auf dem Port, aber der Server ist erreichbar und die Firewall hat das Paket durchgelassen. Läuft der Versuch dagegen in den Timeout, hat die Firewall das Paket still verworfen, bevor es den Server erreicht hat. Nur der Timeout beweist, dass die Firewall wirklich dranhängt. Ein connection refused auf Port 22 wäre bei diesem Aufbau ein Alarmsignal. Die ganze Mechanik dahinter, samt der Frage, wann ein REJECT die ehrlichere Antwort wäre, steht in Connection refused oder Timeout. Für diesen Server ist die Abwägung kurz: Es gibt keinen legitimen Client, der auf Port 22 eine schnelle Absage braucht, deshalb DROP. Und die Sicherheit kommt ohnehin nicht aus DROP statt REJECT, sondern daraus, dass hinter dem Port nichts lauscht.
Beim neuen Server laufen alle TCP-Ports in den Timeout, Ping antwortet, und der WireGuard-Port ist prinzipbedingt nicht von einem geschlossenen zu unterscheiden. Von außen existiert dieser Server nicht. Für mich, mit dem richtigen Schlüssel, vollständig.
Fazit
- Die kleinste Angriffsfläche hat die Tür, die es von außen nicht gibt: Firewall auf ICMP plus einen UDP-Port reduzieren, alles Administrative durch WireGuard.
- WireGuard antwortet ohne gültigen Schlüssel gar nicht. Der Admin-Zugang taucht in keinem Port-Scan auf.
- cloud-init statt remote-exec: Der Server baut sich beim ersten Boot selbst, OpenTofu braucht keinen SSH-Zugang und keine offene Tür.
- Der WireGuard-Private-Key landet beim hcloud-Provider nur als SHA-1-Hash im State, aber das ist ein Implementierungsdetail, kein Versprechen. Plan-Dateien tragen sensitive Variablenwerte im Klartext. Clientseitige Verschlüsselung von State und Plan ab OpenTofu 1.7 ist deshalb Pflicht, nicht Kür, und ein Feature, das Terraform nicht hat.
- Eine Primary IP mit auto_delete false plus tofu destroy -target=hcloud_server.next macht den Server zum Wegwerf-Artikel mit stabilem Endpoint.
- Die SSH-Warnung nach dem Neuaufbau ist kein Fehler, sondern Trust On First Use in Aktion. ssh-keygen -R räumt den alten Host-Key weg, der neue Fingerprint wird einmal bewusst bestätigt.
Eine Grenze der Wegwerf-These gehört dazu: Sie gilt für die Maschine, nicht für alles darunter. Die cloud-config ist portabel, das Tofu-Modul für Netz, IP und Firewall ist anbieterspezifisch und wird bei einem Anbieterwechsel neu geschrieben. Portabel ist an diesem Aufbau vor allem die Betriebsweise: Maschine aus Code, Konfiguration per API, kein Login, Neubau statt Reparatur. Wer die Zahl anbieterspezifischer Ressourcen bewusst klein hält, hält diese Rechnung klein, aber null wird sie nicht.
Das ist die Zwei-Säulen-Idee am Fundament einer Plattform: Jedes Teil dieses Aufbaus ist ein offenes Werkzeug, das du komplett verstehen und im Ernstfall selbst betreiben kannst. WireGuard im Kernel, OpenTofu unter der Linux Foundation, cloud-init als Standard jeder Cloud. Auf diesem Server entsteht als Nächstes ein gehärtetes k3s, danach GitOps und Secrets-Management. Der Weg dorthin beginnt damit, dass die unterste Schicht keine offenen Türen hat.
Warum die Wahl auf OpenTofu statt Terraform fällt, steht in Terraform nach OpenTofu migrieren, und was es mit dem Vertriebsweg der Provider auf sich hat, in Wer liefert deine Provider? Der nächste Teil dieser Serie bringt ein gehärtetes k3s und einen Flux-Bootstrap, der selbst Code ist, auf genau diesen Server.
Das Video zu diesem Beitrag zeigt jede der Messungen im Terminal. Den Spickzettel „Der Wegwerf-Server” mit den Firewall-Minimalregeln, der WireGuard-Vorlage, dem encryption-Block, dem Wegwerf-Rezept und den Nachweis-Kommandos gibt es über den Newsletter.
© 2026 Trutz Software Consulting GmbH. CC BY 4.0: Dieser Text darf mit Namensnennung weiterverwendet und bearbeitet werden, auch kommerziell. Code-Beispiele darin stehen unter Apache 2.0. Ausgenommen sind Marke und Logo sowie gekennzeichnete Zitate Dritter. Was das genau heißt.
Newsletter
Erhalte praxisnahe Tutorials, Best Practices und echte Lösungen für die häufigsten DevOps-Probleme direkt in Dein Postfach.
Datenschutz: Deine E-Mail-Adresse wird DSGVO-konform in Deutschland gespeichert. Keine Spam, nur relevante Inhalte. Eine E-Mail alle 1 bis 2 Wochen.