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
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 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
# 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 aendert
| aendert sich? | Folge & Fix | |
|---|---|---|
| Oeffentliche 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 ueberall 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, ausser 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 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.
# 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. 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.
# 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 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-hoststatt -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):
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 ueber eine Server-Liste.-Only name1,name2fuer Teilmengen. - k3s-/OKD-Cluster uebersteht down/up nur, wenn:
- jeder Node seine feste private IP behaelt (
network+privateIPin der Config), - der/die Server-Node(s) ihre feste oeffentliche IP behalten
(
reservedIP) → API-Zertifikat + kubeconfig bleiben gueltig, - alle Nodes ~gleichzeitig wieder hochkommen.
- jeder Node seine feste private IP behaelt (
- Ehrlich: fuer 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 selbsterklaerend. 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 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
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 fuer Cluster:
hcloud network create --name lib-net --ip-range 10.128.0.0/16+ Subnetz. - Den
$SERVERS-Block insnapshot.ps1ausfuellen – 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 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):
.\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.
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).
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-Loeschen | Wofuer |
|---|---|---|---|
| Snapshot (dieser Guide) | 0,0143 €/GB/Monat | bleibt | Server komplett wegwerfen und identisch wiederherstellen. 1 Node ideal. |
| Backups-Feature | +20 % vom Serverpreis (pauschal) | wird mitgeloescht | automatische taegliche Sicherung eines dauerhaften Servers, 7 Tage. |
| Volume | 0,0572 €/GB/Monat | bleibt (unabhaengig) | nur die Daten ueberleben. Server wegwerfen, Volume an einen neuen anhaengen. Teurer/GB, dafuer live abtrennbar. |
| Neuinstallation | 0 | – | Cluster / 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.