Infrastructure as Code: Provisioning con Terraform su Hetzner Cloud

Come ho trasformato l'architettura progettata nel primo articolo in codice riproducibile con Terraform. Dal setup iniziale alla generazione automatica di inventory Ansible.

Contesto: Dal Design all'Implementazione

Nel primo articolo ho documentato l'architettura di rete multi-tenant che ho progettato. Ora mostro come l'ho implementata usando Terraform come Infrastructure as Code (IaC). L'obiettivo era avere un'infrastruttura completamente riproducibile: distruggere e ricreare tutto in ~20 minuti con un singolo comando terraform apply.

Il codice completo è disponibile su GitHub: magefleet/terraform-init-infra

Cosa Implementeremo

  • 4 reti private (Management, Shared, Business, Enterprise) configurabili via variabili
  • Bastion host con NAT gateway e WireGuard VPN auto-configurato via cloud-init
  • Generazione automatica di chiavi SSH interne per comunicazione tra server
  • Moduli riutilizzabili per Rancher, Vault, ArgoCD, Ecommerce
  • Generazione automatica di Ansible inventory da Terraform state
  • Output pronti per testing e debugging

1. Setup Iniziale del Progetto

1.1 Struttura delle Directory

Ho organizzato il progetto Terraform con una struttura modulare che separa chiaramente le responsabilità:

terraform-init-infra/
├── main.tf                          # Orchestrazione principale
├── provider.tf                      # Configurazione provider Hetzner
├── networks.tf                      # Definizione reti multi-tenant
├── variables.tf                     # Variabili configurabili
├── outputs.tf                       # Output per testing e debugging
├── terraform.tfvars                 # Valori delle variabili (GITIGNORED!)
├── terraform.tfvars.example         # Template per terraform.tfvars
├── modules/
│   ├── rancher/                     # Modulo Rancher management cluster
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   ├── vault/                       # Modulo HashiCorp Vault
│   ├── argocd/                      # Modulo ArgoCD GitOps
│   ├── ecommerce/                   # Modulo Magento/Ecommerce
│   └── ansible/                     # Modulo per generare inventory
└── templates/
    ├── userdata_bastion.tpl         # Cloud-init per bastion host
    └── userdata_cluster_node.tpl    # Cloud-init per cluster nodes
            

Rationale della struttura: Ogni modulo è indipendente e riutilizzabile. Posso disabilitare singoli componenti (es. Rancher) tramite variabili booleane senza modificare il codice (var.enable_rancher).

1.2 Configurazione Provider Hetzner

La configurazione del provider è minimale. Nel file provider.tf:

# Riferimento: provider.tf linee 1-3
provider "hcloud" {
  token = var.hcloud_token
}
            

Il token API viene passato tramite variabile invece di essere hardcoded. In main.tf ho definito i required providers con versioni locked per riproducibilità:

# Riferimento: main.tf linee 1-16
terraform {
  required_providers {
    hcloud = {
      source  = "hetznercloud/hcloud"
      version = "~> 1.52.0"    # Lock version per stabilità
    }
    tls = {
      source  = "hashicorp/tls"
      version = "~> 4.0"        # Per generazione chiavi SSH
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"        # Per rotation key generation
    }
  }
}
            

Best Practice: Lock sempre le versioni dei provider in produzione. L'operatore ~> permette patch updates (1.52.1, 1.52.2) ma blocca minor/major updates che potrebbero introdurre breaking changes.

1.3 Ottenere il Token API Hetzner

Per ottenere il token API Hetzner Cloud:

  1. Accedi a Hetzner Cloud Console
  2. Seleziona il tuo progetto
  3. Vai su Security → API Tokens
  4. Genera un nuovo token con permessi Read & Write
  5. Copia il token (verrà mostrato una sola volta!)

Salva il token in terraform.tfvars:

hcloud_token = "your-api-token-here"
ssh_key_name = "your-ssh-key-name"
hcloud_ssh_private_key_path = "~/.ssh/id_rsa"
            

