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.
A workspace name was all that separated staging from prod, and the module would accept almost any input. Modules had grown around individual tickets instead of ownership or change frequency. Treating a module as an interface with an owner makes the rest of the estate simpler: environment roots compose those modules, 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 interface from an environment value. One account, one team, a handful of resources: a single root module is enough. Without this structure, the cost shows up later, when someone copies prod/ to make dr/ and silently inherits a hard-coded account ID.
Most reusable-module advice solves a different problem. 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. Here a security boundary sits between the environments. A non-production role that can read a production state file is a security finding.
Three accounts, three state backends
The estate in this note 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
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 means a separate S3 backend in a production account that the non-production 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 interface. Publish the integration points a workload root consumes. Do not dump the whole module object into remote state.
# envs/prod/backend.tf (illustrative)
terraform {
# use_lockfile shipped experimental in 1.10; 1.11 is GA.
required_version = ">= 1.11.0"
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-only grant, and it differs from 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, there is no isolation. Azure's azurerm backend leases the state blob; you do not set use_lockfile there. GCS locks natively too, with no separate table.
# 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. A module source that tracks main lets staging and production diverge without a reviewed change to either root. 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. It keeps the same cidr, env, and tags interface where the concept is shared, with 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. Compute and data are consumed by application teams, who pass size and version and leave the backup policy to the module. 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 pinned ref moving from one root to the next. It is never a CLI workspace switch or a copy 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.
Hand-off criteria across teams
A module set is ready for application teams 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 only the integration points teams need (subnet IDs, deploy role ARN, log destination)
- 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.
- 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.
- Run
terraform planagainst the production root as a read-only check. Confirm the plan is empty. Any diff not explained by a recent intentional change is a drift finding. - Confirm the shared-services remote state outputs are pinned and not floating. Any consumer root that reads a mutable
mainbranch state instead of a specific module ref is inheriting unreviewed changes. - List every module in the estate still sourced from a local path (
../modules/). Local paths are fine during development; in a production root they are an unversioned dependency, so move them to a versioned remote module source.
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. For machine image promotion and launch template interfaces consumed by these modules, see Golden image architecture for Linux baselines. If an estate like this needs a second look, get in touch.