Stage 4 — The acme-sampleapp sandbox¶
This is where the isolated exercises stop and the real thing starts. Everything in Stage 2 and Stage 3 teaches one mechanic at a time in a directory you can delete without consequence. The sandbox is five independent Terraform roots that depend on each other through a Consul contract, deploy Helm charts onto a real GKE cluster, and cost real money while they're up.
The single most important difference: no root here can be understood in isolation.
backend/infra reads values that cloudsql/infra, infrastructure/infra, and
sample-program/infra published. Break one and you learn what a dependency graph
actually feels like.
Before you touch it¶
| Source of truth for layout | acme-sampleapp-multirepo-sandbox-elaborate/README.md |
| Source of truth for running it | RUNBOOK.md — apply order, Consul dev agent, teardown |
| Session-by-session history | Session log |
| Concept → file lookup | Concept index |
This one bills
Stages 1–3 are free or nearly so. The sandbox stands up a GKE cluster, Secret Manager
secrets, and optionally a Cloud SQL instance. Read the cost section at the end of the
RUNBOOK before enabling anything, and run ./destroy-all.sh when you pause.
The one pattern to read first¶
sample-program/infra/main.tf is the highest-value file in the sandbox, because it is the
for_each-over-modules pattern from the labs applied for real:
module "vpc" {
source = "./modules/vpc"
project_id = var.project_id
region = var.region
subnets = local.subnets
}
# for_each over a module block — same mechanic from the labs, applied for
# real here: this creates zero, one, or two GKE clusters depending on
# which enable_gke_* flags are true, without duplicating the module call.
module "gke" {
for_each = local.gke_clusters_enabled
source = "./modules/gke"
project_id = var.project_id
zone = var.zone
name = "sample-program-${each.key}"
network = module.vpc.network_self_link
subnetwork = module.vpc.subnets[each.key].self_link
machine_type = each.value.machine_type
node_count = each.value.node_count
spot = each.value.spot
}
module "dns" {
source = "./modules/dns"
project_id = var.project_id
domain = var.dns_domain
}
Two things worth sitting with before moving on:
- Zero is a legal outcome. If both enable flags are false,
local.gke_clusters_enabledis an empty map andmodule.gkeproduces nothing at all — no error, no partial cluster. Everything downstream then has to cope with an empty map, which is exactly the guard-propagation bug that cost a real session's debugging time. See Session 2. - The filtering happens in
locals, not in the module. The module doesn't know it's conditional. That separation is why the same module is reusable, and why the guard has to be re-applied by every consumer of the filtered map rather than once at the source.
Reference — sandbox README¶
Sanitized personal practice copy of a real multi-repo GCP/GKE deployment, restructured
to mirror the actual project layout: each application owns its own infra/
(and charts/ where applicable), and repos coordinate through Consul-published
outputs rather than terraform_remote_state. All org-specific naming has been
genericized (davita-transitcare → acme-sampleapp, davita.com → acme.com);
GCP is left as-is since that's the actual cloud in use.
The original project's Consul cluster and GCS backends don't exist here, so
none of the inherited paths resolve as-written. RUNBOOK.md stands up local
substitutes for both — a Consul dev agent and backend "local" — which is
what makes this copy runnable against a GCP project of your own.
frontend's app source (Angular/FastAPI) and cloudsql's SQL migrations are
intentionally not reconstructed — out of scope for a Terraform-focused
sandbox.
Repo layout¶
sample-program/
infra/ Terraform: the shared GKE clusters (p/np), VPC, and public
DNS zone every other repo depends on. Built as 3 reusable
modules (vpc/gke/dns) rather than inline resources.
backend/
infra/ Terraform: namespace, GSA, Cloud SQL IAM + the shell_script grant
bridge, dynamic RBAC, the helm_release that installs the chart below
charts/acme-sampleapp-backend/ FastAPI + Cloud SQL Auth Proxy sidecar
frontend/
infra/ Terraform: namespace, GSA, static IP + DNS (vip.tf), the
helm_release that installs the chart below
charts/acme-sampleapp-frontend/ Angular build served by nginx, GCE ingress
cloudsql/
infra/ Terraform: the actual Cloud SQL instance, IAM-auth enabled,
plus the grant/revoke Cloud Functions backend/infra calls into
infrastructure/
infra/ Terraform: shared SSO secret (Secret Manager), published to
every other repo via Consul
This sandbox is runnable — see RUNBOOK.md for the actual apply sequence,
a local Consul dev agent, and where to put real GCP values via
terraform.tfvars.
How to use this repository for learning¶
The repository is intentionally split into two layers:
01-basics/and02-gcp-terraform/are small, isolated demonstrations. Use them to learn one Terraform concept at a time without the Acme dependency graph.acme-sampleapp-multirepo-sandbox-elaborate/is the main project. Its five independent Terraform roots model the organization-style repository boundaries, state ownership, Consul contracts, GKE, Cloud SQL, Helm, and Workload Identity.
Each Acme */infra/ directory owns its own local state file. Do not copy or delete
state files between roots. Start a new learning session with
./restore-session.sh: it runs a plan first, reuses existing state, and refuses to
create infrastructure if a state file is missing. Use --bootstrap only for the
intentional first setup, and --refresh-consul only when the local Consul dev agent
was restarted and its in-memory outputs need to be republished. The restore
script also verifies that the GKE cluster has a Ready node before waiting on
Helm, and supports --repair-failed-helm for a failed Helm release left outside
Terraform state.
The learning history is kept separately in the repository-level docs/sessions/
directory. Session notes explain why the current code and run order look the way they
do; they are not Terraform configuration and should not be mixed into an Acme root.
Why 5 separate Terraform repos instead of one¶
This is the distinctive architectural choice worth understanding on its own — it's a distributed-systems pattern applied to infrastructure code, not just an organizational preference. Each repo:
- Has its own lifecycle — the database shouldn't be destroyed/recreated just because the backend redeploys, and the frontend shouldn't need a plan run every time a DB migration ships.
- Has its own state file, with no shared backend config — blast radius of a
bad
applyis contained to one repo. The real project does this withbackend "gcs" {}per repo; this copy usesbackend "local"per repo so it needs no pre-existing bucket. Same isolation property either way: what matters is one state file per root, not where that file lives. - Publishes what other repos need, and nothing else, through Consul rather
than
terraform_remote_state.terraform_remote_statewould create a hard coupling to another repo's entire state file (including things it never meant to expose); a Consul KV write is a deliberate, versioned, minimal publish/subscribe contract instead — closer to a service publishing an API contract than one service reading another's database directly.
Dependency graph¶
┌────────────────────────┐
│ sample-program/ │ publishes: GKE clusters (p + np), VPC,
│ │ public DNS zone
└────────────┬───────────┘
│
┌─────────────┼─────────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ cloudsql/ │ │ │
│ │ │ │
│ publishes: │ │ │
│ connection │ │ │
│ info, grant/ │ │ │
│ revoke URLs │ │ │
└───────┬───────┘ │ │
│ │ │
▼ ▼ │
┌───────────────────────────────┐ │
│ backend/ │◄──────────┘
│ │ reads: sso_secret_id (grants itself
│ publishes: backend_service_url│ secretAccessor), cloudsql outputs
│ service_account.email │
└───────────────┬────────────────┘
│
▼
┌─────────────────┐
│ frontend/ │ reads: backend_service_url, sso_client_id
│ │ (never touches sso_secret_id/client_secret)
└───────────────────┘
infrastructure also reads backend's published service-account email
(infrastructure/infra/iam.tf) — the grant runs in infrastructure (it
owns the secret) even though the dependency on knowing who to grant flows
the other way. This is worth sitting with: "who owns the resource" and
"who initiates the Terraform read" aren't always the same repo.
Consul key map (the actual contract between repos)¶
| Path | Written by | Read by |
|---|---|---|
.../sample-program/default |
sample-program/infra |
backend, frontend |
.../infrastructure/{dev,qa,default} |
infrastructure/infra |
backend, frontend |
.../cloudsql/{dev,qa,default} |
cloudsql/infra |
backend |
.../backend/{workspace} |
backend/infra |
frontend, infrastructure |
Every one of these paths is decoded with jsondecode(...) on the reading
side and encoded with jsonencode(...)/consul_keys on the writing side —
if you ever change a field name on one side, grep the whole sandbox for it
before assuming the change is safe. That's the tradeoff of a JSON-over-KV
contract instead of typed Terraform module outputs: nothing catches a typo
at plan time.
What to trace first¶
sample-program/infra/main.tf— thefor_each-over-modules pattern creating zero, one, or two GKE clusters depending on the enable flags, then theconsul_keyspublish at the bottom that builds the exact JSON shape every other repo'slocal.program_gcpexpects.infrastructure/infra/main.tfandsecrets.tf— good warm-up for thefor_each-over-environments pattern (note: this repo does not useterraform.workspaceper environment the way the other repos do — it runs a single workspace and fans out withfor_eachinstead. Ask yourself why that choice might make sense here specifically.)cloudsql/infra/— a self-contained, single-purpose repo. Good for seeing a fullmain.tf→iam.tf→outputs.tfflow without the added complexity backend has.backend/infra/main.tflocals, thencloudsql.tf, then thedepends_onblock at the bottom ofmain.tf— still the most architecturally dense file in the sandbox.frontend/infra/vip.tf+iam.tf— contrast against backend'siam.tf: notice everything backend needs (SQL roles,shell_scriptbridge,sso_secret_idaccess) that frontend deliberately does not need, and why (no database, andsso_client_idis non-sensitive so it skips Secret Manager IAM entirely).