|
| 1 | +# Infrastructure Terraform Specification |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +Two deployment stacks with clear separation of concerns. |
| 6 | + |
| 7 | +**Non-goals:** Multi-region orchestration, blue-green deployments, auto-scaling policies. These belong in CI/CD or dedicated tooling. |
| 8 | + |
| 9 | +| Stack | Components | Use Case | |
| 10 | +| ------------------- | ------------------------------------------- | ----------------------- | |
| 11 | +| **edge** (default) | Hyperdrive, DNS (Workers via Wrangler) | Most SaaS apps | |
| 12 | +| **hybrid** (opt-in) | Cloud Run, Cloud SQL, GCS + optional CF DNS | GCP services, Vertex AI | |
| 13 | + |
| 14 | +## Directory Structure |
| 15 | + |
| 16 | +```bash |
| 17 | +infra/ |
| 18 | + modules/ # Atomic resources (no credentials) |
| 19 | + cloudflare/ |
| 20 | + hyperdrive/ # Database connection pooling |
| 21 | + r2-bucket/ # Object storage |
| 22 | + dns/ # Proxied DNS records |
| 23 | + gcp/ |
| 24 | + cloud-run/ # Container deployment |
| 25 | + cloud-sql/ # Managed PostgreSQL |
| 26 | + gcs/ # Object storage |
| 27 | + |
| 28 | + stacks/ # Architectural compositions |
| 29 | + edge/ # Hyperdrive + DNS (Workers via Wrangler) |
| 30 | + hybrid/ # GCP + optional CF DNS |
| 31 | + |
| 32 | + envs/ # Terraform roots (providers + backend + state) |
| 33 | + dev/edge/ |
| 34 | + preview/edge/ |
| 35 | + staging/edge/ |
| 36 | + prod/edge/ |
| 37 | + |
| 38 | + templates/ |
| 39 | + env-roots/hybrid/ # Copy to enable hybrid |
| 40 | + backend-r2.example.hcl # Remote state for edge |
| 41 | + backend-gcs.example.hcl # Remote state for hybrid |
| 42 | +``` |
| 43 | + |
| 44 | +## Module Contract |
| 45 | + |
| 46 | +Modules must NOT define `provider` blocks. Non-HashiCorp providers require `required_providers` to specify the source: |
| 47 | + |
| 48 | +```hcl |
| 49 | +# Cloudflare modules declare source only (no version): |
| 50 | +terraform { |
| 51 | + required_providers { |
| 52 | + cloudflare = { |
| 53 | + source = "cloudflare/cloudflare" |
| 54 | + } |
| 55 | + } |
| 56 | +} |
| 57 | +``` |
| 58 | + |
| 59 | +Version constraints live exclusively in env roots. This keeps modules reusable while centralizing version management. |
| 60 | + |
| 61 | +## Provider Versions |
| 62 | + |
| 63 | +Canonical versions (single source of truth): |
| 64 | + |
| 65 | +| Provider | Version | |
| 66 | +| ---------- | -------------- | |
| 67 | +| terraform | `>= 1.12, < 2` | |
| 68 | +| cloudflare | `~> 5.0` | |
| 69 | +| google | `~> 7.0` | |
| 70 | + |
| 71 | +## Design Decisions |
| 72 | + |
| 73 | +### Explicit Roots Over Dispatcher |
| 74 | + |
| 75 | +Each `(environment, stack)` pair gets its own Terraform root with isolated state. |
| 76 | + |
| 77 | +```bash |
| 78 | +terraform -chdir=infra/envs/prod/edge apply |
| 79 | +``` |
| 80 | + |
| 81 | +**Why not a dispatcher?** A `variable "stack"` that switches configs: |
| 82 | + |
| 83 | +- Destroys one stack when switching to another |
| 84 | +- Requires separate backends anyway |
| 85 | +- Creates awkward `module.edge[0].x` references |
| 86 | + |
| 87 | +### No Backend by Default |
| 88 | + |
| 89 | +Terraform uses local state when no backend is configured. Remote backends require pre-existing buckets and credentials. |
| 90 | + |
| 91 | +**Rationale:** Zero-friction onboarding. Add remote backend when ready for team collaboration. |
| 92 | + |
| 93 | +### Providers in Env Roots Only |
| 94 | + |
| 95 | +Only env roots define `provider` blocks with credentials. Modules declare `required_providers` for source resolution only (no versions, no credentials). |
| 96 | + |
| 97 | +**Rationale:** Keeps modules reusable. Version constraints and credentials stay in one place per environment. |
| 98 | + |
| 99 | +### Preview Uses Edge Only |
| 100 | + |
| 101 | +PR previews need fast spin-up and low cost. Cloudflare Workers: no cold starts, instant deploys, minimal cost. |
| 102 | + |
| 103 | +## Secrets |
| 104 | + |
| 105 | +```bash |
| 106 | +# Via environment variables (CI/CD) |
| 107 | +export TF_VAR_cloudflare_api_token="..." |
| 108 | +terraform -chdir=infra/envs/prod/edge apply |
| 109 | + |
| 110 | +# Or local terraform.tfvars (gitignored) |
| 111 | +``` |
| 112 | + |
| 113 | +Mark sensitive variables: |
| 114 | + |
| 115 | +```hcl |
| 116 | +variable "cloudflare_api_token" { |
| 117 | + type = string |
| 118 | + sensitive = true |
| 119 | +} |
| 120 | +``` |
| 121 | + |
| 122 | +## Switching to Remote Backend |
| 123 | + |
| 124 | +### Edge Stack (R2) |
| 125 | + |
| 126 | +```bash |
| 127 | +cp infra/templates/backend-r2.example.hcl infra/envs/prod/edge/backend.hcl |
| 128 | +terraform -chdir=infra/envs/prod/edge init -backend-config=backend.hcl -migrate-state |
| 129 | +``` |
| 130 | + |
| 131 | +### Hybrid Stack (GCS) |
| 132 | + |
| 133 | +```bash |
| 134 | +cp infra/templates/backend-gcs.example.hcl infra/envs/prod/hybrid/backend.hcl |
| 135 | +terraform -chdir=infra/envs/prod/hybrid init -backend-config=backend.hcl -migrate-state |
| 136 | +``` |
| 137 | + |
| 138 | +## Multi-Region |
| 139 | + |
| 140 | +Use separate roots: `envs/prod-eu/edge`, `envs/prod-us/edge`. Each manages its own state. |
| 141 | + |
| 142 | +## Naming Conventions |
| 143 | + |
| 144 | +### Resource values |
| 145 | + |
| 146 | +Cloud resources use `{project_slug}-{environment}`; lowercase alphanumeric and hyphens only: `^[a-z0-9-]+$`. |
| 147 | + |
| 148 | +### Resource identifiers |
| 149 | + |
| 150 | +One simple set of rules: |
| 151 | + |
| 152 | +1. Name the thing being created (provider-native noun, singular). |
| 153 | + |
| 154 | + ```hcl |
| 155 | + resource "cloudflare_hyperdrive_config" "hyperdrive" {} |
| 156 | + resource "cloudflare_r2_bucket" "bucket" {} |
| 157 | + resource "cloudflare_dns_record" "record" {} |
| 158 | + resource "google_cloud_run_v2_service" "service" {} |
| 159 | + resource "google_sql_database_instance" "instance" {} |
| 160 | + ``` |
| 161 | + |
| 162 | +2. If you have multiples, suffix with the role. |
| 163 | + |
| 164 | + ```hcl |
| 165 | + resource "cloudflare_r2_bucket" "uploads" {} |
| 166 | + resource "cloudflare_r2_bucket" "backups" {} |
| 167 | + ``` |
| 168 | + |
| 169 | +3. Module names describe architectural role; resource names describe the concrete thing. |
| 170 | + |
| 171 | + ```hcl |
| 172 | + module "hyperdrive" { |
| 173 | + # contains: cloudflare_hyperdrive_config.hyperdrive |
| 174 | + } |
| 175 | + # → module.hyperdrive.id |
| 176 | + ``` |
| 177 | + |
| 178 | +## Known Limitations |
| 179 | + |
| 180 | +### Hyperdrive Database URL Parsing |
| 181 | + |
| 182 | +The hyperdrive module parses `database_url` via regex to extract individual connection parameters. This works reliably with Neon URLs (which use URL-safe generated credentials) but has limitations: |
| 183 | + |
| 184 | +- Port must be explicitly specified (e.g., `:5432`) |
| 185 | +- Credentials must not contain unencoded `@` or `:` characters |
| 186 | +- Validation fails fast with a descriptive error message |
| 187 | + |
| 188 | +For non-Neon databases with special characters in credentials, consider modifying the module to accept individual connection parameters instead. |
0 commit comments