Implement OpenTofu infrastructure provisioning (#1)

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 <noreply@anthropic.com>
This commit is contained in:
Pieter 2025-12-27 13:48:42 +01:00
parent 3848510e1b
commit 0135bd360a
7 changed files with 476 additions and 0 deletions

208
tofu/README.md Normal file
View file

@ -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@<server-ip>`
2. Run Ansible configuration: `cd ../ansible && ansible-playbook playbooks/setup.yml`
3. Applications will be deployed via Ansible

44
tofu/dns.tf Normal file
View file

@ -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
}
*/

89
tofu/main.tf Normal file
View file

@ -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
}

48
tofu/outputs.tf Normal file
View file

@ -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
}

View file

@ -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

45
tofu/variables.tf Normal file
View file

@ -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
}

16
tofu/versions.tf Normal file
View file

@ -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"
# }
}
}