Security note: Il file terraform.tfvars contiene segreti e DEVE essere in .gitignore. Ho incluso un terraform.tfvars.example con placeholder per documentare le variabili richieste.

2. Implementazione della Rete Multi-Tenant

2.1 Management Network (10.0.0.0/16)

La rete di management è il cuore dell'infrastruttura. Nel file networks.tf l'ho implementata così:

# Riferimento: networks.tf linee 21-29
resource "hcloud_network" "private_net" {
  name     = "service-magefleet-network"
  ip_range = var.management_network_cidr  # Default: 10.0.0.0/16

  labels = {
    purpose = "management"
    tier    = "infrastructure"
  }
}
            

Ho reso il CIDR configurabile tramite variabile per permettere customizzazione senza modificare codice:

# Riferimento: variables.tf linee 189-199
variable "management_network_cidr" {
  type        = string
  description = "CIDR block for management network"
  default     = "10.0.0.0/16"
}

variable "management_subnet_cidr" {
  type        = string
  description = "CIDR block for management subnet"
  default     = "10.0.0.0/24"
}
            

La subnet viene creata con una dipendenza esplicita sulla rete:

# Riferimento: networks.tf linee 31-38
resource "hcloud_network_subnet" "private_subnet" {
  network_id   = hcloud_network.private_net.id
  type         = "cloud"
  network_zone = "eu-central"
  ip_range     = var.management_subnet_cidr

  depends_on = [hcloud_network.private_net]
}
            

Nota sul network_zone: Hetzner richiede di specificare la zona di rete. "eu-central" copre i datacenter di Norimberga (nbg1), Falkenstein (fsn1) e Helsinki (hel1).

2.2 NAT Gateway Route

La route per il NAT gateway è un componente critico. Indica a tutte le VM della rete di usare il bastion come gateway per internet:

# Riferimento: networks.tf linee 41-50
resource "hcloud_network_route" "to_extra_net" {
  network_id  = hcloud_network.private_net.id
  destination = "0.0.0.0/0"                    # Default route
  gateway     = one(hcloud_server.bastion.network[*]).ip  # Bastion IP

  depends_on = [
    hcloud_server.bastion,
    hcloud_network_subnet.private_subnet
  ]
}
            

Spiegazione tecnica: La funzione one() estrae l'IP dalla lista di interfacce di rete del bastion. Il depends_on garantisce che il bastion esista prima di creare la route.

2.3 Customer Networks (Shared, Business, Enterprise)

Le reti per i clienti seguono lo stesso pattern ma con count/for_each conditionals per abilitarle solo quando necessario:

# Riferimento: networks.tf linee 58-77
resource "hcloud_network" "customers_shared" {
  count    = var.enable_shared_customers ? 1 : 0  # Conditional creation
  name     = "customers-shared-network"
  ip_range = var.customers_shared_network_cidr     # Default: 10.10.0.0/16

  labels = {
    purpose = "customers"
    tier    = "standard"
  }
}

resource "hcloud_network_subnet" "customers_shared_workers" {
  count        = var.enable_shared_customers ? 1 : 0
  network_id   = hcloud_network.customers_shared[0].id
  type         = "cloud"
  network_zone = "eu-central"
  ip_range     = var.customers_shared_subnet_cidr  # Default: 10.10.0.0/24
}
            

Perché count invece di for_each? Per risorse singole opzionali uso count. Per multiple istanze dinamiche (es. business customers) uso for_each.

Business Customers con Subnet Dinamiche

Per i clienti business, ogni cliente ottiene una subnet /24 dedicata. Questo richiede un approccio dinamico:

# Riferimento: networks.tf linee 111-120
resource "hcloud_network_subnet" "business_customer_subnet" {
  for_each = var.enable_business_customers ? var.business_customers : {}

  network_id   = hcloud_network.customers_business[0].id
  type         = "cloud"
  network_zone = "eu-central"
  ip_range     = "${var.customers_business_subnet_base}.${each.value.subnet_id}.0/24"

  depends_on = [hcloud_network.customers_business]
}
            

La variabile business_customers è una map con validation:

