From 0135bd360a8362c28b88e9238448035db1783008 Mon Sep 17 00:00:00 2001 From: Pieter Date: Sat, 27 Dec 2025 13:48:42 +0100 Subject: [PATCH] Implement OpenTofu infrastructure provisioning (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completed: - ✅ Hetzner Cloud provider configuration - ✅ VPS server provisioning with for_each pattern - ✅ Cloud firewall rules (SSH, HTTP, HTTPS) - ✅ SSH key management - ✅ Outputs for Ansible dynamic inventory - ✅ Variable structure and documentation - ✅ Test server successfully provisioned Deferred: - DNS configuration (commented out, waiting for domain) Files added: - tofu/versions.tf - Provider versions - tofu/variables.tf - Input variable definitions - tofu/main.tf - Core infrastructure resources - tofu/dns.tf - DNS configuration (optional) - tofu/outputs.tf - Outputs for Ansible integration - tofu/terraform.tfvars.example - Configuration template - tofu/README.md - Comprehensive setup guide Closes #1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- tofu/README.md | 208 ++++++++++++++++++++++++++++++++++ tofu/dns.tf | 44 +++++++ tofu/main.tf | 89 +++++++++++++++ tofu/outputs.tf | 48 ++++++++ tofu/terraform.tfvars.example | 26 +++++ tofu/variables.tf | 45 ++++++++ tofu/versions.tf | 16 +++ 7 files changed, 476 insertions(+) create mode 100644 tofu/README.md create mode 100644 tofu/dns.tf create mode 100644 tofu/main.tf create mode 100644 tofu/outputs.tf create mode 100644 tofu/terraform.tfvars.example create mode 100644 tofu/variables.tf create mode 100644 tofu/versions.tf diff --git a/tofu/README.md b/tofu/README.md new file mode 100644 index 0000000..953fc46 --- /dev/null +++ b/tofu/README.md @@ -0,0 +1,208 @@ +# OpenTofu Configuration for Hetzner Cloud + +This directory contains Infrastructure as Code using OpenTofu to provision VPS instances on Hetzner Cloud. + +## Quick Start + +### 1. Prerequisites + +- OpenTofu installed (`brew install opentofu`) +- Hetzner Cloud account +- Domain registered and added to Hetzner DNS + +### 2. Get Hetzner API Tokens + +#### Cloud API Token: +1. Go to https://console.hetzner.cloud/ +2. Select your project (or create one) +3. Navigate to **Security** → **API tokens** +4. Click **Generate API token** +5. Name: `infrastructure-provisioning` +6. Permissions: **Read & Write** +7. Copy the token (shown only once!) + +#### DNS API Token: +1. Go to https://dns.hetzner.com/ +2. Click on your account name → **API Tokens** +3. Click **Create access token** +4. Name: `infrastructure-dns` +5. Copy the token + +> **Note**: You can use the same token for both if it has the necessary permissions. + +### 3. Add Your Domain to Hetzner DNS + +1. Go to https://dns.hetzner.com/ +2. Click **Add new zone** +3. Enter your domain (e.g., `platform.nl`) +4. Update your domain registrar's nameservers to: + - `hydrogen.ns.hetzner.com` + - `oxygen.ns.hetzner.com` + - `helium.ns.hetzner.de` + +### 4. Configure OpenTofu + +Create `terraform.tfvars` from the example: + +```bash +cd tofu +cp terraform.tfvars.example terraform.tfvars +``` + +Edit `terraform.tfvars` with your values: + +```hcl +hcloud_token = "YOUR_ACTUAL_HETZNER_CLOUD_TOKEN" +hetznerdns_token = "YOUR_ACTUAL_HETZNER_DNS_TOKEN" + +# Your SSH public key (e.g., from ~/.ssh/id_ed25519.pub) +ssh_public_key = "ssh-ed25519 AAAA... user@hostname" + +# Your domain registered in Hetzner DNS +base_domain = "your-domain.com" + +# Start with one test client +clients = { + test = { + server_type = "cx22" # 2 vCPU, 4 GB RAM - €6.25/month + location = "fsn1" # Falkenstein, Germany + subdomain = "test" # Will create test.your-domain.com + apps = ["zitadel", "nextcloud"] + } +} + +enable_snapshots = true +``` + +### 5. Initialize OpenTofu + +```bash +tofu init +``` + +This downloads the Hetzner provider plugins. + +### 6. Plan Infrastructure + +```bash +tofu plan +``` + +Review what will be created: +- SSH key resource +- Firewall rules +- VPS server(s) +- DNS records (A, AAAA, wildcard) + +### 7. Apply Configuration + +```bash +tofu apply +``` + +Type `yes` when prompted. This will: +- Upload your SSH key to Hetzner +- Create firewall rules +- Provision VPS instance(s) +- Create DNS records + +### 8. View Outputs + +```bash +tofu output +``` + +Shows: +- Client IP addresses +- FQDNs +- Complete client information + +## Server Sizes + +| Type | vCPU | RAM | Disk | Price/month | Use Case | +|------|------|-----|------|-------------|----------| +| cx22 | 2 | 4 GB | 40 GB | €6.25 | Small clients (1-10 users) | +| cx32 | 4 | 8 GB | 80 GB | €12.50 | Medium clients (10-50 users) | +| cx42 | 8 | 16 GB | 160 GB | €24.90 | Large clients (50+ users) | + +## Locations + +- `fsn1` - Falkenstein, Germany +- `nbg1` - Nuremberg, Germany +- `hel1` - Helsinki, Finland + +## Important Files + +- `terraform.tfvars` - **GITIGNORED** - Your secrets and configuration +- `versions.tf` - Provider versions +- `variables.tf` - Input variable definitions +- `main.tf` - Server and firewall resources +- `dns.tf` - DNS record management +- `outputs.tf` - Output values for Ansible + +## Adding a New Client + +Edit `terraform.tfvars` and add to the `clients` map: + +```hcl +clients = { + existing-client = { ... } + + new-client = { + server_type = "cx22" + location = "fsn1" + subdomain = "newclient" + apps = ["zitadel", "nextcloud"] + } +} +``` + +Then run: +```bash +tofu plan # Review changes +tofu apply # Provision new server +``` + +## Removing a Client + +Remove the client from `terraform.tfvars`, then: + +```bash +tofu plan # Verify what will be destroyed +tofu apply # Remove server and DNS records +``` + +**Warning**: This permanently deletes the server. Ensure backups are taken first! + +## State Management + +OpenTofu state is stored locally in `terraform.tfstate` (gitignored). + +For production with multiple team members, consider: +- Remote state backend (S3, Terraform Cloud, etc.) +- State locking +- Encrypted state storage + +## Troubleshooting + +### "Zone not found" error +- Ensure your domain is added to Hetzner DNS +- Wait for DNS propagation (can take 24-48 hours) +- Verify zone name matches exactly (no trailing dot) + +### SSH key errors +- Ensure `ssh_public_key` is the **public** key content +- Format: `ssh-ed25519 AAAA... comment` or `ssh-rsa AAAA... comment` +- No newlines or extra whitespace + +### API token errors +- Ensure Read & Write permissions +- Check token hasn't expired +- Verify correct project selected in Hetzner console + +## Next Steps + +After provisioning: +1. SSH to server: `ssh root@` +2. Run Ansible configuration: `cd ../ansible && ansible-playbook playbooks/setup.yml` +3. Applications will be deployed via Ansible diff --git a/tofu/dns.tf b/tofu/dns.tf new file mode 100644 index 0000000..5afccbc --- /dev/null +++ b/tofu/dns.tf @@ -0,0 +1,44 @@ +# DNS Configuration +# OPTIONAL: Only used if you have a domain registered in Hetzner DNS +# Comment out this entire file if you don't have a domain yet + +# Uncomment below when you have a domain registered in Hetzner DNS +/* +# DNS Zone (must already exist in Hetzner DNS) +data "hetznerdns_zone" "main" { + name = var.base_domain +} + +# A Records for client servers +resource "hetznerdns_record" "client_a" { + for_each = var.clients + + zone_id = data.hetznerdns_zone.main.id + name = each.value.subdomain + type = "A" + value = hcloud_server.client[each.key].ipv4_address + ttl = 300 +} + +# Wildcard A record for each client (for subdomains like auth.alpha.platform.nl) +resource "hetznerdns_record" "client_wildcard" { + for_each = var.clients + + zone_id = data.hetznerdns_zone.main.id + name = "*.${each.value.subdomain}" + type = "A" + value = hcloud_server.client[each.key].ipv4_address + ttl = 300 +} + +# AAAA Records for IPv6 +resource "hetznerdns_record" "client_aaaa" { + for_each = var.clients + + zone_id = data.hetznerdns_zone.main.id + name = each.value.subdomain + type = "AAAA" + value = hcloud_server.client[each.key].ipv6_address + ttl = 300 +} +*/ diff --git a/tofu/main.tf b/tofu/main.tf new file mode 100644 index 0000000..293679a --- /dev/null +++ b/tofu/main.tf @@ -0,0 +1,89 @@ +# Provider Configuration +provider "hcloud" { + token = var.hcloud_token +} + +# DNS provider - uncomment when using Hetzner DNS +# provider "hetznerdns" { +# apitoken = var.hetznerdns_token +# } + +# SSH Key Resource +resource "hcloud_ssh_key" "default" { + name = "infrastructure-deploy-key" + public_key = var.ssh_public_key +} + +# Firewall Rules +resource "hcloud_firewall" "client_firewall" { + name = "client-default-firewall" + + # SSH (restricted - add your management IPs here) + rule { + direction = "in" + protocol = "tcp" + port = "22" + source_ips = [ + "0.0.0.0/0", # CHANGE THIS: Replace with your management IP + "::/0" + ] + } + + # HTTP (for Let's Encrypt challenge) + rule { + direction = "in" + protocol = "tcp" + port = "80" + source_ips = [ + "0.0.0.0/0", + "::/0" + ] + } + + # HTTPS + rule { + direction = "in" + protocol = "tcp" + port = "443" + source_ips = [ + "0.0.0.0/0", + "::/0" + ] + } +} + +# Client VPS Instances +resource "hcloud_server" "client" { + for_each = var.clients + + name = each.key + server_type = each.value.server_type + image = "ubuntu-24.04" + location = each.value.location + ssh_keys = [hcloud_ssh_key.default.id] + firewall_ids = [hcloud_firewall.client_firewall.id] + + labels = { + client = each.key + role = "app-server" + # Note: labels can't contain special chars, store apps list separately if needed + } + + # Enable backups if requested + backups = var.enable_snapshots + + # User data for initial setup + user_data = <<-EOF + #cloud-config + package_update: true + package_upgrade: true + packages: + - curl + - wget + - git + - python3 + - python3-pip + runcmd: + - hostnamectl set-hostname ${each.key} + EOF +} diff --git a/tofu/outputs.tf b/tofu/outputs.tf new file mode 100644 index 0000000..75f1f60 --- /dev/null +++ b/tofu/outputs.tf @@ -0,0 +1,48 @@ +# Outputs for Ansible and monitoring + +# Client server IPs +output "client_ips" { + description = "Map of client names to their IPv4 addresses" + value = { + for name, server in hcloud_server.client : + name => server.ipv4_address + } +} + +# Client FQDNs +output "client_fqdns" { + description = "Map of client names to their fully qualified domain names" + value = { + for name, config in var.clients : + name => "${config.subdomain}.${var.base_domain}" + } +} + +# All client information +output "clients" { + description = "Complete client information" + value = { + for name, server in hcloud_server.client : + name => { + id = server.id + name = server.name + ipv4 = server.ipv4_address + ipv6 = server.ipv6_address + location = server.location + fqdn = "${var.clients[name].subdomain}.${var.base_domain}" + apps = var.clients[name].apps + } + } +} + +# Ansible inventory hint +output "ansible_inventory_hint" { + description = "Hint for Ansible dynamic inventory configuration" + value = <<-EOT + Configure Ansible to use Hetzner dynamic inventory: + + 1. Set HCLOUD_TOKEN environment variable + 2. Use ansible/hcloud.yml inventory configuration + 3. Run: ansible-inventory -i ansible/hcloud.yml --graph + EOT +} diff --git a/tofu/terraform.tfvars.example b/tofu/terraform.tfvars.example new file mode 100644 index 0000000..932a68c --- /dev/null +++ b/tofu/terraform.tfvars.example @@ -0,0 +1,26 @@ +# Copy this file to terraform.tfvars and fill in your values +# terraform.tfvars is gitignored and will not be committed + +# Hetzner API Tokens +hcloud_token = "YOUR_HETZNER_CLOUD_API_TOKEN" +hetznerdns_token = "YOUR_HETZNER_DNS_API_TOKEN" # Can be same as cloud token + +# SSH Public Key (paste the contents of ~/.ssh/id_rsa.pub or similar) +ssh_public_key = "ssh-ed25519 AAAA... user@hostname" + +# Base domain (must be registered and added to Hetzner DNS) +base_domain = "example.com" + +# Client configurations +clients = { + # Example client - uncomment and modify when ready to provision + # alpha = { + # server_type = "cx22" # 2 vCPU, 4 GB RAM, 40 GB SSD - €6.25/month + # location = "fsn1" # Falkenstein, Germany + # subdomain = "alpha" # Will create alpha.example.com + # apps = ["zitadel", "nextcloud"] + # } +} + +# Enable automated snapshots (20% of server cost) +enable_snapshots = true diff --git a/tofu/variables.tf b/tofu/variables.tf new file mode 100644 index 0000000..724494a --- /dev/null +++ b/tofu/variables.tf @@ -0,0 +1,45 @@ +# Hetzner Cloud API Token +variable "hcloud_token" { + description = "Hetzner Cloud API Token (Read & Write)" + type = string + sensitive = true +} + +# Hetzner DNS API Token (can be same as Cloud token) +variable "hetznerdns_token" { + description = "Hetzner DNS API Token" + type = string + sensitive = true +} + +# SSH Public Key +variable "ssh_public_key" { + description = "SSH public key for server access" + type = string +} + +# Base Domain (optional - only needed if using DNS) +variable "base_domain" { + description = "Base domain for client subdomains (e.g., platform.nl) - leave empty if not using DNS" + type = string + default = "" +} + +# Client Configurations +variable "clients" { + description = "Map of client configurations" + type = map(object({ + server_type = string # e.g., "cx22" (2 vCPU, 4 GB RAM) + location = string # e.g., "fsn1" (Falkenstein), "nbg1" (Nuremberg), "hel1" (Helsinki) + subdomain = string # e.g., "alpha" for alpha.platform.nl + apps = list(string) # e.g., ["zitadel", "nextcloud"] + })) + default = {} +} + +# Enable automated snapshots +variable "enable_snapshots" { + description = "Enable automated daily snapshots (20% of server cost)" + type = bool + default = true +} diff --git a/tofu/versions.tf b/tofu/versions.tf new file mode 100644 index 0000000..2bef68d --- /dev/null +++ b/tofu/versions.tf @@ -0,0 +1,16 @@ +terraform { + required_version = ">= 1.6.0" + + required_providers { + hcloud = { + source = "hetznercloud/hcloud" + version = "~> 1.45" + } + # DNS provider - optional, only needed if using Hetzner DNS + # Commented out since DNS is not required initially + # hetznerdns = { + # source = "timohirt/hetznerdns" + # version = "~> 2.4" + # } + } +}