← Übersicht  ·  Skripte & Dateien  ·  Kosten sparen · Server wegwerfen & wiederherstellen

Server per Snapshot wegwerfen & neu aufbauen

Ein Hetzner-Server kostet, solange er existiert – herunterfahren spart nichts. Der billige Weg für Server, die du nur zeitweise brauchst: sauber stoppen → Snapshot → löschen → bei Bedarf identisch aus dem Snapshot neu. Was dabei genau passiert, was sich ändert (die IP!), und ein Skript für einen oder mehrere Server.

Stand: 29. August 2026 gilt für alle cloud-host-Guides ~0,30 €/Monat statt ~8

Platzhalter einsetzen – werden in allen Befehlen unten ersetzt, nur im Browser, nichts wird gesendet

01 Was ein Snapshot ist

Ein voller Abzug der Server-Platte zu genau diesem Zeitpunkt.

  • Enthält: OS (Betriebssystem), Container-Engine, alle gepullten Images, alle Volumes / Daten, Configs, .env, compose-Dateien, systemd-Units – alles.
  • Beim up wird nichts neu aus der Registry gezogen – Container/Pods starten aus dem lokalen Cache. Schnell.
  • Preis: 0,0143 €/GB (Gigabyte)/Monat (Stand Aug 2026), stundengenau, nach tatsächlich belegter, komprimierter Größe. Ein Container-Host mit 3–5 GB → ~4–7 Cent/Monat.
  • Verwaltung: Console → Images → Snapshots, oder hcloud image list --type snapshot.
  • Server löschen entfernt Snapshots nicht. Sie bleiben, bis du sie löschst. Standard-Limit ~25 Snapshots/Projekt (per Support erhöhbar).
  • Snapshot ≠ Backups-Feature (das ist +20 % pauschal, letzte 7, automatisch, und wird mit dem Server gelöscht).

02 Der Zyklus: down → up

Laptop · PowerShell – DOWN (von Hand)
# 1) Dienste sauber stoppen + Puffer leeren (Konsistenz der Datenbank):
ssh root@k3s-host-ip "systemctl stop k3s; sync"

# 2) Snapshot (wartet, 1-4 Min):
hcloud server create-image --type snapshot --description "k3s-host" k3s-host

# 3) alte Snapshots dieses Servers aufraeumen (die 2 neuesten behalten) -> siehe snapshot.ps1

# 4) Server loeschen -> Rechenzeit-Kosten stoppen SOFORT:
hcloud server delete k3s-host

# 5) veralteten Host-Key rauswerfen:
ssh-keygen -R <alte-ip>
Laptop · PowerShell – UP (von Hand)
# 1) neuesten Snapshot per Beschreibung finden:
$snap = hcloud image list --type snapshot -o json | ConvertFrom-Json |
        Where-Object description -eq "k3s-host" |
        Sort-Object { [datetime]$_.created } -Descending | Select-Object -First 1

