Skip to content

Terraform Environments

The four (five, including bootstrap) environment roots

Root Applies Depends on Owns
bootstrap/ once, manually, by a human, outside CI nothing the R2 bucket every other root's state lives in
platform/ rarely - sizing/location changes to the shared node only bootstrap's state (implicitly, via the backend config) the one shared K3s VM + private network
staging/ routinely reads platform's state (terraform_remote_state) for the node's IP/network ID staging's own data VM, DNS record, R2 buckets
production/ routinely, deliberately same as staging production's own data VM, DNS record(s), R2 buckets, zone-wide Cloudflare settings
dev/ (never applied yet - structural scaffold) same pattern as staging would own dev's own data VM/DNS/R2
shared/ independently of everything above nothing the optional Jenkins controller VM

Why four+ roots, not one root with a workspace/count toggle

staging and production keep fully independent state for everything they actually own - a mistake in a staging apply has zero blast radius into production's resources. platform is the one deliberate exception (see Infrastructure → Single-VM topology) - accepted because a K3s node's own compute needs don't meaningfully differ between hosting staging pods and hosting production pods, so paying for two mostly-idle VMs was the actual waste being traded away. shared/ exists because Jenkins deploys to both environments, so it isn't a per-environment resource and doesn't belong in either app root or in platform (which is specifically the K3s compute layer).

Prerequisite ordering

bootstrap  →  platform  →  staging / production / dev (any order, independent of each other)  →  shared (independent of all)
staging's/production's/dev's own terraform plan/apply reads platform's state via data "terraform_remote_state" "platform" and fails with a clear "no state file found" error if platform hasn't been applied first.

The dev scaffold

terraform/environments/dev/ is modeled directly on staging/ - same modules, same remote-state read pattern, same check block. Differences: subdomain = "dev", private IP 10.0.1.40, its own R2 bucket (akshayabazaar-media-dev), manage_zone_settings hard-defaulted false. It validates (terraform fmt, terraform validate) but has never been applied - doing so provisions a real Hetzner VM, a Cloudflare DNS record, and an R2 bucket, and incurs real cost. That is a deliberate infrastructure decision for a human to make explicitly, not something any automated pass (including this documentation build) performs.