The first time I inherited a shared Terraform estate, prod had a CIDR nobody could explain, staging still pointed at a sandbox account ID, and the "reusable" module took forty inputs, half of them unused. The apply meant for staging ran in prod anyway, because the workspace was still selected from the day before.
Terraform was not the problem. The boundary was. Modules had grown around tickets, not around ownership or change frequency. Once you treat a module as a platform contract, the rest of the estate gets simpler: environment roots compose those contracts, state stays inside a blast-radius boundary, and application teams stop forking the platform to ship a feature.
Use this pattern when more than one environment or account should share network, compute, identity, and observability decisions. A platform team owns the landing zone, production and non-production must be reproducible from documented inputs, a second provider is already in play or about to be, and reviews stall because nobody can tell a module contract from an environment value. One account, one team, a handful of resources: a single root module is enough. The cost of this structure shows up later, when someone copies prod/ to make dr/ and silently inherits a hard-coded account ID.
This is a different problem from the one most reusable-module advice actually solves. Two similar services in one account sharing a modules/app folder to avoid repeating the same eight resources is DRY, and CLI workspaces or a shared local module handle it fine. What changes here is a security boundary sitting between the environments, not a naming convention. A non-production role that can read a production state file is a security finding waiting for a Tuesday deploy, not a code-quality complaint.
A concrete estate
A mid-size platform I keep coming back to looks like this:
- one shared-services account for network hub, DNS, and log archive
- one non-production account for development and staging
- one production account
- a second provider later (usually Azure) for a single regulated workload, not a second copy of the whole estate
The mistake is to model that as one Terraform root with CLI workspaces named dev, stage, and prod. Those workspaces are separate state files inside one backend. A staging apply does not lock or overwrite production state. The failure is the same backend, the same credentials, and terraform workspace select prod still sitting in the shell. Isolation is a prod backend in a prod account that the non-prod role cannot assume. envs/prod/ on disk is not enough if one role can read every state object.
The better shape is boring:
modules/
network/ # VPC or VNet, subnets, routes, baseline security groups
identity/ # deploy roles, workload identities, break-glass hook
compute/ # launch templates, node groups, or App Service plan
data/ # databases, caches, queues, backup and encryption
observe/ # log sinks, metrics, alert baselines
envs/
shared/
main.tf
outputs.tf
backend.tf
terraform.tfvars
nonprod/
main.tf
remote.tf
providers.tf
backend.tf
terraform.tfvars
prod/
main.tf
remote.tf
providers.tf
backend.tf
terraform.tfvars
Shared-services composes network, identity, and observe. Non-production and production compose compute and data. They read hub outputs. They do not recreate the hub, and they do not copy subnet IDs into tfvars. The hub has to publish those outputs, or the consumer invents them.
# envs/shared/main.tf (illustrative)
module "network" {
source = "git::https://git.example.com/platform/modules.git//network?ref=v3.1.0"
cidr = var.cidr
tags = var.tags
}
module "identity" {
source = "git::https://git.example.com/platform/modules.git//identity?ref=v2.0.4"
tags = var.tags
}
# envs/shared/outputs.tf (illustrative)
output "private_subnet_ids" {
value = module.network.private_subnet_ids
}
output "deploy_role_arn" {
value = module.identity.deploy_role_arn
}
Those two outputs are the contract. Publish the integration points a workload root actually consumes. Do not dump the whole module object into remote state.
# envs/prod/backend.tf (illustrative)
terraform {
backend "s3" {
bucket = "org-tf-state-prod"
key = "platform/prod/terraform.tfstate"
region = "eu-central-1"
encrypt = true
use_lockfile = true
assume_role = {
role_arn = "arn:aws:iam::111122223333:role/terraform-prod"
}
# dynamodb_table is the older, deprecated variant.
}
}
# envs/prod/remote.tf (illustrative)
data "terraform_remote_state" "shared" {
backend = "s3"
config = {
bucket = "org-tf-state-shared"
key = "platform/shared/terraform.tfstate"
region = "eu-central-1"
}
}
# envs/prod/main.tf (illustrative)
# Safety defaults (encryption, no public endpoints, logging) live in the module.
module "compute" {
source = "git::https://git.example.com/platform/modules.git//compute?ref=v2.4.1"
subnet_ids = data.terraform_remote_state.shared.outputs.private_subnet_ids
deploy_role_arn = data.terraform_remote_state.shared.outputs.deploy_role_arn
env = var.env
tags = var.tags
}
module "data" {
source = "git::https://git.example.com/platform/modules.git//data?ref=v1.3.0"
env = var.env
instance_class = var.instance_class
retention_days = var.retention_days
tags = var.tags
}
assume_role on the backend is the line that matters. The folder name is not a control. The prod pipeline assumes terraform-prod for every state API call. Pair that with a bucket policy that names the same principal, and allowed_account_ids so a stolen non-prod credential cannot even talk to the prod account. The prod role still needs read on the shared state object. That is a deliberate read, not write, and it is not the same as giving non-prod read on prod.
# bootstrap or shared-services: prod state bucket policy (illustrative)
data "aws_iam_policy_document" "prod_state" {
statement {
sid = "ProdRoleOnly"
effect = "Allow"
principals {
type = "AWS"
identifiers = ["arn:aws:iam::111122223333:role/terraform-prod"]
}
actions = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
resources = ["arn:aws:s3:::org-tf-state-prod/platform/prod/*"]
}
}
# envs/prod/providers.tf (illustrative)
provider "aws" {
region = "eu-central-1"
allowed_account_ids = ["111122223333"]
}
An Allow that names the prod role is not enough if another role in the same account already has s3:*. Add an explicit Deny for every other principal, and s3:ListBucket on the bucket itself, or someone with admin in that account can still read the state. If a non-prod pipeline can aws s3 cp the production state object, the isolation is theatre. Azure's azurerm backend leases the state blob; you do not set use_lockfile there. GCS locks natively too, no separate table needed. Same rule, different lock primitive.
# modules/data/variables.tf (illustrative)
variable "encryption" {
type = bool
default = true
}
variable "publicly_accessible" {
type = bool
default = false
}
# envs/prod/terraform.tfvars (illustrative)
env = "prod"
instance_class = "db.r6g.large"
retention_days = 35
tags = {
environment = "prod"
owner = "platform"
}
SSM or another published output store is the other valid path. The rule is the same: the workload root reads the hub. Floating main is how staging and production silently diverge after a Friday merge. Provider-official sources (AWS modules, Azure Verified Modules, Cloud Foundation Toolkit) are fine as internals. Wrap them. Your interface module is what application teams depend on.
The Azure subscription, when it arrives, gets its own envs/azure-prod/ root. Same cidr, env, and tags interface where the concept is shared. Provider-native internals underneath. The network topology note is where the three buckets live: standardise, abstract, or accept divergence.
Split modules by ownership and change frequency
The useful cut is who can merge a change, and how often that change should happen. Network and identity change slowly and stay with platform; pair those with the network and RBAC notes rather than copying last year's diagrams. Compute and data are consumed by application teams, who pass size and version, not a new backup policy. Observe is required for any new environment.
If two teams have to coordinate on every pull request, the module is too wide. If every service has its own fork of network, the module is too vague to use.
State and promotion
Separate state per environment, or per blast-radius boundary if an environment is too large to fail as one unit. Locking lives in the backend block above; the matrix IaC row keeps the provider names current. S3 uses use_lockfile. DynamoDB is the deprecated path.
Promotion is a pin moving from one root to the next, not a CLI workspace switch and not a copy-paste of state.
# envs/nonprod/main.tf: applied, plan kept in the production PR
module "compute" {
source = "git::https://git.example.com/platform/modules.git//compute?ref=v2.5.0"
}
# envs/prod/main.tf: still on the last known-good tag
module "compute" {
source = "git::https://git.example.com/platform/modules.git//compute?ref=v2.4.1"
}
The production PR changes only that ref, to the same tag that already ran in non-production. Do not apply main.
Policy checks belong on the plan: tagging, public endpoints, encryption, and identity wildcards. If a check only exists as a wiki page, it will lose to a cutover window.
What "done" looks like for a hand-off
A module set is not ready for application teams because the platform team can apply it. It is ready when a team that did not write it can create a new environment from documented inputs alone.
Before I hand it over I want:
- a new environment created from
tfvarsand a pinned module version, no Slack questions - an engineer who did not write the modules can reason about one of them without reading the whole estate
- outputs that expose the integration points teams actually need (subnet IDs, deploy role ARN, log destination), not the entire state object
- destroy and recreate in a test environment without leftover security groups, DNS records, or locked state
- encryption and "no public endpoints" living in one module, so a reviewer has one place to check
- no account IDs, secrets, or peering connection IDs hard-coded in module source
If destroy does not work, you do not have a module. You have a snowflake with a .tf extension.
What to check Monday
- Separate the backend per environment. Confirm the non-prod role is absent from the prod state bucket policy and cannot assume
terraform-prod. - Pin a module
refin every environment root. Grep for anything still trackingmain. - Move encryption, logging, and "no public endpoints" into module defaults, not wiki guidance.
- Run destroy and recreate in a test root. Fix whatever it leaves behind.
- Confirm production is not a CLI workspace on a shared backend. Split it if it is.
None of this needs new tooling. It needs treating the module boundary as its own decision, not a side effect of however the estate happened to grow.
Related work
The multi-cloud control comparison matrix keeps the IaC row set current: provider maturity, state backend, module registry, and drift detection. This note follows on from the migration operating model, which sets the baseline this one assumes, and from the network topology decision for what to standardise versus leave provider-native. If an estate like this needs a second look, get in touch.