06 · Designing a Platform's Terraform Architecture¶
Every module in this course so far has looked at one mechanism at a
time — state locking, testing, policy, the graph. Designing a platform's
Terraform architecture is the opposite exercise: given all of those
mechanisms, how do you actually split dozens of teams' infrastructure
across state files, modules, and repositories so that nobody's apply
can take down someone else's service, and no one has to understand the
whole system to change their corner of it.
The core tension: blast radius vs. duplication¶
# One giant root module — everything in one state
module "network" { source = "./modules/network" }
module "database" { source = "./modules/database" }
module "app" { source = "./modules/app" }
module "cdn" { source = "./modules/cdn" }
# vs. four separate root modules, four separate state files
# network/main.tf, database/main.tf, app/main.tf, cdn/main.tf
A single state file holding network, database, app, and CDN resources
means one terraform apply can, in principle, plan changes across all
four — which is convenient for keeping cross-references simple
(module.network.vpc_id is just a local reference) but means a lock held
by anyone touching any of it blocks everyone touching any of it (the
state-locking mechanism from Level 3 module 01), and a bad plan anywhere
in that state risks -target-less applies touching resources the
change had no business touching. Splitting into four state files
shrinks each blast radius to one domain, at the cost that
module.network.vpc_id is no longer a local reference — it has to cross
a state boundary via a remote state data source or an explicit output/
input contract, which is real coordination overhead. Platform
architecture is choosing where to draw these boundaries deliberately,
not accidentally.
Composing across state boundaries: terraform_remote_state¶
# database/main.tf
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "acme-tfstate"
key = "network/terraform.tfstate"
region = "us-east-1"
}
}
resource "aws_db_subnet_group" "main" {
subnet_ids = data.terraform_remote_state.network.outputs.private_subnet_ids
}
The network state file's outputs block is the only thing the
database state file is allowed to depend on — not network's internal
resource addresses, not its module structure, just whatever it chose to
publish as outputs. This is the actual API boundary between teams: the
network team can refactor everything inside their state (rename modules,
restructure subnets) as long as private_subnet_ids keeps meaning the
same thing, and the database team never needs to know or care how it's
produced. Get the output contract right and teams stop needing to
coordinate apply order by hand.
The alternative to remote-state coupling: a platform module registry¶
module "vpc" {
source = "app.terraform.io/acme/vpc/aws"
version = "~> 4.0"
cidr_block = "10.20.0.0/16"
az_count = 3
}
Instead of every team's root module reaching into another team's state
via terraform_remote_state, a platform team can publish reviewed,
versioned modules (via a private registry — Terraform Cloud's or an
internal Git-tag-based one) that encode the org's approved patterns —
"this is what a compliant VPC looks like here." Consuming teams pin a
version and get updates on their own schedule by bumping it, rather than
being silently affected by another team's state changing underneath
them. This trades the remote-state model's tight, live coupling for a
looser, versioned one — the same tradeoff as pinning a library dependency
instead of building against another team's HEAD.
Layering: foundational vs. workload state¶
platform/
foundation/ (state: foundation) — org, VPCs, IAM boundaries, DNS zones
shared-services/ (state: shared-services) — logging, monitoring, artifact registry
teams/
checkout/ (state: checkout) — owned entirely by the checkout team
catalog/ (state: catalog) — owned entirely by the catalog team
A workable large-org layout separates state by rate and ownership of
change, not just by service. foundation changes rarely and requires
the widest review (it underlies everything); shared-services changes
occasionally and is owned by a platform team; each team's own state
changes constantly and is owned entirely by that team. Each layer
consumes the layer below it via published outputs or registry modules,
never the reverse — checkout's state can read foundation's VPC ID,
but foundation's configuration never references anything about
checkout. That one-directional dependency is what keeps a graph of
otherwise-independent teams from becoming a single, effectively
monolithic apply the moment enough cross-references accumulate.
Worked example: onboarding a new service without touching foundation¶
# teams/payments/main.tf
data "terraform_remote_state" "foundation" {
backend = "s3"
config = { bucket = "acme-tfstate", key = "foundation/terraform.tfstate", region = "us-east-1" }
}
module "service" {
source = "app.terraform.io/acme/ecs-service/aws"
version = "~> 2.1"
vpc_id = data.terraform_remote_state.foundation.outputs.vpc_id
private_subnets = data.terraform_remote_state.foundation.outputs.private_subnet_ids
cluster_arn = data.terraform_remote_state.foundation.outputs.ecs_cluster_arn
service_name = "payments"
container_image = "acme/payments:1.4.0"
}
The payments team writes exactly this file, in their own state, applied
through their own CI pipeline, with their own approvers — no pull
request against foundation, no coordination with the platform team
beyond consuming the published ecs-service module and the foundation
outputs contract. This is the actual deliverable of good platform
architecture: a new team can stand up a compliant service by writing a
handful of lines against stable, versioned interfaces, without anyone
needing write access to, or deep knowledge of, any other team's state.
How It Actually Works¶
Each terraform_remote_state data source is a read of another
statefile's outputs map at plan time — Terraform Core fetches that
state file (via the same backend read path used for the owning
configuration's own state), extracts only the outputs block, and
exposes it as this data source's attributes. Nothing about the
referenced state's resources, providers, or internal structure is
visible or loaded — the coupling is deliberately narrow, limited to
whatever the other configuration chose to declare as an output block.
This is mechanically why an output contract functions as a real API
boundary: Core enforces that only published outputs cross the boundary,
the same way a function's return value — not its local variables — is
the only thing its caller can observe.
Splitting one state into several means the dependency graph that used
to be a single DAG inside one terraform apply becomes several
independent DAGs with no Core-level ordering between them at all —
Terraform has no built-in concept of "apply network's state before
database's"; that ordering has to be enforced externally, by CI
pipeline sequencing or by the fact that database's plan will simply
show stale or missing values if network's outputs haven't been applied
yet. This is the direct cost of the reduced blast radius: you gain
independent locks and independent failure domains, and you give up
Core's automatic cross-resource ordering guarantee, which only ever
existed within a single state's single graph in the first place.
A versioned registry module pins a specific module source at a
specific tag, so a consuming configuration's plan is computed against
exactly that version's resource definitions regardless of what the
module's source repository looks like today — terraform init
resolves version = "~> 4.0" to a concrete tagged release and downloads
that snapshot, and nothing in the consuming state changes again until
someone bumps the version constraint and re-runs init. This is what
converts "another team's Terraform code" from a live, uncontrolled
dependency (remote state, which reflects whatever was last applied,
whenever that happened) into a controlled one (a module version,
which only changes when the consumer deliberately upgrades it) — the two
mechanisms in this module exist specifically to offer that choice.
Exercise¶
A platform has one state file containing the org's VPC, its shared RDS instance, and twelve independent microservices' ECS services, all authored by different teams. Propose a state-splitting boundary (which resources move to which state files, and how they'd reference each other), and explain specifically how your split reduces the blast radius of a mistake in one microservice's configuration compared to the current single-state layout.