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