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
upwird 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
# 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>
# 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-IP | podman 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-Key | JA – neuer Server | known_hosts-Warnung → ssh-keygen -R <ip> + StrictHostKeyChecking=accept-new. |
| Private IP (im Netz) | JA, außer du setzt sie fest | Cluster-Nodes finden sich nicht → attach-to-network --ip <feste-ip>. |
| Hostname, MACs, Interface-Namen | nein (Hetzner stabil: eth0) | – |
| Volume-Daten, Images, Configs | nein – im Snapshot | das ist der Zweck. |
Server-ID / /etc/machine-id | neue 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.
# 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>
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.
# 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
# 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
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.
| Parameter | steht bei | bewirkt |
|---|---|---|
.\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-hoststatt -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):
ddnsZone = 'seb4u.com' # leer = kein DDNS
ddnsNames = @('blog','vault','@')
ddnsProxied = $false # $true = Cloudflare-Proxy (orange Wolke)
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.ps1iteriert über eine Server-Liste.-Only name1,name2für Teilmengen. - k3s-/OKD-Cluster übersteht down/up nur, wenn:
- jeder Node seine feste private IP behält (
network+privateIPin der Config), - der/die Server-Node(s) ihre feste öffentliche IP behalten
(
reservedIP) → API-Zertifikat + kubeconfig bleiben gültig, - alle Nodes ~gleichzeitig wieder hochkommen.
- jeder Node seine feste private IP behält (
- Ehrlich: für einen echten Multi-Node-Cluster ist
Neuinstallation (der
deploy.ps1aus 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.
.\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
#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 $_ } }
}
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:
| Konstrukt | Stelle | bewirkt |
|---|---|---|
#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
hcloud context create heim(falls noch nicht – API-Token mit Read&Write).- Firewall(s) anlegen (falls noch nicht):
hcloud firewall create --name k3s-fw+ Regeln (siehe der jeweilige Guide). - Optional feste IP:
hcloud primary-ip create --type ipv4 --name <ipname> --datacenter nbg1-dc3. - Optional privates Netz für Cluster:
hcloud network create --name lib-net --ip-range 10.128.0.0/16+ Subnetz. - Den
$SERVERS-Block insnapshot.ps1ausfüllen – ein@{ … }je Server.preDown(sauberes Stoppen) undpostUp(Verbindung neu setzen) an deinen Dienst anpassen. Set-ExecutionPolicy -Scope CurrentUser RemoteSigned.
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
.\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):
.\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.
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).
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.
| Nutzung | Rechenzeit | + Snapshot | + feste IP | = /Monat |
|---|---|---|---|---|
durchlaufend CPX32 | ~35 € | – | – | ~35 € |
| Wegwerf: 6× 3 h/Abend | 18 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, Wegwerf | 3× ~0,60 € | 3× ~0,15 € | ggf. 3× 0,50 | ~3,75 € |
+ Snapshot vs Backup vs Volume vs Neuinstallation
| Weg | Preis | Beim Server-Löschen | Wofür |
|---|---|---|---|
| Snapshot (dieser Guide) | 0,0143 €/GB/Monat | bleibt | Server komplett wegwerfen und identisch wiederherstellen. 1 Node ideal. |
| Backups-Feature | +20 % vom Serverpreis (pauschal) | wird mitgelöscht | automatische tägliche Sicherung eines dauerhaften Servers, 7 Tage. |
| Volume | 0,0572 €/GB/Monat | bleibt (unabhängig) | nur die Daten überleben. Server wegwerfen, Volume an einen neuen anhängen. Teurer/GB, dafür live abtrennbar. |
| Neuinstallation | 0 | – | Cluster / 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.