Running a homelab is great until your NAS is maxed out, your mini PC cluster is sweating under load, or you need a service to be reachable when your ISP goes down. The traditional answer is “buy more hardware,” but there’s a better option for many scenarios: overflow to a cheap, fast cloud provider.
Hetzner Cloud is the standout choice for homelab operators who want genuine cloud infrastructure without the AWS/GCP/Azure pricing shock. Their CX22 (2 vCPU, 4 GB RAM) starts at €3.79/month. A CCX13 with 2 dedicated AMD vCPUs and 8 GB RAM is €12.49/month. That’s real compute, not the burstable T2/E2 nonsense that throttles you when you actually need it.
This guide covers how to integrate Hetzner Cloud as homelab overflow: cloud bursting for temporary compute needs, geo-redundant deployments for uptime, hybrid networking with WireGuard, and managing it all with Terraform.
Why Hetzner?
Before getting into the how, it’s worth understanding what makes Hetzner different from the hyperscalers.
Pricing That Makes Sense
| Instance |
vCPU |
RAM |
Storage |
Monthly (EUR) |
| CX22 |
2 shared |
4 GB |
40 GB NVMe |
€3.79 |
| CX32 |
4 shared |
8 GB |
80 GB NVMe |
€6.49 |
| CX42 |
8 shared |
16 GB |
160 GB NVMe |
€13.09 |
| CCX13 |
2 dedicated |
8 GB |
80 GB NVMe |
€12.49 |
| CCX23 |
4 dedicated |
16 GB |
160 GB NVMe |
€24.49 |
| CPX51 |
20 shared |
96 GB |
360 GB NVMe |
€60.99 |
For comparison: an AWS t3.medium (2 vCPU, 4 GB) costs ~$35/month. Hetzner’s CX22 is essentially the same spec for ~$4. That’s not a typo.
Infrastructure Quality
Hetzner isn’t “cheap and bad.” Their network is fast (up to 1 Gbit/s), latency within Europe is excellent, and their API is clean and well-documented. They offer:
- Private networks — RFC1918 space between your cloud servers, free
- Floating IPs — static IPs that can move between servers
- Load balancers — L4/L7 with SSL termination, ~€5.39/month
- Volumes — persistent block storage, €0.0516/GB/month
- Object storage (Hetzner Object Storage) — S3-compatible, €0.0069/GB/month
- Firewalls — stateful, free
- Placement groups — spread or pack your servers
Locations
Hetzner has data centers in:
- Nuremberg (NBG1)
- Falkenstein (FSN1)
- Helsinki (HEL1)
- Ashburn, Virginia, USA (IAD1, newer)
- Singapore (SIN, newer)
For European homelab operators, having servers in FSN + HEL gives genuine geo-redundancy with low latency between them. The US location opens up transatlantic distribution for content.
Setting Up Your Hetzner Account
API Token and hcloud CLI
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
# Install hcloud CLI
# macOS
brew install hcloud
# Linux
curl -fsSL https://github.com/hetznercloud/cli/releases/latest/download/hcloud-linux-amd64.tar.gz \
| tar xz && sudo mv hcloud /usr/local/bin/
# Create API token: Cloud Console → Security → API Tokens → Generate API Token
# Give it Read & Write permissions
# Configure CLI
hcloud context create homelab
# Paste your API token when prompted
# Verify
hcloud server list
hcloud datacenter list
|
SSH Key Setup
1
2
3
4
5
|
# Add your SSH key to Hetzner
hcloud ssh-key create --name homelab-key --public-key-file ~/.ssh/id_ed25519.pub
# Verify
hcloud ssh-key list
|
Managing Hetzner resources with Terraform gives you reproducibility, drift detection, and easy teardown — essential for temporary burst workloads.
Provider Configuration
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
# versions.tf
terraform {
required_version = ">= 1.6"
required_providers {
hcloud = {
source = "hetznercloud/hcloud"
version = "~> 1.47"
}
}
}
provider "hcloud" {
token = var.hcloud_token
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
# variables.tf
variable "hcloud_token" {
type = string
sensitive = true
}
variable "location" {
type = string
default = "fsn1"
}
variable "ssh_key_name" {
type = string
default = "homelab-key"
}
|
1
2
|
# terraform.tfvars (not committed — add to .gitignore)
hcloud_token = "your-api-token-here"
|
Base Network Setup
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
# network.tf
resource "hcloud_network" "homelab" {
name = "homelab-net"
ip_range = "10.10.0.0/16"
}
resource "hcloud_network_subnet" "servers" {
type = "cloud"
network_id = hcloud_network.homelab.id
network_zone = "eu-central"
ip_range = "10.10.1.0/24"
}
resource "hcloud_firewall" "base" {
name = "base-firewall"
rule {
direction = "in"
protocol = "tcp"
port = "22"
source_ips = ["0.0.0.0/0", "::/0"]
description = "SSH"
}
rule {
direction = "in"
protocol = "icmp"
source_ips = ["0.0.0.0/0", "::/0"]
description = "ICMP ping"
}
}
|
A Basic Cloud Server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
# server.tf
data "hcloud_ssh_key" "homelab" {
name = var.ssh_key_name
}
resource "hcloud_server" "overflow" {
name = "overflow-01"
image = "ubuntu-24.04"
server_type = "cx22"
location = var.location
ssh_keys = [data.hcloud_ssh_key.homelab.id]
firewall_ids = [hcloud_firewall.base.id]
network {
network_id = hcloud_network.homelab.id
ip = "10.10.1.10"
}
user_data = file("${path.module}/cloud-init.yaml")
labels = {
environment = "homelab"
role = "overflow"
}
}
output "overflow_ip" {
value = hcloud_server.overflow.ipv4_address
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
# cloud-init.yaml
#cloud-config
package_update: true
package_upgrade: true
packages:
- docker.io
- docker-compose-v2
- wireguard
- htop
- curl
runcmd:
- systemctl enable --now docker
- usermod -aG docker ubuntu
|
Use Case 1: Cloud Bursting for Temporary Workloads
Cloud bursting means spinning up Hetzner servers on demand for workloads that spike beyond your homelab capacity, then tearing them down when done. Think: CI/CD build agents, batch processing, rendering, data pipeline runs.
On-Demand Build Agents
If your homelab CI runner (Gitea Actions, GitHub self-hosted runner, or GitLab Runner) is overloaded, spin up a temporary Hetzner agent:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
#!/usr/bin/env bash
# burst-agent.sh — provision a temporary CI agent on Hetzner
set -euo pipefail
AGENT_NAME="ci-agent-$(date +%Y%m%d%H%M%S)"
RUNNER_TOKEN="${GITHUB_RUNNER_TOKEN}"
REPO_URL="https://github.com/your-org/your-repo"
# Create the server
SERVER_IP=$(hcloud server create \
--name "$AGENT_NAME" \
--type cx32 \
--image ubuntu-24.04 \
--ssh-key homelab-key \
--location fsn1 \
--user-data-from-file <(cat <<EOF
#cloud-config
runcmd:
- apt-get update && apt-get install -y docker.io curl
- systemctl enable --now docker
- mkdir -p /opt/runner && cd /opt/runner
- curl -sL https://github.com/actions/runner/releases/latest/download/actions-runner-linux-x64-$(curl -sI https://github.com/actions/runner/releases/latest | grep location | sed 's/.*tag\/v//;s/\r//').tar.gz | tar xz
- ./config.sh --url ${REPO_URL} --token ${RUNNER_TOKEN} --name ${AGENT_NAME} --unattended --ephemeral
- ./run.sh
EOF
) \
--output json | jq -r '.server.public_net.ipv4.ip')
echo "Agent $AGENT_NAME started at $SERVER_IP"
echo "It will self-register, run one job, then you can delete it."
|
For a cleaner approach, use Terraform with -target to create/destroy individual agents:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
# agents.tf
variable "agent_count" {
type = number
default = 0
}
resource "hcloud_server" "ci_agent" {
count = var.agent_count
name = "ci-agent-${count.index + 1}"
image = "ubuntu-24.04"
server_type = "cx32"
location = var.location
ssh_keys = [data.hcloud_ssh_key.homelab.id]
user_data = templatefile("${path.module}/agent-cloud-init.yaml", {
runner_token = var.runner_token
repo_url = var.repo_url
})
}
|
Scale up:
1
|
terraform apply -var="agent_count=3"
|
Scale down (and stop paying):
1
|
terraform apply -var="agent_count=0"
|
Batch Processing Overflow
For data pipelines or batch jobs that would take hours on your NAS:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
#!/usr/bin/env bash
# burst-batch.sh — run a batch job on a temporary Hetzner server
JOB_NAME="batch-$(date +%s)"
SCRIPT_PATH="$1" # path to script to run
# Create server
hcloud server create \
--name "$JOB_NAME" \
--type ccx13 \
--image ubuntu-24.04 \
--ssh-key homelab-key \
--location fsn1
# Wait for SSH
sleep 30
IP=$(hcloud server describe "$JOB_NAME" -o json | jq -r '.public_net.ipv4.ip')
# Copy and run the job
scp -o StrictHostKeyChecking=no "$SCRIPT_PATH" root@"$IP":/tmp/job.sh
ssh root@"$IP" 'chmod +x /tmp/job.sh && /tmp/job.sh && poweroff'
# Wait for job to complete, then clean up
echo "Job running at $IP — delete server when done:"
echo " hcloud server delete $JOB_NAME"
|
Use Case 2: Geo-Redundant Services
For services that need to stay up when your home internet drops, power goes out, or you’re doing maintenance — a Hetzner server in a different datacenter gives you a genuine failover path.
Architecture: Active-Passive with DNS Failover
The simplest geo-redundant setup:
Internet → DNS (low TTL)
├── Hetzner FSN (primary) ──→ app + reverse proxy
└── Hetzner HEL (standby) ──→ same app, hot standby
↑
Floating IP switches on failure
Both servers run your application. Healthchecks monitor primary. If primary fails, a script moves the Floating IP to standby.
Floating IP Failover Script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
#!/usr/bin/env bash
# failover.sh — move floating IP to standby if primary is down
set -euo pipefail
FLOATING_IP_ID="your-floating-ip-id"
PRIMARY_SERVER_ID="your-primary-server-id"
STANDBY_SERVER_ID="your-standby-server-id"
PRIMARY_IP="1.2.3.4"
HEALTH_URL="https://yoursite.com/health"
HCLOUD_TOKEN="${HCLOUD_TOKEN}"
check_health() {
curl -sf --max-time 5 "$HEALTH_URL" > /dev/null 2>&1
}
get_current_assignment() {
curl -sH "Authorization: Bearer $HCLOUD_TOKEN" \
"https://api.hetzner.cloud/v1/floating_ips/$FLOATING_IP_ID" \
| jq -r '.floating_ip.server'
}
assign_to_standby() {
curl -sX POST \
-H "Authorization: Bearer $HCLOUD_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"server\": $STANDBY_SERVER_ID}" \
"https://api.hetzner.cloud/v1/floating_ips/$FLOATING_IP_ID/actions/assign"
echo "Floating IP moved to standby server"
}
CURRENT=$(get_current_assignment)
if [[ "$CURRENT" == "$PRIMARY_SERVER_ID" ]]; then
if ! check_health; then
echo "$(date): Primary health check failed — initiating failover"
assign_to_standby
fi
fi
|
Run this from a third location (your homelab, a cron job on a monitoring server, or a GitHub Actions scheduled workflow):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
# .github/workflows/failover-monitor.yml
name: Failover Monitor
on:
schedule:
- cron: '*/2 * * * *' # every 2 minutes
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run health check and failover if needed
env:
HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN }}
run: bash scripts/failover.sh
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
# geo-redundant.tf
locals {
regions = {
primary = "fsn1"
standby = "hel1"
}
}
resource "hcloud_server" "app" {
for_each = local.regions
name = "app-${each.key}"
image = "ubuntu-24.04"
server_type = "cx22"
location = each.value
ssh_keys = [data.hcloud_ssh_key.homelab.id]
user_data = file("${path.module}/app-cloud-init.yaml")
labels = {
role = "app"
region = each.key
}
}
resource "hcloud_floating_ip" "app" {
type = "ipv4"
home_location = "fsn1"
description = "App primary IP"
}
resource "hcloud_floating_ip_assignment" "app" {
floating_ip_id = hcloud_floating_ip.app.id
server_id = hcloud_server.app["primary"].id
}
resource "hcloud_load_balancer" "app" {
name = "app-lb"
load_balancer_type = "lb11"
location = "fsn1"
}
resource "hcloud_load_balancer_target" "primary" {
type = "server"
load_balancer_id = hcloud_load_balancer.app.id
server_id = hcloud_server.app["primary"].id
}
|
Use Case 3: Hybrid WireGuard Network
The most powerful homelab-cloud integration connects your Hetzner servers directly to your homelab via a WireGuard tunnel — one flat private network spanning both locations.
Homelab LAN: 192.168.1.0/24
Homelab WG: 10.0.0.1/24
Hetzner net: 10.10.1.0/24
WG tunnel: 10.0.0.0/24 (bridges both sides)
WireGuard Hub-and-Spoke Setup
Your homelab router (OPNsense, pfSense, or a dedicated Linux box) acts as the WireGuard hub. Each Hetzner server is a spoke.
On the homelab hub (192.168.1.1):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
# /etc/wireguard/wg-cloud.conf
[Interface]
Address = 10.0.0.1/24
ListenPort = 51820
PrivateKey = <hub-private-key>
# Allow forwarding between WireGuard and LAN
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
[Peer]
# Hetzner server 1 (fsn1)
PublicKey = <hetzner-fsn1-public-key>
AllowedIPs = 10.0.0.2/32, 10.10.1.0/24
PersistentKeepalive = 25
[Peer]
# Hetzner server 2 (hel1)
PublicKey = <hetzner-hel1-public-key>
AllowedIPs = 10.0.0.3/32
PersistentKeepalive = 25
|
On a Hetzner server (fsn1, 10.10.1.10):
1
2
3
4
5
6
7
8
9
10
11
|
# /etc/wireguard/wg0.conf
[Interface]
Address = 10.0.0.2/24
PrivateKey = <fsn1-private-key>
[Peer]
# Homelab hub
PublicKey = <hub-public-key>
Endpoint = your-home-public-ip:51820
AllowedIPs = 10.0.0.0/24, 192.168.1.0/24
PersistentKeepalive = 25
|
Enable and start:
1
|
systemctl enable --now wg-quick@wg0
|
Now the Hetzner server can reach your homelab NAS at 192.168.1.100, and your homelab can reach the Hetzner server at 10.0.0.2. Services on both ends are on the same logical network.
Cloud-Init WireGuard Bootstrap
Provision Hetzner servers with WireGuard pre-configured:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
# wireguard-cloud-init.yaml
#cloud-config
packages:
- wireguard
write_files:
- path: /etc/wireguard/wg0.conf
permissions: '0600'
content: |
[Interface]
Address = 10.0.0.${peer_ip}/24
PrivateKey = ${private_key}
[Peer]
PublicKey = ${hub_public_key}
Endpoint = ${hub_endpoint}:51820
AllowedIPs = 10.0.0.0/24, 192.168.1.0/24
PersistentKeepalive = 25
runcmd:
- systemctl enable --now wg-quick@wg0
|
In your Terraform templatefile:
1
2
3
4
5
6
|
user_data = templatefile("${path.module}/wireguard-cloud-init.yaml", {
peer_ip = "2"
private_key = var.wg_private_key_fsn1
hub_public_key = var.wg_hub_public_key
hub_endpoint = var.home_public_ip
})
|
Use Case 4: Offloading Internet-Facing Services
Many homelab operators don’t want to expose their home IP directly. Hetzner makes an excellent DMZ:
Internet → Hetzner (public) → WireGuard tunnel → Homelab (private)
The Hetzner server handles all public traffic — TLS termination, DDoS absorption, rate limiting — and proxies it through the tunnel to your homelab where the actual service runs.
Caddy as a Tunnel Proxy
On the Hetzner server, run Caddy proxying back through WireGuard to homelab services:
1
2
3
4
5
6
7
8
9
10
11
12
|
# /etc/caddy/Caddyfile
your-domain.com {
reverse_proxy 192.168.1.50:3000 # Grafana on homelab
}
git.your-domain.com {
reverse_proxy 192.168.1.60:3000 # Gitea on homelab
}
photos.your-domain.com {
reverse_proxy 192.168.1.70:2283 # Immich on homelab
}
|
Your homelab IP never appears in DNS. If your home IP changes (dynamic IP), only the WireGuard tunnel needs to reconnect — the public IP (Hetzner) stays constant.
Nginx Stream for Non-HTTP Services
For non-HTTP services like game servers, MQTT brokers, or database access:
1
2
3
4
5
6
7
8
9
10
11
12
|
# /etc/nginx/nginx.conf (stream block)
stream {
upstream mqtt_homelab {
server 192.168.1.80:1883; # MQTT broker through WireGuard
}
server {
listen 1883;
proxy_pass mqtt_homelab;
proxy_connect_timeout 5s;
}
}
|
Use Case 5: S3-Compatible Object Storage
Hetzner Object Storage is S3-compatible and dramatically cheaper than AWS S3 at €0.0069/GB/month (vs. ~$0.023/GB for S3 standard). Use it for:
- Terraform remote state
- Restic backup targets
- Static asset hosting
- Application object storage
Setup
Create a bucket in the Hetzner Console under “Object Storage”, then get your Access Key and Secret Key.
1
2
3
4
5
6
7
|
# Configure rclone for Hetzner Object Storage
rclone config
# Choose: New remote → S3 → Other → Custom endpoint
# endpoint: fsn1.your-objectstorage.com
# access_key_id: your-access-key
# secret_access_key: your-secret-key
# region: (leave blank)
|
Or use environment variables with any S3-compatible tool:
1
2
3
4
5
6
7
8
9
10
|
export AWS_ACCESS_KEY_ID="your-access-key"
export AWS_SECRET_ACCESS_KEY="your-secret-key"
export AWS_ENDPOINT_URL="https://fsn1.your-objectstorage.com"
# Works with aws CLI
aws s3 ls s3://your-bucket/
# Works with mc (MinIO client)
mc alias set hetzner https://fsn1.your-objectstorage.com your-access-key your-secret-key
mc ls hetzner/your-bucket
|
1
2
3
4
5
6
7
8
9
10
11
12
13
|
# backend.tf
terraform {
backend "s3" {
bucket = "terraform-state"
key = "homelab/terraform.tfstate"
region = "us-east-1" # required but ignored
endpoint = "https://fsn1.your-objectstorage.com"
skip_credentials_validation = true
skip_metadata_api_check = true
skip_region_validation = true
force_path_style = true
}
}
|
Restic Backups to Hetzner Object Storage
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
# Initialize Restic repository
export RESTIC_REPOSITORY="s3:https://fsn1.your-objectstorage.com/restic-backups"
export RESTIC_PASSWORD="your-strong-password"
export AWS_ACCESS_KEY_ID="your-access-key"
export AWS_SECRET_ACCESS_KEY="your-secret-key"
restic init
# Back up homelab data
restic backup /opt/appdata /home/user/important
# Scheduled backup (systemd timer or cron)
restic backup --tag homelab /opt/appdata \
&& restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
|
At €0.0069/GB, storing 100 GB of backups costs about €0.69/month. Compare to Backblaze B2 at $0.006/GB — they’re nearly identical, and Hetzner keeps everything in one place if you’re already using their compute.
Cost Management
The biggest risk with cloud resources is forgetting they exist. Hetzner servers are cheap, but “a bunch of cheap things” adds up.
Tagging Everything
1
2
3
4
5
6
7
8
9
|
resource "hcloud_server" "example" {
# ...
labels = {
environment = "homelab"
purpose = "ci-agent"
owner = "jmoon"
expires = "2026-04-01" # for temporary resources
}
}
|
Automatic Deletion for Temporary Resources
For burst workloads, build self-deletion into the cloud-init:
1
2
3
4
5
6
7
8
9
10
|
# self-destruct-cloud-init.yaml
#cloud-config
runcmd:
- /opt/job/run.sh
# After job completes, delete this server via API
- |
SERVER_ID=$(curl -sH "X-Hetzner-Server-Id: metadata" http://169.254.169.254/hetzner/v1/metadata/instance-id)
curl -sX DELETE \
-H "Authorization: Bearer ${hcloud_token}" \
"https://api.hetzner.cloud/v1/servers/$SERVER_ID"
|
Budget Alerts
Hetzner doesn’t have native budget alerts, but you can poll the API:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
#!/usr/bin/env bash
# check-hetzner-costs.sh
TOKEN="${HCLOUD_TOKEN}"
MONTHLY_BUDGET=50 # EUR
# Get current billing estimate
CURRENT=$(curl -sH "Authorization: Bearer $TOKEN" \
"https://api.hetzner.cloud/v1/servers" \
| jq '[.servers[].included_traffic] | add // 0')
# Simple server count check as a proxy
SERVER_COUNT=$(curl -sH "Authorization: Bearer $TOKEN" \
"https://api.hetzner.cloud/v1/servers" \
| jq '.meta.pagination.total_entries')
echo "Active servers: $SERVER_COUNT"
if [[ $SERVER_COUNT -gt 10 ]]; then
echo "WARNING: More than 10 active servers — check for forgotten resources"
# Send to Slack, email, Pushover, etc.
fi
|
Hetzner’s Pricing Model
Servers are billed hourly, with a monthly cap. A CX22 at €3.79/month billed hourly is €0.0053/hour. If you delete the server after 10 hours, you pay €0.053. Volumes and Floating IPs are billed for as long as they exist, even if unattached — so delete unneeded volumes and IPs promptly.
Practical Homelab Integration Patterns
Pattern 1: Homelab Primary, Hetzner Standby
- All services run at home
- Hetzner server stays warm but idle
- Floating IP + failover script moves traffic if home goes down
- Cost: ~€4/month for a warm standby
Good for: Personal services where some downtime during home maintenance is OK, but total outage during ISP issues is not.
Pattern 2: Hetzner Public Face, Homelab Backend
- Hetzner handles all internet-facing traffic (no home IP exposure)
- WireGuard tunnel passes traffic to homelab services
- Hetzner does TLS termination, rate limiting, DDoS mitigation
- Cost: ~€4/month for the proxy
Good for: Self-hosted services you share with family/friends, anything where you don’t want your home IP in DNS.
Pattern 3: Hetzner for Stateless, Homelab for Stateful
- Stateless services (web frontends, APIs, batch workers) run on Hetzner
- Stateful services (databases, NAS, media library) stay on homelab with better storage
- Hetzner servers connect to homelab databases via WireGuard
Good for: Apps where compute can be elastic but data stays local for performance/cost.
Pattern 4: Pure Burst
- No persistent Hetzner resources
- Scripts provision and deprovision on demand
- Hetzner handles CI builds, rendering, data processing spikes
Good for: Workloads with highly variable load; homelab handles baseline, Hetzner handles peaks.
Security Considerations
Firewall Defaults
Never leave a server open. Use Hetzner Cloud Firewalls as the first line of defense:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
resource "hcloud_firewall" "strict" {
name = "strict-firewall"
# Allow only specific ports
rule {
direction = "in"
protocol = "tcp"
port = "22"
source_ips = ["your.home.ip/32"] # SSH from home only
}
rule {
direction = "in"
protocol = "tcp"
port = "443"
source_ips = ["0.0.0.0/0", "::/0"]
}
rule {
direction = "in"
protocol = "udp"
port = "51820"
source_ips = ["your.home.ip/32"] # WireGuard from home only
}
# All outbound allowed (Hetzner default)
}
|
Never hardcode tokens in .tf files. Use environment variables:
1
2
|
export TF_VAR_hcloud_token="your-token"
terraform apply
|
Or use a secrets manager:
1
2
3
4
5
6
7
|
data "vault_generic_secret" "hcloud" {
path = "secret/hetzner"
}
provider "hcloud" {
token = data.vault_generic_secret.hcloud.data["token"]
}
|
SSH Hardening on Cloud Servers
In your cloud-init:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
#cloud-config
ssh_pwauth: false
disable_root: true
users:
- name: deploy
groups: [sudo, docker]
sudo: ['ALL=(ALL) NOPASSWD:ALL']
shell: /bin/bash
ssh_authorized_keys:
- ssh-ed25519 AAAA... your-key-comment
write_files:
- path: /etc/ssh/sshd_config.d/hardening.conf
content: |
PasswordAuthentication no
PermitRootLogin no
MaxAuthTries 3
ClientAliveInterval 300
ClientAliveCountMax 2
runcmd:
- systemctl restart ssh
|
Putting It Together: A Starter Repository Layout
hetzner-homelab/
├── terraform/
│ ├── versions.tf
│ ├── variables.tf
│ ├── network.tf # Private networks, subnets
│ ├── firewalls.tf # Firewall rules
│ ├── servers.tf # Server definitions
│ ├── floating-ips.tf # Floating IPs
│ ├── load-balancers.tf # Load balancers
│ └── outputs.tf
├── cloud-init/
│ ├── base.yaml # Common packages, users, SSH hardening
│ ├── docker.yaml # Docker + compose setup
│ ├── wireguard.yaml # WireGuard spoke config
│ └── ci-agent.yaml # GitHub/Gitea runner setup
├── scripts/
│ ├── failover.sh # Floating IP failover
│ ├── burst-agents.sh # Provision/deprovision CI agents
│ └── check-costs.sh # Resource audit
└── README.md
Getting Started in 30 Minutes
- Create Hetzner account — cloud.hetzner.com, verify email, add payment method
- Generate API token — Console → Security → API Tokens
- Install tools —
hcloud CLI, Terraform, wireguard-tools
- Add SSH key —
hcloud ssh-key create --name homelab-key --public-key-file ~/.ssh/id_ed25519.pub
- Deploy a test server —
hcloud server create --name test --type cx22 --image ubuntu-24.04 --ssh-key homelab-key --location fsn1
- SSH in —
ssh root@$(hcloud server describe test -o json | jq -r '.public_net.ipv4.ip')
- Delete it —
hcloud server delete test
Total cost for that experiment: less than €0.01.
The power of Hetzner for homelab use isn’t just the price — it’s that the API is clean enough and the instances spin up fast enough (< 30 seconds) that you can treat cloud compute as a utility rather than a capital expense. Burst when you need it, delete when you don’t, and keep your homelab doing what homelabs do best: running the services you care about on hardware you own.
Comments