← Ü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 fuer Server, die du nur zeitweise brauchst: sauber stoppen → Snapshot → loeschen → bei Bedarf identisch aus dem Snapshot neu. Was dabei genau passiert, was sich aendert (die IP!), und ein Skript fuer einen oder mehrere Server.

Stand: 29. August 2026 gilt fuer 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.

  • Enthaelt: 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 tatsaechlich belegter, komprimierter Groesse. Ein Container-Host mit 3–5 GB → ~4–7 Cent/Monat.
  • Verwaltung: Console → Images → Snapshots, oder hcloud image list --type snapshot.
  • Server loeschen entfernt Snapshots nicht. Sie bleiben, bis du sie loeschst. Standard-Limit ~25 Snapshots/Projekt (per Support erhoehbar).
  • Snapshot ≠ Backups-Feature (das ist +20 % pauschal, letzte 7, automatisch, und wird mit dem Server geloescht).

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 aendert

aendert sich?Folge & Fix
Oeffentliche 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 ueberall nachziehen (das Skript macht Letzteres).
SSH-Host-KeyJA – neuer Serverknown_hosts-Warnung → ssh-keygen -R <ip> + StrictHostKeyChecking=accept-new.
Private IP (im Netz)JA, ausser 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 behaeltst, auch wenn der Server weg ist. Kostet 0,50 €/Monat, egal ob gerade an einem Server oder nicht. Dafuer aendert sich die IP nie – DNS-Record, kubeconfig, podman-connection, --tls-san bleiben ueber jeden Zyklus gueltig.

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. Fuer 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 fuehrt aus Sicherheitsgruenden nichts aus dem aktuellen Verzeichnis aus, wenn es nicht ausdruecklich so angegeben ist – sonst koennte eine Datei namens ls.ps1 im Download-Ordner den echten Befehl verdraengen. Ohne das Praefix meldet PowerShell, der Begriff sei nicht erkannt, obwohl die Datei sichtbar danebenliegt. Beim ersten Aufruf kommt ausserdem die Frage nach der Ausfuehrungsrichtlinie (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 schliessen 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 bestaetigen, 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 ueber eine Server-Liste. -Only name1,name2 fuer Teilmengen.
  • k3s-/OKD-Cluster uebersteht down/up nur, wenn:
    • jeder Node seine feste private IP behaelt (network + privateIP in der Config),
    • der/die Server-Node(s) ihre feste oeffentliche IP behalten (reservedIP) → API-Zertifikat + kubeconfig bleiben gueltig,
    • alle Nodes ~gleichzeitig wieder hochkommen.
  • Ehrlich: fuer 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 selbsterklaerend. 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 aeltere Fassung laeuft. 5.1 ist die, die bei Windows 10 und 11 dabei ist – das Skript kommt also ohne Nachinstallation aus, muss dafuer 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 fuer diese Sitzung um. Ohne sie scheitern Invoke-RestMethod-Aufrufe mit einer Meldung ueber 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 pruefen, bevor eine einzige Zeile laeuft – ein Tippfehler wie dwon bricht sofort ab, statt spaeter in einem switch stillschweigend nichts zu tun. Nebeneffekt: die Tab-Vervollstaendigung 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 prueft jede wichtige Stelle $LASTEXITCODE selbst – die einzige verlaessliche Erfolgsmeldung eines externen Programms.
2>$null | Out-Null
*> $null
ueberall Zwei verschiedene Dinge: 2>$null wirft nur stderr weg, | Out-Null nur die normale Ausgabe. *> in Forget-HostKey wirft alle Stroeme weg – dort ist auch die Warnung unerwuenscht, 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 wuerde daran scheitern; -join setzt sie vorher wieder zusammen. Das try/catch drumherum faengt 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) haengen davon ab, was in der Konfiguration steht. Statt acht Varianten des Aufrufs zu schreiben, waechst ein Array Stueck fuer Stueck und wird mit @a als Argumentliste ausgerollt. Das @ ist hier kein Tippfehler fuer $ – es aendert 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 aendert – 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 fuenf Minuten dauern statt beliebig lange.
ssh-keygen -R $ip Forget-HostKey Loescht den alten Eintrag aus known_hosts. Ohne reservierte IP bekommt der Server bei jedem up eine neue Adresse – faellt eine alte Adresse spaeter 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 Datentraeger, 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 geloescht. Die Reihenfolge Snapshot → pruefen → loeschen ist der Grund, warum das Skript ueberhaupt sicher ist.
Select-Object -Skip $Keep Pruning Ueberspringt die N neuesten und loescht alles danach. Das funktioniert nur, weil Snapshots-For vorher Sort-Object … -Descending anwendet – ohne die Sortierung wuerde dieselbe Zeile die falschen Snapshots wegwerfen.
--primary-ipv4 $s.reservedIP Invoke-Up Haengt eine vorab reservierte Primary-IP an den neuen Server. Damit bleibt die Adresse ueber down/up hinweg gleich – DNS, fremde Firewall-Regeln und known_hosts stimmen weiter. Kostet rund 0,50 EUR im Monat, auch waehrend der Server geloescht ist.
postUp = { param($ip) … }
& $s.postUp $ip
Konfiguration Ein Scriptblock in der Konfiguration: Code als Wert, der erst nach dem Start auf dem Laptop laeuft und die neue IP als Argument bekommt. Das & ist der Aufruf-Operator – ohne ihn wuerde PowerShell den Block nur ausgeben statt ausfuehren. 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 wuerde PowerShell als „Switch gesetzt, und $false ist das naechste 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 fuer Cluster: hcloud network create --name lib-net --ip-range 10.128.0.0/16 + Subnetz.
  5. Den $SERVERS-Block in snapshot.ps1 ausfuellen – 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 fuer 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-Verknuepfung

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

Ebene 3 Naechtliches Auto-Down

Windows-Aufgabenplanung ruft down taeglich um 03:00 – ein vergessener Server laeuft 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 Selbstzerstoerung serverseitig

Falls der Laptop wochenlang aus bleibt: ein systemd-Timer loescht die Instanz nach 12 h per Hetzner-API selbst. Eine aktive SSH-Sitzung verlaengert die Frist.

Nur mit separatem Projekt

Dafuer liegt ein Hetzner-API-Token auf dem Server. Ausschliesslich in einem eigenen Hetzner-Projekt verwenden, das nur diesen einen Server enthaelt – so kann der Token maximal diesen Server treffen. Nicht bei Clustern (loescht einzelne Nodes, laesst LB/Netz stehen).

An cloud-init.yaml anhaengen (= 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-LoeschenWofuer
Snapshot (dieser Guide)0,0143 €/GB/MonatbleibtServer komplett wegwerfen und identisch wiederherstellen. 1 Node ideal.
Backups-Feature+20 % vom Serverpreis (pauschal)wird mitgeloeschtautomatische taegliche Sicherung eines dauerhaften Servers, 7 Tage.
Volume0,0572 €/GB/Monatbleibt (unabhaengig)nur die Daten ueberleben. Server wegwerfen, Volume an einen neuen anhaengen. Teurer/GB, dafuer live abtrennbar.
Neuinstallation0Cluster / alles-per-Skript (deploy.ps1). Kein Zustand mitgeschleppt, ~20 Min. Robuster fuer Multi-Node.

Kombinierbar: Snapshot fur den schnellen Wiederaufbau und ein pg_dump ausser Haus (Grundausstattung) fuer den Fall, dass der Hetzner-Zugang selbst weg ist.