Skip to content

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

cd ~/coder-core/terraform/hetzner
terraform output

Expected output:

bruno_ip = "188.34.198.57"
bruno_id = "12345678"
bruno_ssh = "ssh root@188.34.198.57"

Add New Server

  1. Edit main.tf and 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"
  }
}
  1. Apply:
./bin/deploy-infra.sh apply
  1. Update Ansible inventory:
vim ~/coder-core/ansible/inventory/hosts.yml
# Add new server with IP from terraform output

Upgrade Server Type

  1. Edit main.tf:
resource "hcloud_server" "bruno" {
  server_type = "cpx42"  # Was: cpx32
  # ...
}
  1. Apply:
./bin/deploy-infra.sh apply
# Server will be resized (may require reboot)

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:

./bin/deploy-infra.sh 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

  1. Add key to Hetzner Cloud Console
  2. Add data source in main.tf:
data "hcloud_ssh_key" "new_device" {
  name = "new-device-key"
}
  1. 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:

~/coder-core/terraform/hetzner/terraform.tfstate

State File

This file contains sensitive information. Do not commit to Git. It's already in .gitignore.

View State

cd ~/coder-core/terraform/hetzner
terraform state list
terraform state show hcloud_server.bruno

Troubleshooting

"Resource already exists"

Cause: Server exists in Hetzner but not in Terraform state

Fix: Import the resource:

terraform import hcloud_server.bruno <server_id>

"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:

  1. Check token in Infisical: HETZNER_API_TOKEN
  2. Verify deploy-infra.sh is exporting it correctly


Last updated: January 2026