Skip to content

Terraform

Structure

terraform/
├── bootstrap/            # one-time: creates the R2 bucket used as remote state backend
├── cloud-init/            # shared cloud-init templates (k3s-node-shared.yaml.tpl, data-vm.yaml.tpl)
├── modules/
│   ├── hetzner-network/   # private network + subnet
│   ├── hetzner-server/    # a single Hetzner Cloud VM (used for both the K3s node and each data VM)
│   ├── hetzner-firewall/  # Hetzner Cloud firewall rules
│   ├── cloudflare-dns/    # DNS A records
│   ├── cloudflare-security/ # zone-wide security settings (WAF, SSL mode, etc.)
│   └── cloudflare-r2/     # a single R2 bucket
└── environments/
    ├── platform/          # shared K3s compute + network - applied once, before any of the below
    ├── dev/                # NEW - structural scaffold, never applied (this pass)
    ├── staging/
    ├── production/
    └── shared/             # cross-environment concerns (see that directory's own files)

Single-VM topology

environments/platform/ provisions one shared K3s node (and the private network it lives on). environments/{dev,staging,production}/ each provision their own data VM (MySQL+Redis), DNS record, and R2 bucket, but read the K3s node's IP/network ID from platform's state via terraform_remote_state rather than provisioning their own node. This means:

  • platform must be applied first, standalone.
  • Applying dev/staging/production afterward only adds that environment's own data VM/DNS/R2 - it never re-provisions or touches the shared K3s node.
  • Kubernetes-level isolation between environments (namespace, NetworkPolicy, separate Secrets) is what actually separates them on the shared node - see helm/akshayabazaar/templates/networkpolicy.yaml's own extensive comments for the mechanics.

See terraform/README.md for the full history and the private-IP addressing scheme (platform's K3s node: .10; staging's data VM: .20; production's data VM: .30; dev's data VM: .40).

The new dev environment (this pass)

terraform/environments/dev/ is a structural scaffold modeled directly on environments/staging/ - same modules, same remote-state pattern, same check block requiring either a provisioned data VM or an external one. Differences: subdomain = "dev", private IP 10.0.1.40, its own R2 bucket (akshayabazaar-media-dev), and manage_zone_settings hard-defaulted false (production always owns zone-wide Cloudflare settings).

Validated, never applied:

terraform fmt -recursive -check -diff   -> no diffs (already correctly formatted)
terraform validate (dev)                -> Success! The configuration is valid.
terraform validate (platform/staging/production, re-checked after adding dev) -> all Success

Applying it provisions real Hetzner/Cloudflare infrastructure and incurs real cost - a deliberate decision for you to make, not something this pass does unattended. See environments/dev/README.md "Known gaps" for what's still missing before it would be useful (no TLS cert, no deploy-dev.ps1 wrapper yet).

Module naming

The audit's suggested module layout (modules/{network,compute,database,kubernetes}) doesn't match this repo's actual module names one-to-one. Rather than rename working, already-validated modules referenced by three live main.tf files (a real risk of breaking a subtle path/reference for a cosmetic-only gain), here's the mapping:

Audit's suggested name This repo's actual module Why it's named that way
network hetzner-network Provider-specific (this project only targets Hetzner Cloud) rather than a generic abstraction.
compute hetzner-server Same module provisions both the shared K3s node (environments/platform) and every environment's data VM - "server" reflects that it's one generic VM primitive, not two different resources.
kubernetes (no dedicated module) K3s itself is installed via cloud-init/k3s-node-shared.yaml.tpl at boot time on a hetzner-server instance, not a separate Terraform-managed resource (no managed Kubernetes service is in use here - see terraform/README.md). Extracting a kubernetes module would mean either wrapping the cloud-init template (low value - it's already a single reused file) or introducing a managed K8s provider this project doesn't use. Noted as a legitimate future option if K3s provisioning logic grows more complex.
database (no dedicated module) MySQL/Redis run as software installed via cloud-init/data-vm.yaml.tpl on a hetzner-server instance too - Hetzner Cloud has no native managed MySQL/Redis product (see staging/variables.tf's provision_data_vm comment for the accepted tradeoff and the external_mysql_host/external_redis_host escape hatch to a real managed provider instead).
(not suggested) hetzner-firewall Firewall rules are a distinct Hetzner Cloud resource type from the server itself - kept separate so a data VM and its firewall can have independent lifecycles.
(not suggested) cloudflare-dns / cloudflare-security / cloudflare-r2 Cloudflare's three concerns here (DNS records, zone-wide security settings, R2 buckets) are independent enough (different resource types, different apply cadence - cloudflare-security is applied once per zone, not once per environment) to stay as three modules rather than one combined cloudflare module.

Remote state

All environments (including the new dev) share one R2-backed S3-compatible remote state bucket (akshayabazaar-terraform-state), one state key per environment (platform/terraform.tfstate, dev/terraform.tfstate, etc.) - see terraform/README.md "Remote state strategy" and terraform/bootstrap/ for the one-time bucket creation. Every environments/*/versions.tf already declares this backend; dev's follows the identical pattern.

Validation commands (safe - never apply/destroy)

# Format check (all environments)
docker run --rm -v "$(pwd)/terraform:/tf" -w /tf hashicorp/terraform:1.9 fmt -recursive -check -diff

# Validate a specific environment (needs the full terraform/ tree mounted so local module
# paths like ../../modules/hetzner-network resolve)
docker run --rm -v "$(pwd)/terraform:/tf" -w "/tf/environments/dev" hashicorp/terraform:1.9 init -backend=false -input=false
docker run --rm -v "$(pwd)/terraform:/tf" -w "/tf/environments/dev" hashicorp/terraform:1.9 validate

terraform apply/terraform destroy were never run against any environment during this pass - see docs/devops/README.md "Safety guardrails observed".