# Riferimento: variables.tf linee 266-295
variable "business_customers" {
  type = map(object({
    subnet_id   = number  # 1-254, usato come: 10.20.{subnet_id}.0/24
    server_type = string  # es. "cpx31", "cpx41"
    location    = string  # es. "nbg1", "fsn1"
  }))
  description = "Map of business tier customers with dedicated nodes"
  default     = {}

  validation {
    condition = alltrue([
      for k, v in var.business_customers : v.subnet_id >= 1 && v.subnet_id <= 254
    ])
    error_message = "subnet_id must be between 1 and 254"
  }
}

# Esempio di utilizzo in terraform.tfvars:
# business_customers = {
#   "cliente-acme" = {
#     subnet_id   = 1      # Crea 10.20.1.0/24
#     server_type = "cpx41"
#     location    = "nbg1"
#   }
#   "cliente-beta" = {
#     subnet_id   = 2      # Crea 10.20.2.0/24
#     server_type = "cpx41"
#     location    = "nbg1"
#   }
# }
            

Validation block: Terraform valida automaticamente che subnet_id sia tra 1 e 254 prima di applicare. Questo previene errori di configurazione.

3. Bastion Host con Auto-Configuration

3.1 Generazione Automatica Chiavi SSH

Una feature che ho implementato è la generazione automatica di chiavi SSH interne per la comunicazione bastion → private VMs. Questo elimina la necessità di gestire manualmente le chiavi:

# Riferimento: main.tf linee 19-24
resource "tls_private_key" "internal_ssh_key" {
  algorithm = "RSA"
  rsa_bits  = 4096  # RSA 4096-bit per sicurezza

}
            

Terraform genera la coppia di chiavi, e poi inietto la chiave privata nel bastion e quella pubblica nelle VM private. Tutto automatico, nessuna gestione manuale.

3.2 Bastion Host Resource

Il bastion è il componente più complesso perché deve svolgere multiple funzioni: NAT gateway, SSH jump host, WireGuard VPN server, DNS server interno. Ecco come l'ho configurato:

# Riferimento: main.tf linee 31-63
resource "hcloud_server" "bastion" {
  name        = "bastion-host-wireguard"
  server_type = "cpx11"                    # 2 vCPU, 2GB RAM (sufficiente)
  image       = "ubuntu-22.04"
  location    = "nbg1"                     # Norimberga
  ssh_keys    = [var.ssh_key_name]        # SSH key per accesso iniziale

  # Attach alla management network con IP statico
  network {
    network_id = hcloud_network.private_net.id
    ip         = var.bastion_private_ip    # Default: 10.0.0.2
  }

  # Cloud-init user data per configurazione automatica
  user_data = templatefile("${path.module}/templates/userdata_bastion.tpl", {
    private_network_ip_range = hcloud_network_subnet.private_subnet.ip_range
    internal_ssh_private_key = tls_private_key.internal_ssh_key.private_key_pem
    bastion_private_ip       = var.bastion_private_ip
    vault_cluster_ip         = var.vault_private_ip
    rancher_cluster_ip       = var.rancher_private_ip
    npm_admin_email          = var.npm_admin_email
    npm_admin_password       = var.npm_admin_password
  })

  depends_on = [
    hcloud_network_subnet.private_subnet
  ]
}
            

Nota su user_data: Hetzner supporta cloud-init. Il template userdata_bastion.tpl viene popolato con variabili da Terraform ed eseguito al primo boot della VM.

3.3 Cloud-Init Template per NAT Configuration

Il template userdata_bastion.tpl configura il bastion come NAT gateway. Ecco le parti più importanti:

# Riferimento: templates/userdata_bastion.tpl linee 4-12
#!/bin/bash -x
set -e  # Exit on error

# NAT Configuration
apt update && apt upgrade -y
cat > /etc/networkd-dispatcher/routable.d/10-eth0-post-up << 'EOF_NAT_SCRIPT'
#!/bin/bash
echo 1 > /proc/sys/net/ipv4/ip_forward
iptables -t nat -A POSTROUTING -s '${private_network_ip_range}' -o eth0 -j MASQUERADE
EOF_NAT_SCRIPT
chmod +x /etc/networkd-dispatcher/routable.d/10-eth0-post-up
            