# 2) Server daraus erstellen (Firewall wieder anhaengen, ggf. feste IP + Netz):
hcloud server create --name k3s-host --type cpx32 `
  --image $snap.id --ssh-key laptop --firewall k3s-fw

# 3) auf SSH warten (erster Boot 20-60 s), 4) Verbindung neu setzen (podman/kubeconfig), 5) fertig

Die Dienste laufen nach dem up von selbst wieder an – sie waren im Snapshot aktiv bzw. systemd/--restart startet sie. Das alles macht snapshot.ps1 in einem Befehl.

03 Was sich beim Neuaufbau ändert

ändert sich?Folge & Fix
Öffentliche IPv4 (Internet Protocol Version 4)JA – neue Zufalls-IPpodman system connection zeigt ins Leere, kubeconfig server: falsch, DNS-A-Record veraltet, --tls-san im k3s-Zertifikat stimmt nicht → feste IP (Schritt 4) oder überall nachziehen (das Skript macht Letzteres).
SSH-Host-KeyJA – neuer Serverknown_hosts-Warnung → ssh-keygen -R <ip> + StrictHostKeyChecking=accept-new.
Private IP (im Netz)JA, außer du setzt sie festCluster-Nodes finden sich nicht → attach-to-network --ip <feste-ip>.
Hostname, MACs, Interface-Namennein (Hetzner stabil: eth0)
Volume-Daten, Images, Configsnein – im Snapshotdas ist der Zweck.
Server-ID / /etc/machine-idneue Server-ID, machine-id bleibt (aus dem Snapshot)meist egal, manche Tools/Lizenzen binden an machine-id – dann bewusst neu: rm /etc/machine-id; systemd-machine-id-setup vor dem Snapshot.

04 Feste IP: Primary IP

Eine Primary IP ist eine reservierte IPv4/IPv6 (Internet Protocol Version 6), die du behältst, auch wenn der Server weg ist. Kostet 0,50 €/Monat, egal ob gerade an einem Server oder nicht. Dafür ändert sich die IP nie – DNS-Record, kubeconfig, podman-connection, --tls-san bleiben über jeden Zyklus gültig.

Laptop · PowerShell (einmalig)
# Datacenter muss zur --location deiner Server passen:
#   nbg1 -> nbg1-dc3   fsn1 -> fsn1-dc14   hel1 -> hel1-dc2   ash -> ash-dc1   hil -> hil-dc1
hcloud primary-ip create --type ipv4 --name <ipname> --datacenter nbg1-dc3

# beim up anhaengen:
hcloud server create ... --primary-ipv4 <ipname>

# wieder loswerden:
hcloud primary-ip delete <ipname>
Lohnt sich ab

Du hast einen DNS-Namen drauf, oder willst kubeconfig / podman-connection nicht jedes Mal neu setzen. Für ein reines Wegwerf-Lab ohne Domain: weglassen – das Skript setzt die Verbindung ohnehin neu.

05 Ohne feste IP: DNS automatisch nachziehen

Willst du die 0,50 €/Monat für die Primary IP sparen und trotzdem eine Domain drauf haben: nach jedem up den A-Record auf die neue IP setzen. Das macht cf-dns.ps1 (Cloudflare-API), aufgerufen aus dem up-Ablauf.

Laptop · PowerShell – Token einmalig setzen
# Cloudflare -> My Profile -> API Tokens -> Create Token
# Vorlage "Edit zone DNS",  Zone Resources: Specific zone -> deine Zone
setx CF_API_TOKEN "cf_xxxxxxxx..."         # dauerhaft (gilt in NEUEN Fenstern)
$env:CF_API_TOKEN = "cf_xxxxxxxx..."       # dieses Fenster jetzt
Laptop · PowerShell – manuell testen
# a) trocken: prueft Token + Zone, aendert nichts
#    '@' = Apex-Domain, IN ANFUEHRUNGSZEICHEN (@ ist in PowerShell sonst ein Sonderzeichen; 'apex' geht auch)
.\scripts\cf-dns.ps1 -Zone seb4u.com -Name '@' -IP 203.0.113.10 -DryRun

# b) echter Round-Trip mit einer Wegwerf-Subdomain (203.0.113.10 = Doku-IP)
.\scripts\cf-dns.ps1 -Zone seb4u.com -Name test -IP 203.0.113.10
Start-Sleep 30
Resolve-DnsName test.seb4u.com -Server 1.1.1.1        # sollte 203.0.113.10 zeigen
# danach test.seb4u.com in Cloudflare -> DNS -> Records wieder loeschen

# c) so wird es spaeter benutzt
.\scripts\cf-dns.ps1 -Zone seb4u.com -Name blog,vault,'@' -Server k3s-host   # IP per hcloud
.\scripts\cf-dns.ps1 -Zone seb4u.com -Name www -IP 203.0.113.10 -Proxied
.\scripts\cf-dns.ps1 -Zone seb4u.com -Name blog,vault,'@' -Delete           # A-Records entfernen
Was die Parameter bedeuten

Drei Aufrufarten in einem Block – trocken prüfen, einmal echt durchspielen, dann der Alltag. Die Reihenfolge ist Absicht: DNS-Fehler fällt man erst Stunden später auf.

Parametersteht beibewirkt
.\scripts\cf-dns.ps1 Aufruf Das .\ am Anfang ist Pflicht, keine Zierde: PowerShell führt aus Sicherheitsgründen nichts aus dem aktuellen Verzeichnis aus, wenn es nicht ausdrücklich so angegeben ist – sonst könnte eine Datei namens ls.ps1 im Download-Ordner den echten Befehl verdraengen. Ohne das Präfix meldet PowerShell, der Begriff sei nicht erkannt, obwohl die Datei sichtbar danebenliegt. Beim ersten Aufruf kommt außerdem die Frage nach der Ausführungsrichtlinie (Set-ExecutionPolicy -Scope CurrentUser RemoteSigned).
-DryRun a) Prüft Token und Zone und zeigt, was passieren würde, ohne einen Record anzufassen. Der lohnende erste Aufruf: ein falsches Token oder ein Tippfehler im Zonennamen fällt hier auf – und nicht erst, wenn ein echter Eintrag überschrieben wurde.
-Name '@' a), c) @ ist im DNS die Kurzschreibweise für die Domain selbst (seb4u.com ohne Subdomain), „Apex“ genannt. Die Anführungszeichen sind Pflicht: unmaskiert leitet @ in PowerShell ein Array oder eine Hashtable ein, und der Aufruf scheitert mit einer Syntaxmeldung, die nichts mit DNS zu tun hat. 'apex' tut dasselbe ohne Sonderzeichen.
-Name blog,vault,'@' c) Kommagetrennt – PowerShell macht daraus ein Array, und das Skript setzt alle drei Records in einem Durchgang. Kein Leerzeichen nach den Kommas, sonst liest PowerShell die weiteren Namen als eigene positionale Argumente.
-Server k3s-host
statt -IP …
c) Die beiden schließen einander aus: -IP setzt eine feste Adresse, -Server lässt das Skript sie per hcloud vom laufenden Server holen. Im Alltag – nach jedem up aus der Snapshot-Anleitung – ist -Server der richtige Weg, denn die Adresse ist dann jedes Mal eine andere.
-Proxied c) Schaltet Cloudflares Proxy ein (die orange Wolke): der Verkehr läuft dann über Cloudflare, die echte Server-Adresse bleibt verborgen, TLS endet dort. Nicht setzen für Dienste, die nicht über HTTP laufen – SSH oder ein Mailserver hinter dem Proxy sind schlicht nicht mehr erreichbar.
-Delete c) Entfernt die A-Records wieder. Gehört in den down-Weg: bleibt ein Record auf eine Adresse zeigen, die Hetzner inzwischen an jemand anderen vergeben hat, verweist die eigene Domain auf einen fremden Server.
Resolve-DnsName … -Server 1.1.1.1 b) Fragt gezielt einen öffentlichen Resolver statt des lokalen. Der eigene Rechner und der Router haben die alte Antwort meist noch zwischengespeichert und würden bestätigen, was vor der Änderung galt.
203.0.113.10 a), b) Keine willkürliche Zahl: 203.0.113.0/24 ist ein reservierter Dokumentationsbereich, der nirgends geroutet wird. Ein Testeintrag darauf kann also niemanden treffen – anders als eine ausgedachte Adresse, die jemandem gehört.
Start-Sleep 30 b) Cloudflare braucht ein paar Sekunden, bis die Änderung an allen Standorten steht. Sofort abgefragt kommt noch die alte Antwort – und man sucht den Fehler im Skript statt in der Wartezeit.

In die Server-Konfig von snapshot.ps1 eintragen – dann läuft es bei jedem up automatisch (und down entfernt die Records wieder, damit die Domain nicht auf eine inzwischen fremde IP zeigt):

Datei · snapshot.ps1 → $SERVERS-Block
    ddnsZone    = 'seb4u.com'          # leer = kein DDNS
    ddnsNames   = @('blog','vault','@')
    ddnsProxied = $false               # $true = Cloudflare-Proxy (orange Wolke)
Feste IP vs. DDNS (Dynamic DNS)

Die Primary IP ist ruhiger (nichts ändert sich, Zertifikate bleiben, kein Token). DDNS spart die 0,50 €, hat aber jedes Mal ein kurzes Fenster, bis der A-Record propagiert (TTL, Standard 60 s) – und der API-Token liegt auf dem Laptop. Für einen produktiven Dienst: Primary IP. Für ein Wegwerf-Setup, das du selten hochfahrst: DDNS.

06 Mehrere Server / Cluster

  • Jeder Server bekommt seinen eigenen Snapshot (eigene --description). snapshot.ps1 iteriert über eine Server-Liste. -Only name1,name2 für Teilmengen.
  • k3s-/OKD-Cluster übersteht down/up nur, wenn:
    • jeder Node seine feste private IP behält (network + privateIP in der Config),
    • der/die Server-Node(s) ihre feste öffentliche IP behalten (reservedIP) → API-Zertifikat + kubeconfig bleiben gültig,
    • alle Nodes ~gleichzeitig wieder hochkommen.
  • Ehrlich: für einen echten Multi-Node-Cluster ist Neuinstallation (der deploy.ps1 aus dem Bibliothek-Guide, ~20 Min) oft robuster als Snapshot-Restore. Snapshot/Restore glaenzt bei 1 Node.

07 snapshot.ps1

Robuster als das cloud.ps1 aus den Anleitungen: mehrere Server, feste IP, festes privates Netz, Snapshot-Pruning, Kosten-Anzeige. Liegt im cloud-host-Ordner.

Laptop · PowerShell
.\scripts\snapshot.ps1 status                 # was laeuft, ~Kosten, Snapshot-Inventar
.\scripts\snapshot.ps1 down                   # alle Server: Hook -> Snapshot -> pruzen -> loeschen
.\scripts\snapshot.ps1 up                     # alle Server: aus neuestem Snapshot neu, Hook danach
.\scripts\snapshot.ps1 down -Only k3s-host    # nur einer
.\scripts\snapshot.ps1 snapshots              # alle Snapshots + monatliche Kosten
.\scripts\snapshot.ps1 prune -Keep 2          # je Server nur die 2 neuesten behalten
Datei · cloud-host/snapshot.ps1
#Requires -Version 5.1
<#
    snapshot.ps1  -  Hetzner-Server als Snapshot sichern, loeschen und spaeter
    identisch wieder aufbauen. Mehrere Server, feste (reservierte) IP optional.

      .\scripts\snapshot.ps1 status                 was laeuft, seit wann, ~Kosten, Snapshot-Inventar
      .\scripts\snapshot.ps1 down                   alle Server: Hook -> Snapshot -> alte pruzen -> loeschen
      .\scripts\snapshot.ps1 up                     alle Server: aus neuestem Snapshot neu, Hook danach
      .\scripts\snapshot.ps1 down -Only k3s-host    nur einzelne
      .\scripts\snapshot.ps1 up   -Only k3s-host
      .\scripts\snapshot.ps1 snapshots              alle Snapshots + geschaetzte monatliche Kosten
      .\scripts\snapshot.ps1 prune  -Keep 2         je Server nur die N neuesten Snapshots behalten

    Voraussetzungen:  hcloud CLI mit Kontext (hcloud context create ...), SSH-Key bei Hetzner,
    Firewall(s) existieren. Fuer feste IP: einmalig eine Primary-IP anlegen, z.B.
      hcloud primary-ip create --type ipv4 --name k3s-ip --datacenter nbg1-dc3
    und ihren Namen unten bei reservedIP eintragen (~0,50 EUR/Monat, IP bleibt stabil).
#>
param(
  [Parameter(Position = 0)]
  [ValidateSet('status', 'down', 'up', 'snapshots', 'prune')]
  [string]$Action = 'status',
  [string[]]$Only,
  [int]$Keep = 2
)

# Kein globales 'Stop': hcloud und ssh schreiben Hinweise ("Server not found",
# Fortschritt) nach stderr. PowerShell 5.1 wertet das unter 'Stop' faelschlich als
# abbrechenden Fehler. Wichtige Aufrufe pruefen $LASTEXITCODE selbst.
$ErrorActionPreference = 'Continue'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

# ============================ KONFIGURATION ============================
# Ein Hashtable je Server. Optionale Felder leer lassen ('').
# preDown  : bash-Befehl auf dem Server, um Dienste sauber zu stoppen (vor dem Snapshot).
# postUp   : Scriptblock auf dem Laptop nach 'up'; bekommt die neue/feste IP als $args[0].
$SERVERS = @(
  @{
    name       = 'k3s-host'
    type       = 'cpx32'                                   # cpx22 | cpx32 | cax21 ...
    image      = 'ubuntu-24.04'                           # nur beim allerersten 'up' (kein Snapshot da)
    sshKey     = 'laptop'
    firewall   = 'k3s-fw'
    location   = 'nbg1'                                   # '' = Hetzner waehlt
    reservedIP = ''                                       # Name einer Primary-IP; '' = jedes Mal neue IP
    network    = ''                                       # Name eines privaten Netzes; '' = keins
    privateIP  = ''                                       # feste private IP (nur mit network)
    userData   = ''                                       # Pfad zu cloud-init.yaml (nur Erst-'up')
    ddnsZone   = ''                                       # z.B. 'seb4u.com' - leer = kein DDNS
    ddnsNames  = @()                                      # z.B. @('blog','vault','@')
    ddnsProxied = $false                                  # Cloudflare-Proxy fuer diese Records
    preDown    = 'systemctl stop k3s'
    postUp     = {
      param($ip)
      $kube = Join-Path $env:USERPROFILE '.kube\k3s-hetzner.yaml'
      scp -o StrictHostKeyChecking=accept-new "root@${ip}:/etc/rancher/k3s/k3s.yaml" $kube
      (Get-Content $kube) -replace '127\.0\.0\.1', $ip | Set-Content $kube
      [Environment]::SetEnvironmentVariable('KUBECONFIG', $kube, 'User')
      Write-Host "  KUBECONFIG -> $kube ($ip)"
    }
  }
  # Weitere Server als zusaetzliche @{ ... }-Bloecke hier einfuegen.
)

# Stundensaetze (EUR): im Repo aus scripts/rates.psd1 geladen (eine Quelle fuer
# beide Skripte + kosten.html). Hier inline als Fallback - Stand nach 15.06.2026.
$RatePerHour       = @{ cpx22  = 0.0075; cpx32  = 0.0116; cpx42  = 0.0219; cpx52 = 0.0404
                        cax11 = 0.0082; cax21 = 0.0144; cax31 = 0.0288; cax41 = 0.0562
                        cpx22 = 0.0267; cpx32 = 0.0486; cpx42 = 0.0952; cpx52 = 0.1377; cpx62 = 0.1781
                        ccx13 = 0.0589; ccx23 = 0.1178; ccx33 = 0.1897 }
$SnapRatePerGBMonth = 0.0143     # Stand nach 15.06.2026 (unveraendert)
$WarnHours         = 12
# =====================================================================

function Require-Hcloud {
  if (-not (Get-Command hcloud -ErrorAction SilentlyContinue)) {
    throw "hcloud CLI fehlt. Installieren: winget install HetznerCloud.CLI"
  }
}
function Targets {
  if ($Only) { return $SERVERS | Where-Object { $Only -contains $_.name } }
  return $SERVERS
}
function Server-Info([string]$name) {
  $raw = hcloud server describe $name -o json 2>$null
  if ($LASTEXITCODE -ne 0 -or -not $raw) { return $null }
  try { return (($raw -join "`n") | ConvertFrom-Json) } catch { return $null }
}
function Snapshots-For([string]$name) {
  $raw = hcloud image list --type snapshot -o json
  if ($LASTEXITCODE -ne 0 -or -not $raw) { return @() }
  try { $list = ($raw -join "`n") | ConvertFrom-Json } catch { return @() }
  $list | Where-Object { $_.description -eq $name } |
    Sort-Object { [datetime]$_.created } -Descending
}
function Forget-HostKey([string]$hostOrIp) {
  if ($hostOrIp) { ssh-keygen -R $hostOrIp *> $null }
}
function Wait-Ssh([string]$ip) {
  Forget-HostKey $ip
  for ($i = 0; $i -lt 60; $i++) {
    ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=5 -o BatchMode=yes `
        "root@$ip" 'true' 2>$null | Out-Null
    if ($LASTEXITCODE -eq 0) { return $true }
    Start-Sleep 5
  }
  return $false
}

function Invoke-Down([hashtable]$s) {
  $info = Server-Info $s.name
  if (-not $info) { Write-Host "  $($s.name): schon weg."; return }
  $ip = $info.public_net.ipv4.ip

  if ($s.preDown) {
    Write-Host "  $($s.name): stoppe Dienste ..."
    ssh -o StrictHostKeyChecking=accept-new "root@$ip" "$($s.preDown)" 2>$null | Out-Null
    ssh -o StrictHostKeyChecking=accept-new "root@$ip" 'sync' 2>$null | Out-Null
  }

  Write-Host "  $($s.name): Snapshot wird erstellt (1-4 Min) ..."
  hcloud server create-image --type snapshot --description $s.name `
    --label 'managed-by=snapshot-ps1' $s.name | Out-Null
  if ($LASTEXITCODE -ne 0) {
    Write-Warning "  $($s.name): Snapshot fehlgeschlagen - Server wird NICHT geloescht."
    return
  }

  # alte Snapshots dieses Servers aufraeumen, die N neuesten behalten
  Snapshots-For $s.name | Select-Object -Skip $Keep | ForEach-Object {
    hcloud image delete $_.id | Out-Null
  }

  hcloud server delete $s.name | Out-Null
  Forget-HostKey $ip

  # DDNS: A-Records entfernen (nur ohne reservierte IP)
  if ($s.ddnsZone -and $s.ddnsNames -and -not $s.reservedIP) {
    $cf = Join-Path $PSScriptRoot 'cf-dns.ps1'
    if (Test-Path $cf) {
      try { & $cf -Zone $s.ddnsZone -Name $s.ddnsNames -Delete } catch { Write-Warning "  DDNS: $($_.Exception.Message)" }
    }
  }

  $keptIp = if ($s.reservedIP) { "  Reservierte IP '$($s.reservedIP)' bleibt (~0,50 EUR/Monat)." } else { '' }
  Write-Host "  $($s.name): geloescht.$keptIp" -ForegroundColor Green
}

function Invoke-Up([hashtable]$s) {
  if (Server-Info $s.name) { Write-Host "  $($s.name): laeuft bereits."; return }

  $snap  = Snapshots-For $s.name | Select-Object -First 1
  $image = if ($snap) { "$($snap.id)" } else { $s.image }
  Write-Host "  $($s.name): erstelle aus $(if($snap){"Snapshot $($snap.id)"}else{"Basis-Image $image"}) ..."

  $a = @('server', 'create', '--name', $s.name, '--type', $s.type, '--image', $image, '--ssh-key', $s.sshKey)
  if ($s.firewall)   { $a += @('--firewall', $s.firewall) }
  if ($s.location)   { $a += @('--location', $s.location) }
  if ($s.reservedIP) { $a += @('--primary-ipv4', $s.reservedIP) }
  if ($s.network)    { $a += @('--network', $s.network) }
  if (-not $snap -and $s.userData) { $a += @('--user-data-from-file', $s.userData) }
  hcloud @a | Out-Null
  if ($LASTEXITCODE -ne 0) { Write-Warning "  $($s.name): Server-Erstellung fehlgeschlagen."; return }

  # feste private IP (falls gewuenscht): neu anhaengen
  if ($s.network -and $s.privateIP) {
    hcloud server detach-from-network $s.name --network $s.network 2>$null | Out-Null
    hcloud server attach-to-network   $s.name --network $s.network --ip $s.privateIP | Out-Null
  }

  $ip = if ($s.reservedIP) {
    (hcloud primary-ip describe $s.reservedIP -o 'format={{.IP}}').Trim()
  } else {
    (hcloud server ip $s.name).Trim()
  }

  if (Wait-Ssh $ip) {
    if ($s.ddnsZone -and $s.ddnsNames) {          # DNS-A-Record auf die neue IP (cf-dns.ps1)
      $cf = Join-Path $PSScriptRoot 'cf-dns.ps1'
      if (Test-Path $cf) {
        try { & $cf -Zone $s.ddnsZone -Name $s.ddnsNames -IP $ip -Proxied:([bool]$s.ddnsProxied) }
        catch { Write-Warning "  DDNS: $($_.Exception.Message)" }
      }
    }
    if ($s.postUp) {
      Write-Host "  $($s.name): postUp-Hook ..."
      & $s.postUp $ip
    }
    Write-Host "  $($s.name): bereit ($ip)." -ForegroundColor Green
  } else {
    Write-Warning "  $($s.name): Server $ip da, aber SSH noch nicht erreichbar. Spaeter: .\scripts\snapshot.ps1 status"
  }
}

function Show-Status {
  $sumRun = 0.0
  foreach ($s in (Targets)) {
    $info = Server-Info $s.name
    if (-not $info) { Write-Host ("  {0,-16} AUS" -f $s.name) -ForegroundColor Green; continue }
    $age  = (Get-Date).ToUniversalTime() - ([datetime]$info.created).ToUniversalTime()
    $rt   = $info.server_type.name                        # echter Typ, nicht die Config
    $rate = $RatePerHour[$rt]
    if ($rate) { $sumRun += $age.TotalHours * $rate }
    $flag = if ($age.TotalHours -gt $WarnHours) { ' (!)' } else { '' }
    Write-Host ("  {0,-16} AN {1,-7} seit {2}d {3:00}h  {4,-15} {5}" -f `
      $s.name, $rt, $age.Days, $age.Hours, $info.public_net.ipv4.ip, $flag) -ForegroundColor Yellow
  }
  Write-Host ""
  Show-Snapshots
  Write-Host ("`nLaufende Server: ~{0:N2} EUR bisher." -f $sumRun) -ForegroundColor Yellow
}

function Show-Snapshots {
  $raw = hcloud image list --type snapshot -o json
  if ($LASTEXITCODE -ne 0 -or -not $raw) { Write-Warning "  hcloud image list fehlgeschlagen."; return }
  try { $all = ($raw -join "`n") | ConvertFrom-Json } catch { Write-Warning "  Snapshot-Liste nicht lesbar."; return }
  $mine = $all | Where-Object { ($SERVERS.name) -contains $_.description } |
          Sort-Object description, { [datetime]$_.created }
  if (-not $mine) { Write-Host "  Keine Snapshots."; return }
  $totalGb = 0.0
  Write-Host ("  {0,-16} {1,-12} {2,8}   {3}" -f 'SERVER', 'SNAPSHOT-ID', 'GROESSE', 'ERSTELLT')
  foreach ($i in $mine) {
    $gb = [double]$i.image_size
    $totalGb += $gb
    Write-Host ("  {0,-16} {1,-12} {2,6:N2} GB   {3}" -f $i.description, $i.id, $gb, ([datetime]$i.created).ToString('yyyy-MM-dd HH:mm'))
  }
  Write-Host ("  = {0:N1} GB  ->  ~{1:N2} EUR/Monat Snapshot-Speicher" -f $totalGb, ($totalGb * $SnapRatePerGBMonth))
}

function Invoke-Prune {
  foreach ($s in (Targets)) {
    $old = Snapshots-For $s.name | Select-Object -Skip $Keep
    foreach ($i in $old) {
      Write-Host "  loesche $($s.name) / $($i.id) ($([datetime]$i.created))"
      hcloud image delete $i.id | Out-Null
    }
  }
  Write-Host "Fertig. Je Server bleiben die $Keep neuesten." -ForegroundColor Green
}

Require-Hcloud
switch ($Action) {
  'status'    { Show-Status }
  'snapshots' { Show-Snapshots }
  'prune'     { Invoke-Prune }
  'down'      { Targets | ForEach-Object { Invoke-Down $_ } }
  'up'        { Targets | ForEach-Object { Invoke-Up   $_ } }
}
Die Hebel im Skript

Rund 300 Zeilen, aber nur ein gutes Dutzend Stellen sind wirklich nicht selbsterklärend. Der Rest ist Ausgabe und Schleifen. Diese hier lohnen einen zweiten Blick:

KonstruktStellebewirkt
#Requires -Version 5.1 Zeile 1 Keine Kommentarzeile, sondern eine Zusicherung an PowerShell: das Skript weigert sich zu starten, wenn eine ältere Fassung läuft. 5.1 ist die, die bei Windows 10 und 11 dabei ist – das Skript kommt also ohne Nachinstallation aus, muss dafür aber auf die Bequemlichkeiten von PowerShell 7 verzichten (kein ??, kein &&, kein Ternaeroperator).
[Net.ServicePointManager]::SecurityProtocol = …Tls12 Kopf PowerShell 5.1 verhandelt von sich aus noch TLS 1.0 – das lehnen viele Anbieter (auch Hetzner und Cloudflare) inzwischen ab. Die Zeile stellt das einmalig für diese Sitzung um. Ohne sie scheitern Invoke-RestMethod-Aufrufe mit einer Meldung über eine "zugrunde liegende Verbindung", die den eigentlichen Grund nicht nennt.
[Parameter(Position = 0)]
[ValidateSet('status','down','up',…)]
param-Block Position = 0 erlaubt snapshot.ps1 down statt -Action down. ValidateSet laesst PowerShell die Eingabe prüfen, bevor eine einzige Zeile läuft – ein Tippfehler wie dwon bricht sofort ab, statt später in einem switch stillschweigend nichts zu tun. Nebeneffekt: die Tab-Vervollständigung kennt die Werte.
[string[]]$Only param-Block Die eckigen Klammern machen daraus ein Array. -Only k3s-host,blog filtert damit auf zwei Server, ohne dass das Skript den String selbst zerlegen muss.
$ErrorActionPreference = 'Continue' Kopf Bewusst nicht 'Stop'. hcloud und ssh schreiben harmlose Hinweise („Server not found“, Fortschritt) nach stderr; PowerShell 5.1 wertet das unter Stop als abbrechenden Fehler und das Skript stuerbe mitten im Snapshot ab. Stattdessen prüft jede wichtige Stelle $LASTEXITCODE selbst – die einzige verlaessliche Erfolgsmeldung eines externen Programms.
2>$null | Out-Null
*> $null
überall Zwei verschiedene Dinge: 2>$null wirft nur stderr weg, | Out-Null nur die normale Ausgabe. *> in Forget-HostKey wirft alle Ströme weg – dort ist auch die Warnung unerwünscht, wenn der Eintrag gar nicht existiert.
($raw -join "`n") | ConvertFrom-Json Server-Info, Snapshots-For hcloud -o json kommt in PowerShell als Array von Zeilen an, nicht als ein String. ConvertFrom-Json würde daran scheitern; -join setzt sie vorher wieder zusammen. Das try/catch drumherum fängt den Fall ab, dass gar kein JSON kam.
$a = @('server','create',…)
hcloud @a
Invoke-Up Splatting. Die optionalen Schalter (Firewall, Standort, feste IP, privates Netz, cloud-init) hängen davon ab, was in der Konfiguration steht. Statt acht Varianten des Aufrufs zu schreiben, wächst ein Array Stück für Stück und wird mit @a als Argumentliste ausgerollt. Das @ ist hier kein Tippfehler für $ – es ändert die Bedeutung.
-o StrictHostKeyChecking=accept-new
-o BatchMode=yes
-o ConnectTimeout=5
Wait-Ssh Der neue Server hat einen neuen Host-Key. accept-new nimmt ihn beim ersten Mal ungefragt an, meldet aber weiterhin, wenn sich ein bekannter Key ändert – anders als das grobe StrictHostKeyChecking=no. BatchMode=yes verbietet jede Rueckfrage, damit die Schleife nicht auf eine Passwort-Eingabe wartet, und ConnectTimeout=5 begrenzt den einzelnen Versuch, sodass 60 Runden a 5 Sekunden auch wirklich rund fünf Minuten dauern statt beliebig lange.
ssh-keygen -R $ip Forget-HostKey Löscht den alten Eintrag aus known_hosts. Ohne reservierte IP bekommt der Server bei jedem up eine neue Adresse – fällt eine alte Adresse später an jemand anderen, warnt SSH sonst vor einem vermeintlichen Angriff und verweigert die Verbindung.
ssh … 'sync' Invoke-Down Schreibt die Dateisystem-Puffer auf die Platte, nachdem die Dienste gestoppt wurden und bevor der Snapshot startet. Der Snapshot liest den Datenträger, nicht den Arbeitsspeicher – ohne sync landen die letzten Schreibvorgaenge nicht darin.
if ($LASTEXITCODE -ne 0) { … return } nach create-image Die wichtigste Zeile des ganzen Skripts. Scheitert der Snapshot, wird der Server nicht gelöscht. Die Reihenfolge Snapshot → prüfen → löschen ist der Grund, warum das Skript überhaupt sicher ist.
Select-Object -Skip $Keep Pruning Überspringt die N neuesten und löscht alles danach. Das funktioniert nur, weil Snapshots-For vorher Sort-Object … -Descending anwendet – ohne die Sortierung würde dieselbe Zeile die falschen Snapshots wegwerfen.
--primary-ipv4 $s.reservedIP Invoke-Up Hängt eine vorab reservierte Primary-IP an den neuen Server. Damit bleibt die Adresse über down/up hinweg gleich – DNS, fremde Firewall-Regeln und known_hosts stimmen weiter. Kostet rund 0,50 EUR im Monat, auch während der Server gelöscht ist.
postUp = { param($ip) … }
& $s.postUp $ip
Konfiguration Ein Scriptblock in der Konfiguration: Code als Wert, der erst nach dem Start auf dem Laptop läuft und die neue IP als Argument bekommt. Das & ist der Aufruf-Operator – ohne ihn würde PowerShell den Block nur ausgeben statt ausführen. Hier holt er die kubeconfig ab und biegt 127.0.0.1 auf die neue Adresse um.
-Proxied:([bool]$s.ddnsProxied) DDNS-Aufruf Der Doppelpunkt ist Pflicht, wenn ein Switch-Parameter einen berechneten Wert bekommen soll. -Proxied $false ohne Doppelpunkt würde PowerShell als „Switch gesetzt, und $false ist das nächste positionale Argument“ lesen – also genau das Gegenteil.

08 Einrichten

  1. hcloud context create heim (falls noch nicht – API-Token mit Read&Write).
  2. Firewall(s) anlegen (falls noch nicht): hcloud firewall create --name k3s-fw + Regeln (siehe der jeweilige Guide).
  3. Optional feste IP: hcloud primary-ip create --type ipv4 --name <ipname> --datacenter nbg1-dc3.
  4. Optional privates Netz für Cluster: hcloud network create --name lib-net --ip-range 10.128.0.0/16 + Subnetz.
  5. Den $SERVERS-Block in snapshot.ps1 ausfüllen – ein @{ … } je Server. preDown (sauberes Stoppen) und postUp (Verbindung neu setzen) an deinen Dienst anpassen.
  6. Set-ExecutionPolicy -Scope CurrentUser RemoteSigned.
preDown je Dienst

Podman: podman stop --all -t 15; systemctl stop podman.socket · Docker-Compose: cd /opt/stack && docker compose stop · k3s: systemctl stop k3s · WordPress/DB: docker compose stop (oder mysqldump vorher).

09 Taeglicher Gebrauch + Auto-Down

Laptop · PowerShell
.\scripts\snapshot.ps1 up          # morgens
.\scripts\snapshot.ps1 status
.\scripts\snapshot.ps1 down        # abends

Die vier Schutz-Ebenen – alle Anleitungen verweisen hierher:

Ebene 1 Ein Skript für An / Aus / Status

cloud.ps1 (ein Server) bzw. snapshot.ps1 (mehrere) aus dem Repo – up / down / status, Firewall + Verbindung + DNS beim up. Konfiguration siehe Server-Basis.

Ebene 2 Desktop-Verknüpfung

create-shortcuts.ps1 legt <Label>-up / -down / -status als Icons an – der „ein Klick“. Vorgabe -For cloud. Für die anderen Fassungen -For k3s / -For sno / -For cluster. Der Snapshot-Weg für mehrere Server hat kein up-Icon – hier direkt .\scripts\snapshot.ps1 up.

Ebene 3 Nächtliches Auto-Down

Windows-Aufgabenplanung ruft down täglich um 03:00 – ein vergessener Server läuft hoechstens bis dahin. install-autodown.ps1 im Repo legt die Aufgabe an (eigener Task-Name je Fassung, mehrere gehen nebeneinander):

Laptop · PowerShell (einmalig)
.\scripts\install-autodown.ps1                 # cloud.ps1  (Podman-Host)
.\scripts\install-autodown.ps1 -For snapshot   # snapshot.ps1 down fuer alle $SERVERS
.\scripts\install-autodown.ps1 -For k3s        # bzw. -For sno / -For cluster

Von Hand ginge es auch: Register-ScheduledTask -TaskName '…' -Action (New-ScheduledTaskAction -Execute powershell.exe -Argument '… -File <pfad>\snapshot.ps1 down') -Trigger (New-ScheduledTaskTrigger -Daily -At 3am) -Settings (New-ScheduledTaskSettingsSet -StartWhenAvailable).

Ebene 4 Selbstzerstörung serverseitig

Falls der Laptop wochenlang aus bleibt: ein systemd-Timer löscht die Instanz nach 12 h per Hetzner-API selbst. Eine aktive SSH-Sitzung verlängert die Frist.

Nur mit separatem Projekt

Dafür liegt ein Hetzner-API-Token auf dem Server. Ausschließlich in einem eigenen Hetzner-Projekt verwenden, das nur diesen einen Server enthält – so kann der Token maximal diesen Server treffen. Nicht bei Clustern (löscht einzelne Nodes, laesst LB/Netz stehen).

An cloud-init.yaml anhängen (= cloud-init.selfdestruct.yaml im Repo)
write_files:
  - path: /etc/selfdestruct.env
    permissions: '0600'
    content: |
      HCLOUD_TOKEN=TOKEN_NUR_FUER_DIESES_PROJEKT
  - path: /usr/local/bin/selfdestruct
    permissions: '0755'
    content: |
      #!/bin/bash
      LEASE=/etc/selfdestruct.lease
      now=$(date +%s)
      [ -f "$LEASE" ] || echo $((now + 43200)) > "$LEASE"      # 12 h ab Boot
      deadline=$(cat "$LEASE")
      if who | grep -q . ; then w=$((now + 7200)); [ "$w" -gt "$deadline" ] && echo "$w" > "$LEASE"; exit 0; fi
      [ "$now" -lt "$deadline" ] && exit 0
      . /etc/selfdestruct.env
      id=$(curl -s http://169.254.169.254/hetzner/v1/metadata/instance-id)
      curl -s -X DELETE -H "Authorization: Bearer $HCLOUD_TOKEN" \
        "https://api.hetzner.cloud/v1/servers/$id"
  - path: /etc/systemd/system/selfdestruct.timer
    content: |
      [Timer]
      OnBootSec=10min
      OnUnitActiveSec=10min
      [Install]
      WantedBy=timers.target
  - path: /etc/systemd/system/selfdestruct.service
    content: |
      [Service]
      Type=oneshot
      ExecStart=/usr/local/bin/selfdestruct
runcmd:
  - systemctl enable --now selfdestruct.timer

+ Kosten-Rechnung

Preise sind Richtwerte. Hetzner hat 2026 zweimal erhöht (zuletzt 15. Juni: CX/CAX +30–40 %, CPX/CCX über 100 %). Aktuelle Server-Preise, alle Nebenposten, ein Rechner und Beispielrechnungen stehen zentral in Kosten & Budget.

NutzungRechenzeit+ Snapshot+ feste IP= /Monat
durchlaufend CPX32~35 €~35 €
Wegwerf: 6× 3 h/Abend18 h × 0,0113 ≈ 0,20 €~0,07 €0 (oder 0,50)~0,30 €
Wegwerf mit fester IP + DNS~0,20 €~0,07 €0,50 €~0,80 €
3-Node-Cluster, Wegwerf3× ~0,60 €3× ~0,15 €ggf. 3× 0,50~3,75 €

+ Snapshot vs Backup vs Volume vs Neuinstallation

WegPreisBeim Server-LöschenWofür
Snapshot (dieser Guide)0,0143 €/GB/MonatbleibtServer komplett wegwerfen und identisch wiederherstellen. 1 Node ideal.
Backups-Feature+20 % vom Serverpreis (pauschal)wird mitgelöschtautomatische tägliche Sicherung eines dauerhaften Servers, 7 Tage.
Volume0,0572 €/GB/Monatbleibt (unabhängig)nur die Daten überleben. Server wegwerfen, Volume an einen neuen anhängen. Teurer/GB, dafür live abtrennbar.
Neuinstallation0Cluster / alles-per-Skript (deploy.ps1). Kein Zustand mitgeschleppt, ~20 Min. Robuster für Multi-Node.

Kombinierbar: Snapshot fur den schnellen Wiederaufbau und ein pg_dump außer Haus (Grundausstattung) für den Fall, dass der Hetzner-Zugang selbst weg ist.