terraform-hetzner

SKILL.md

Terraform Hetzner Patterns

Provision and manage Hetzner Cloud infrastructure with Terraform for cost-optimized VPS deployments targeting ~5€/month.

When to Use This Skill

  • Provisioning Hetzner Cloud VPS instances
  • Configuring firewall rules for K3s clusters
  • Managing SSH keys via Terraform
  • Setting up DNS records (wildcard subdomain routing)
  • Creating reusable Terraform modules
  • Managing Terraform state remotely

Hetzner Provider Setup

Provider Configuration

# versions.tf
terraform {
  required_version = ">= 1.5"

  required_providers {
    hcloud = {
      source  = "hetznercloud/hcloud"
      version = "~> 1.45"
    }
    hetznerdns = {
      source  = "timohirt/hetznerdns"
      version = "~> 2.2"
    }
  }
}

# provider.tf
provider "hcloud" {
  token = var.hcloud_token
}

provider "hetznerdns" {
  apitoken = var.hetzner_dns_token
}

Variables

# variables.tf
variable "hcloud_token" {
  description = "Hetzner Cloud API token"
  type        = string
  sensitive   = true
}

variable "hetzner_dns_token" {
  description = "Hetzner DNS API token"
  type        = string
  sensitive   = true
  default     = ""
}

variable "environment" {
  description = "Environment name (dev, staging, prod)"
  type        = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Environment must be dev, staging, or prod."
  }
}

variable "ssh_public_key" {
  description = "SSH public key for server access"
  type        = string
}

variable "admin_ip" {
  description = "Admin IP address for SSH access restriction"
  type        = string
  default     = "0.0.0.0/0"  # Restrict in production
}

variable "server_type" {
  description = "Hetzner server type"
  type        = string
  default     = "cx22"  # 2 vCPU, 4GB RAM, ~4.85€/mo
}

variable "location" {
  description = "Hetzner datacenter location"
  type        = string
  default     = "fsn1"  # Falkenstein, Germany
}

VPS Provisioning

Server Resource

# main.tf
resource "hcloud_ssh_key" "main" {
  name       = "fsn-${var.environment}"
  public_key = var.ssh_public_key
}

resource "hcloud_server" "k3s" {
  name        = "k3s-${var.environment}"
  server_type = var.server_type
  location    = var.location
  image       = "ubuntu-22.04"

  ssh_keys = [hcloud_ssh_key.main.id]

  firewall_ids = [hcloud_firewall.k3s.id]

  labels = {
    environment = var.environment
    project     = "fsn-monorepo"
    managed_by  = "terraform"
  }

  user_data = templatefile("${path.module}/cloud-init.yaml", {
    ssh_public_key = var.ssh_public_key
  })

  # Prevent destruction without explicit confirmation
  lifecycle {
    prevent_destroy = true
  }
}

Cloud-Init Template

# cloud-init.yaml
#cloud-config
package_update: true
package_upgrade: true

packages:
  - curl
  - ufw
  - fail2ban
  - unattended-upgrades

# Harden SSH
write_files:
  - path: /etc/ssh/sshd_config.d/hardening.conf
    content: |
      PasswordAuthentication no
      PermitRootLogin prohibit-password
      MaxAuthTries 3

runcmd:
  # Install K3s
  - curl -sfL https://get.k3s.io | sh -s - --write-kubeconfig-mode 644
  # Enable automatic security updates
  - systemctl enable unattended-upgrades

Firewall Configuration

resource "hcloud_firewall" "k3s" {
  name = "k3s-${var.environment}"

  # SSH access (restricted to admin IP)
  rule {
    direction  = "in"
    protocol   = "tcp"
    port       = "22"
    source_ips = [var.admin_ip]
    description = "SSH from admin"
  }

  # HTTP (for Let's Encrypt HTTP-01 challenge and redirect)
  rule {
    direction  = "in"
    protocol   = "tcp"
    port       = "80"
    source_ips = ["0.0.0.0/0", "::/0"]
    description = "HTTP"
  }

  # HTTPS
  rule {
    direction  = "in"
    protocol   = "tcp"
    port       = "443"
    source_ips = ["0.0.0.0/0", "::/0"]
    description = "HTTPS"
  }

  # K3s API (only if managing remotely)
  rule {
    direction  = "in"
    protocol   = "tcp"
    port       = "6443"
    source_ips = [var.admin_ip]
    description = "K3s API from admin"
  }

  labels = {
    environment = var.environment
    managed_by  = "terraform"
  }
}

DNS Configuration

Hetzner DNS

# DNS zone
data "hetznerdns_zone" "main" {
  name = "easyfactu.es"
}

# A record for API
resource "hetznerdns_record" "api" {
  zone_id = data.hetznerdns_zone.main.id
  name    = "api"
  type    = "A"
  value   = hcloud_server.k3s.ipv4_address
  ttl     = 300
}

# A record for web app
resource "hetznerdns_record" "app" {
  zone_id = data.hetznerdns_zone.main.id
  name    = "app"
  type    = "A"
  value   = hcloud_server.k3s.ipv4_address
  ttl     = 300
}

# Wildcard for tenant subdomains
resource "hetznerdns_record" "wildcard_app" {
  zone_id = data.hetznerdns_zone.main.id
  name    = "*.app"
  type    = "A"
  value   = hcloud_server.k3s.ipv4_address
  ttl     = 300
}