Spiegazione tecnica:

  • ip_forward = 1: Abilita il forwarding di pacchetti IP tra interfacce
  • POSTROUTING MASQUERADE: Applica SNAT per traffico da 10.0.0.0/16 verso internet
  • Lo script viene eseguito automaticamente quando l'interfaccia eth0 diventa routable

DNS Server Interno con dnsmasq

Ho configurato dnsmasq sul bastion per fornire DNS resolution interno. Questo permette di usare nomi come vault.internal invece di IP:

# Riferimento: templates/userdata_bastion.tpl linee 21-45
cat > /etc/dnsmasq.d/internal.conf << 'EOF_DNSMASQ'
# Internal services
address=/vault.internal/${vault_cluster_ip}
address=/rancher.internal/${rancher_cluster_ip}

# DNS forwarding per query esterne
server=8.8.8.8
server=1.1.1.1

# Listen su localhost e interfaccia privata
listen-address=127.0.0.1,${bastion_private_ip}
bind-interfaces

domain=internal.local
expand-hosts
cache-size=1000
EOF_DNSMASQ

systemctl enable dnsmasq
systemctl restart dnsmasq
            

Perché dnsmasq? È leggero, semplice da configurare, e perfetto per reti private. Le VM private possono ora usare il bastion come DNS server (/etc/resolv.conf punta a 10.0.0.2).

4. Moduli Riutilizzabili

4.1 Pattern del Modulo Terraform

Ogni componente dell'infrastruttura (Rancher, Vault, ArgoCD) è implementato come modulo. Il pattern è sempre lo stesso: main.tf, variables.tf, outputs.tf.

Esempio del modulo Rancher nel main.tf principale:

# Riferimento: main.tf linee 65-76
module "rancher" {
  count  = var.enable_rancher ? 1 : 0     # Conditional instantiation
  source = "./modules/rancher"

  bastion_id              = hcloud_server.bastion.id
  bastion_private_ip      = var.bastion_private_ip
  rancher_private_ip      = var.rancher_private_ip
  internal_ssh_public_key = tls_private_key.internal_ssh_key.public_key_openssh
  location                = var.location
  network_id              = hcloud_network.private_net.id
  ssh_key_name            = var.ssh_key_name
}
            

Vantaggi dell'approccio modulare:

  • Posso riutilizzare i moduli in altri progetti
  • Ogni modulo è testabile indipendentemente
  • Disabilitare un componente è semplice: enable_rancher = false
  • La logica di ogni componente è isolata dal resto

4.2 Modulo Ansible per Inventory Generation

Un aspetto interessante è il modulo ansible che genera automaticamente l'inventory Ansible a partire dallo state di Terraform:

# Riferimento: main.tf linee 134-163
module "ansible" {
  source = "./modules/ansible"

  bastion_id                   = hcloud_server.bastion.id
  bastion_private_ip           = var.bastion_private_ip
  bastion_public_ip            = hcloud_server.bastion.ipv4_address
  ecommerce_cluster_private_ip = try(module.ecommerce[0].private_ip, var.ecommerce_private_ip)
  argocd_cluster_private_ip    = try(module.argocd[0].private_ip, var.argocd_private_ip)
  rancher_cluster_private_ip   = try(module.rancher[0].private_ip, var.rancher_private_ip)
  vault_cluster_private_ip     = var.vault_private_ip
  internal_ssh_private_pem     = tls_private_key.internal_ssh_key.private_key_pem

  # Pass enable flags
  enable_rancher   = var.enable_rancher
  enable_vault     = var.enable_vault
  enable_ecommerce = var.enable_ecommerce
  enable_argocd    = var.enable_argocd

  # Altri parametri...
}
            

Funzione try(): Uso try() per gestire casi dove il modulo non è abilitato. Se module.rancher[0] non esiste, fallback a var.rancher_private_ip.

Il modulo genera file come inventory.ini e ansible.cfg pronti per uso immediato. Questo elimina la necessità di mantenere inventory manualmente.

