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.tfvarsfor secrets (add to.gitignore) - Tag all resources with
environmentandmanaged_bylabels - Use
lifecycle { prevent_destroy = true }for production servers - Run
terraform planbefore everyapply - 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
Repository
franciscosanche…factu-esFirst Seen
12 days ago
Security Audits
Installed on
mcpjam1
claude-code1
junie1
windsurf1
zencoder1
crush1