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
Two-column diagram: one Terraform root with shared CLI workspaces and credentials on the left, versus separate shared, nonprod, and prod roots each with their own backend and role on the right
Figure 1. CLI workspaces share a backend and a role. Separate roots put production state behind a role the non-prod pipeline cannot assume.

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 tfvars and 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 ref in every environment root. Grep for anything still tracking main.
  • 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 plan against 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 main branch 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.

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.