LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Hetzner Cloud for Homelab Overflow: Cost-Effective Cloud Bursting and Geo-Redundancy

hetznercloudhomelabvpsinfrastructureterraformkubernetesgeo-redundancy

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

Terraform Provider for Hetzner

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

Terraform: Multi-Region 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
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

Terraform Remote State on Hetzner

 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)
}

Secrets in Terraform

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

  1. Create Hetzner account — cloud.hetzner.com, verify email, add payment method
  2. Generate API token — Console → Security → API Tokens
  3. Install toolshcloud CLI, Terraform, wireguard-tools
  4. Add SSH keyhcloud ssh-key create --name homelab-key --public-key-file ~/.ssh/id_ed25519.pub
  5. Deploy a test serverhcloud server create --name test --type cx22 --image ubuntu-24.04 --ssh-key homelab-key --location fsn1
  6. SSH inssh root@$(hcloud server describe test -o json | jq -r '.public_net.ipv4.ip')
  7. Delete ithcloud 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