5. Outputs e Testing

5.1 Output Utili

Ho configurato output che forniscono tutte le informazioni necessarie per testing e debugging:

# Riferimento: outputs.tf linee 1-21
output "bastion_public_ip" {
  value       = hcloud_server.bastion.ipv4_address
  description = "Public IP del bastion host"
}

output "internal_ssh_private_key" {
  description = "Chiave SSH privata interna per Ansible"
  value       = tls_private_key.internal_ssh_key.private_key_pem
  sensitive   = true  # Non mostrato nei log
}
            

Per vedere output sensibili: terraform output -raw internal_ssh_private_key

5.2 WireGuard Configuration Auto-Fetch

Ho implementato un null_resource che recupera automaticamente la configurazione WireGuard generata dal bastion:

# Riferimento: outputs.tf linee 24-57
resource "null_resource" "fetch_wireguard_client_config" {
  depends_on = [hcloud_server.bastion]

  triggers = {
    always_run = timestamp()  # Force run ogni volta
  }

  # Aspetta che cloud-init finisca sul bastion
  provisioner "remote-exec" {
    inline = ["cloud-init status --wait > /dev/null"]
    connection {
      type        = "ssh"
      user        = "root"
      host        = hcloud_server.bastion.ipv4_address
      private_key = file(var.hcloud_ssh_private_key_path)
      timeout     = "5m"
    }
  }

  # Recupera il file di configurazione WireGuard
  provisioner "local-exec" {
    command = <<-EOT
      ssh -o StrictHostKeyChecking=no \
          -o IdentityFile=${var.hcloud_ssh_private_key_path} \
          root@${hcloud_server.bastion.ipv4_address} \
          'cat /root/wg_client.conf' > wireguard_client_config.conf
    EOT
  }
}
            

Dopo terraform apply, trovi il file wireguard_client_config.conf nella directory corrente, pronto da importare nel tuo client WireGuard.

5.3 Testing Instructions Output

Ho creato un output con istruzioni passo-passo per testare l'infrastruttura:

# Dopo terraform apply, visualizza le istruzioni:
terraform output testing_instructions > testing.txt
cat testing.txt
            

L'output include comandi pronti per:

  • SSH nel bastion
  • SSH da bastion → VM private usando chiave interna
  • Test connettività internet dalle VM private (NAT gateway)
  • Setup WireGuard VPN
  • Accesso a Rancher UI

6. State Management e Best Practices

6.1 Remote State con Terraform Cloud

In produzione, lo state di Terraform NON dovrebbe essere locale. Ho configurato remote state su Terraform Cloud (o S3 per progetti AWS):

# Aggiungere a main.tf per remote state
terraform {
  backend "remote" {
    organization = "magefleet"

    workspaces {
      name = "production"
    }
  }
}
            

Perché remote state?

  • Evita conflitti quando più persone lavorano sullo stesso progetto
  • State locking automatico
  • Backup automatico dello state
  • History dello state per rollback

6.2 Workspaces per Ambienti Multipli

Per gestire dev/staging/production, ho usato Terraform workspaces:

# Crea workspace per staging
terraform workspace new staging
terraform workspace select staging
terraform apply -var-file="staging.tfvars"

# Switch a production
terraform workspace select production
terraform apply -var-file="production.tfvars"
            

Ogni workspace ha il proprio state file, permettendo infrastrutture separate con lo stesso codice.

6.3 tfvars Files per Ambienti

Ho organizzato le variabili per ambiente:

# production.tfvars
environment              = "production"
management_network_cidr  = "10.0.0.0/16"
enable_rancher          = true
enable_vault            = true

# staging.tfvars
environment              = "staging"
management_network_cidr  = "10.100.0.0/16"  # CIDR diverso
enable_rancher          = true
enable_vault            = false              # Risparmio costi in staging
            

7. Workflow Completo

7.1 Deploy Iniziale

# 1. Clone repository
git clone https://github.com/ramingo/magefleet.git
cd magefleet/terraform-init-infra

