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.