# AAAA record (IPv6)
resource "hetznerdns_record" "api_ipv6" {
  zone_id = data.hetznerdns_zone.main.id
  name    = "api"
  type    = "AAAA"
  value   = hcloud_server.k3s.ipv6_address
  ttl     = 300
}

Networking

Private Network

resource "hcloud_network" "main" {
  name     = "fsn-${var.environment}"
  ip_range = "10.0.0.0/16"

  labels = {
    environment = var.environment
    managed_by  = "terraform"
  }
}

resource "hcloud_network_subnet" "k3s" {
  network_id   = hcloud_network.main.id
  type         = "cloud"
  network_zone = "eu-central"
  ip_range     = "10.0.1.0/24"
}

# Attach server to private network
resource "hcloud_server_network" "k3s" {
  server_id  = hcloud_server.k3s.id
  network_id = hcloud_network.main.id
  ip         = "10.0.1.1"
}

Persistent Volumes

resource "hcloud_volume" "data" {
  name      = "data-${var.environment}"
  size      = 10  # GB, starts at ~0.44€/mo per 10GB
  server_id = hcloud_server.k3s.id
  location  = var.location
  format    = "ext4"

  labels = {
    environment = var.environment
    managed_by  = "terraform"
  }
}

Reverse DNS

resource "hcloud_rdns" "k3s_ipv4" {
  server_id  = hcloud_server.k3s.id
  ip_address = hcloud_server.k3s.ipv4_address
  dns_ptr    = "k3s-${var.environment}.easyfactu.es"
}

resource "hcloud_rdns" "k3s_ipv6" {
  server_id  = hcloud_server.k3s.id
  ip_address = hcloud_server.k3s.ipv6_address
  dns_ptr    = "k3s-${var.environment}.easyfactu.es"
}

Outputs

# outputs.tf
output "server_ipv4" {
  description = "Server public IPv4 address"
  value       = hcloud_server.k3s.ipv4_address
}

output "server_ipv6" {
  description = "Server public IPv6 address"
  value       = hcloud_server.k3s.ipv6_address
}

output "server_id" {
  description = "Hetzner server ID"
  value       = hcloud_server.k3s.id
}

output "ssh_command" {
  description = "SSH command to connect to the server"
  value       = "ssh root@${hcloud_server.k3s.ipv4_address}"
}

output "kubeconfig_command" {
  description = "Command to copy kubeconfig"
  value       = "scp root@${hcloud_server.k3s.ipv4_address}:/etc/rancher/k3s/k3s.yaml ~/.kube/k3s-config"
}

Module Structure

Reusable Module

packages/tf/hetzner-k3s/
├── main.tf          # Server + firewall resources
├── variables.tf     # Input variables with validation
├── outputs.tf       # Output values
├── versions.tf      # Provider version constraints
├── cloud-init.yaml  # Server initialization template
└── README.md        # Usage documentation

Module Usage

# infra/terraform/environments/prod/main.tf
module "k3s" {
  source = "../../../../packages/tf/hetzner-k3s"

  environment    = "prod"
  server_type    = "cx22"
  location       = "fsn1"
  ssh_public_key = var.ssh_public_key
  admin_ip       = var.admin_ip
  hcloud_token   = var.hcloud_token
}

output "server_ip" {
  value = module.k3s.server_ipv4
}

State Management

Remote Backend (S3-Compatible)

# backend.tf
terraform {
  backend "s3" {
    bucket                      = "fsn-terraform-state"
    key                         = "prod/terraform.tfstate"
    region                      = "eu-central-1"
    endpoint                    = "https://s3.eu-central-1.wasabisys.com"
    skip_credentials_validation = true
    skip_metadata_api_check     = true
    skip_region_validation      = true
  }
}

Terraform Cloud (Free Tier)

terraform {
  cloud {
    organization = "fsn"
    workspaces {
      name = "fsn-monorepo-prod"
    }
  }
}

Cost Optimization

Server Type Comparison

Type vCPU RAM SSD Monthly Recommendation
CX11 1 2 GB 20 GB ~3.29€ Too small for K3s
CX22 2 4 GB 40 GB ~4.85€ Recommended
CX32 4 8 GB 80 GB ~8.49€ If more headroom needed
CAX11 2 4 GB 40 GB ~3.79€ ARM64, cheaper

Cost Breakdown (~5€/month target)

Resource Monthly Cost
CX22 VPS ~4.85€
Hetzner DNS Free
Let's Encrypt SSL Free
GHCR (registry) Free
Supabase (DB) Free tier
Total ~4.85€

Best Practices

  • Use terraform.tfvars for secrets (add to .gitignore)
  • Tag all resources with environment and managed_by labels
  • Use lifecycle { prevent_destroy = true } for production servers
  • Run terraform plan before every apply
  • Pin provider versions with ~> operator
  • Store state remotely with locking enabled
  • Use modules in packages/tf/ for reusable components
  • Restrict SSH access to admin IP in production

Guidelines

  • Target CX22 (~5€/mo) for single-node K3s deployments
  • Store secrets in terraform.tfvars (never commit to git)
  • Use Hetzner DNS for free DNS management
  • Apply firewall rules to restrict SSH access
  • Use cloud-init for server bootstrap scripts
  • Pin provider versions to prevent unexpected changes
  • Output connection details (IP, SSH command) from modules
  • Use remote state with locking for team collaboration
Weekly Installs
1
First Seen
12 days ago
Installed on
mcpjam1
claude-code1
junie1
windsurf1
zencoder1
crush1