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:
platformmust be applied first, standalone.- Applying
dev/staging/productionafterward 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".