VPS Management with Terraform¶
Complete guide for managing Hetzner VPS servers with Terraform.
Overview¶
What Terraform Manages¶
| Resource | Description |
|---|---|
| Bruno | Production server (CPX32) |
| Firewalls | Bruno firewall rules |
| SSH Keys | References existing Hetzner SSH keys |
Development VPS
development-vps is NOT managed by Terraform (created manually).
Current Configuration¶
main.tf¶
Location: ~/coder-core/terraform/hetzner/main.tf
terraform {
required_version = ">= 1.0"
required_providers {
hcloud = {
source = "hetznercloud/hcloud"
version = "~> 1.45"
}
}
}
provider "hcloud" {
token = var.hcloud_token
}
# SSH Keys (references existing keys in Hetzner)
data "hcloud_ssh_key" "macmini" {
name = "kavi@Mac-Mini-Kavi.local"
}
data "hcloud_ssh_key" "macbookpro" {
name = "macbookpro-key"
}
# Bruno - Production Server
resource "hcloud_server" "bruno" {
name = "bruno"
image = "ubuntu-24.04"
server_type = "cpx32" # 4 vCPU, 8GB RAM, 160GB NVMe
location = "nbg1" # Nuremberg
ssh_keys = [
data.hcloud_ssh_key.macmini.id,
data.hcloud_ssh_key.macbookpro.id
]
labels = {
environment = "production"
managed_by = "terraform"
}
lifecycle {
prevent_destroy = true # Protect production
}
}
# Firewall for Bruno
resource "hcloud_firewall" "bruno" {
name = "bruno-firewall"
rule {
direction = "in"
protocol = "tcp"
port = "22"
source_ips = ["0.0.0.0/0", "::/0"]
}
rule {
direction = "in"
protocol = "tcp"
port = "80"
source_ips = ["0.0.0.0/0", "::/0"]
}
rule {
direction = "in"
protocol = "tcp"
port = "443"
source_ips = ["0.0.0.0/0", "::/0"]
}
rule {
direction = "in"
protocol = "udp"
port = "41641"
source_ips = ["0.0.0.0/0", "::/0"] # Tailscale
}
rule {
direction = "in"
protocol = "tcp"
port = "2222"
source_ips = ["0.0.0.0/0", "::/0"] # Git SSH
}
}
resource "hcloud_firewall_attachment" "bruno" {
firewall_id = hcloud_firewall.bruno.id
server_ids = [hcloud_server.bruno.id]
}
Server Types¶
| Type | vCPU | RAM | Disk | Price/month | Use Case |
|---|---|---|---|---|---|
| CX22 | 2 | 4GB | 40GB | ~€5 | Dev/Test |
| CX32 | 4 | 8GB | 80GB | ~€10 | Development |
| CPX32 | 4 | 8GB | 160GB | ~€15 | Production |
| CPX42 | 8 | 16GB | 240GB | ~€30 | High Traffic |
| CPX51 | 16 | 32GB | 360GB | ~€60 | Enterprise |
Current: Bruno uses CPX32 (4 vCPU, 8GB RAM, 160GB NVMe)
Locations¶
| Code | Location | Notes |
|---|---|---|
| nbg1 | Nuremberg, Germany | Current |
| fsn1 | Falkenstein, Germany | Alternative |
| hel1 | Helsinki, Finland | Nordic |
| ash | Ashburn, US | Americas |
Operations¶
Deploy Infrastructure¶
cd ~/coder-core
./bin/deploy-infra.sh plan # Preview changes
./bin/deploy-infra.sh apply # Apply changes
./bin/deploy-infra.sh output # View outputs
View Current State¶
Expected output:
Add New Server¶
- Edit
main.tfand add new server resource:
resource "hcloud_server" "my_new_server" {
name = "my-new-server"
image = "ubuntu-24.04"
server_type = "cpx32"
location = "nbg1"
ssh_keys = [
data.hcloud_ssh_key.macmini.id,
data.hcloud_ssh_key.macbookpro.id
]
labels = {
environment = "production"
managed_by = "terraform"
}
}
- Apply:
- Update Ansible inventory:
Upgrade Server Type¶
- Edit
main.tf:
- Apply:
Delete Server¶
Prevent Destroy
Bruno has prevent_destroy = true. To delete:
1. Remove `prevent_destroy` from lifecycle block
2. Run `terraform destroy -target=hcloud_server.bruno`
Firewall Rules¶
Current Rules (Bruno)¶
| Port | Protocol | Purpose |
|---|---|---|
| 22 | TCP | SSH |
| 80 | TCP | HTTP (redirects to HTTPS) |
| 443 | TCP | HTTPS (Traefik) |
| 2222 | TCP | Git SSH (Forgejo) |
| 41641 | UDP | Tailscale |
Add New Firewall Rule¶
Edit main.tf:
resource "hcloud_firewall" "bruno" {
# ... existing rules ...
# New rule for custom port
rule {
direction = "in"
protocol = "tcp"
port = "8080"
source_ips = ["0.0.0.0/0", "::/0"]
}
}
Apply:
SSH Keys¶
SSH keys are managed in Hetzner Cloud Console, then referenced in Terraform:
# Reference existing key
data "hcloud_ssh_key" "macmini" {
name = "kavi@Mac-Mini-Kavi.local" # Must match name in Hetzner
}
Add New SSH Key¶
- Add key to Hetzner Cloud Console
- Add data source in
main.tf:
- Add to server's ssh_keys list:
resource "hcloud_server" "bruno" {
ssh_keys = [
data.hcloud_ssh_key.macmini.id,
data.hcloud_ssh_key.macbookpro.id,
data.hcloud_ssh_key.new_device.id,
]
}
State Management¶
State File Location¶
State is stored locally at:
State File
This file contains sensitive information. Do not commit to Git.
It's already in .gitignore.
View State¶
Troubleshooting¶
"Resource already exists"¶
Cause: Server exists in Hetzner but not in Terraform state
Fix: Import the resource:
"Error getting SSH key"¶
Cause: SSH key name in Terraform doesn't match Hetzner
Fix: Check exact name in Hetzner Cloud Console and update main.tf
"Unauthorized" or Token Error¶
Cause: Invalid or missing Hetzner API token
Fix:
- Check token in Infisical:
HETZNER_API_TOKEN - Verify
deploy-infra.shis exporting it correctly
Related Documentation¶
- Provisioning Protocol - Full provisioning guide
- Hetzner Overview - Server details
- Ansible Playbooks - Post-provision configuration
Last updated: January 2026