# 2. Copia template e configura variabili
cp terraform.tfvars.example terraform.tfvars
# Edita terraform.tfvars con i tuoi valori

# 3. Inizializza Terraform
terraform init

# 4. Valida configurazione
terraform validate

# 5. Plan (dry-run)
terraform plan -out=tfplan

# 6. Review del plan
# IMPORTANTE: Leggi attentamente cosa verrà creato!

# 7. Apply
terraform apply tfplan

# 8. Salva output
terraform output testing_instructions > TESTING.md
terraform output -raw internal_ssh_private_key > internal_key.pem
chmod 600 internal_key.pem
            

7.2 Modifiche Successive

Per modificare l'infrastruttura esistente:

# 1. Modifica terraform.tfvars o .tf files
# Esempio: Aggiungi un cliente business
# In terraform.tfvars:
business_customers = {
  "cliente-new" = {
    subnet_id   = 3
    server_type = "cpx41"
    location    = "nbg1"
  }
}

# 2. Plan per vedere cosa cambierà
terraform plan

# 3. Apply solo se il plan è corretto
terraform apply
            

7.3 Destroy (Attenzione!)

# Per distruggere TUTTA l'infrastruttura
terraform destroy

# Per distruggere risorse specifiche
terraform destroy -target=module.ecommerce
            

⚠️ Warning: terraform destroy cancella TUTTO. In produzione, usa sempre target specifici e fai backup dello state prima.

8. Troubleshooting Comune

8.1 "Error creating network: network overlaps"

Problema: Terraform fallisce con errore di overlap tra reti.

Causa: Hai già una rete con CIDR che overlaps con quello che stai cercando di creare.

Soluzione:

# 1. Lista reti esistenti
hcloud network list

# 2. Cambia CIDR in terraform.tfvars
management_network_cidr = "10.50.0.0/16"  # Usa range diverso

# 3. O elimina la rete esistente manualmente
hcloud network delete OLD_NETWORK_ID
            

8.2 "Cloud-init did not finish in time"

Problema: Il null_resource che recupera WireGuard config va in timeout.

Causa: Cloud-init sul bastion sta ancora configurando il sistema.

Soluzione:

# Verifica status cloud-init manualmente
ssh root@BASTION_IP 'cloud-init status'

# Output desiderato: "status: done"
# Se "status: running", aspetta e ri-esegui:
terraform apply
            

8.3 "Private VMs can't reach internet"

Problema: Le VM private non riescono a fare apt update o ping 8.8.8.8.

Debugging steps:

# 1. SSH nel bastion
ssh root@BASTION_IP

# 2. Verifica IP forwarding
cat /proc/sys/net/ipv4/ip_forward  # Deve essere 1

# 3. Verifica iptables NAT rules
iptables -t nat -L -n -v | grep MASQUERADE

# 4. SSH in una VM privata dal bastion
ssh -i /root/.ssh/id_rsa_internal root@10.0.0.4

# 5. Dalla VM privata, verifica route
ip route show | grep default  # Deve puntare a 10.0.0.2

# 6. Verifica DNS
cat /etc/resolv.conf  # Deve avere nameserver 10.0.0.2 o 8.8.8.8
            

Conclusioni

L'implementazione con Terraform mi ha permesso di trasformare un design architetturale complesso in codice riproducibile. Le decisioni chiave sono state:

  • Struttura modulare: Ogni componente è un modulo riutilizzabile con enable flags
  • Cloud-init per auto-configuration: Bastion si configura automaticamente al boot
  • Generazione automatica chiavi SSH: Elimina gestione manuale e migliora security
  • Integrazione Terraform-Ansible: Inventory generato automaticamente dallo state
  • Multiple networks conditionals: Attivo solo le reti necessarie per ridurre costi

Nel prossimo articolo mostrerò come uso Ansible per configurare i servizi su questa infrastruttura: Rancher, Vault, ArgoCD, e deploy di Kubernetes clusters.

Risorse

Articolo precedente: Architettura di un'Infrastruttura Cloud Multi-Tenant

Prossimo articolo: Bastion Host Setup e Security Hardening