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:
parent
3848510e1b
commit
0135bd360a
7 changed files with 476 additions and 0 deletions
208
tofu/README.md
Normal file
208
tofu/README.md
Normal 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
44
tofu/dns.tf
Normal 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
89
tofu/main.tf
Normal 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
48
tofu/outputs.tf
Normal 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
|
||||||
|
}
|
||||||
26
tofu/terraform.tfvars.example
Normal file
26
tofu/terraform.tfvars.example
Normal 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
45
tofu/variables.tf
Normal 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
16
tofu/versions.tf
Normal 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"
|
||||||
|
# }
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue