Skip to content

Scaling Terraform Architecture


A Terraform project can be easy to understand when one person manages a small amount of infrastructure. The architecture becomes much more important when multiple teams, environments, accounts, regions, and deployment frequencies enter the picture.

Scaling Terraform is not mainly about making files more complicated. It is about choosing boundaries.

The most useful boundaries are:

State
Ownership
Permissions
Blast radius
Dependencies
Deployment frequency

How Much Infrastructure Should One Root Module Manage?

There is no universal number of resources that should live in one state.

The better question is:

Which resources should change, fail, and be owned together?

A single large state can be simple:

One root module
       β”‚
       └── one state
             β”œβ”€β”€ network
             β”œβ”€β”€ database
             β”œβ”€β”€ applications
             └── shared services

But the blast radius becomes large. A plan for a small application change may involve a state containing unrelated infrastructure.

Splitting state creates boundaries:

Network state
      β”‚
      β–Ό
Application state
      β”‚
      β–Ό
Monitoring state

Now each state can have:

  • separate permissions
  • separate ownership
  • smaller plans
  • smaller failure domains
  • different deployment frequencies

But splitting also introduces dependencies between states.

Neither approach is automatically correct.

State Boundaries Should Follow Real Boundaries

A useful state boundary often corresponds to one or more of:

  • ownership boundary
  • lifecycle boundary
  • permission boundary
  • blast-radius boundary
  • deployment-frequency boundary

For example, a shared network may change less frequently than an application.

shared-network/
    ↓
changes occasionally

application/
    ↓
changes frequently

Putting both into one state means frequent application changes operate alongside slower-moving shared infrastructure.

Separating them can reduce unnecessary coupling.

On the other hand, excessive splitting can create a maze of remote state dependencies.

The goal is not “as many states as possible.”

The goal is:

Split where the operational boundary is real.

Common Architectural Patterns

One Large State

production/
└── everything

Advantages

  • simple dependency model
  • easy to understand initially
  • fewer state boundaries

Disadvantages

  • large blast radius
  • large plans
  • broader permissions
  • unrelated teams can interfere with each other
  • more difficult concurrent work

This can be reasonable for smaller environments.

State Per Environment

environments/
β”œβ”€β”€ dev/
β”œβ”€β”€ staging/
└── production/

Each environment has its own state.

Advantages

  • strong environment isolation
  • straightforward mental model
  • easy permission separation

Disadvantages

  • production can still become a very large state
  • shared infrastructure may need another boundary
  • environment directories can duplicate configuration

State Per System Or Domain

production/
β”œβ”€β”€ network/
β”œβ”€β”€ data/
β”œβ”€β”€ application/
└── observability/

Advantages

  • smaller blast radius
  • ownership can align with teams
  • independent deployment frequency

Disadvantages

  • cross-state dependencies
  • more repositories/directories/state management
  • greater operational complexity

Layered Infrastructure

A common compromise is to separate relatively stable foundations from faster-moving workloads:

Foundation
    β”‚
    β”œβ”€β”€ network
    β”œβ”€β”€ shared identity
    └── shared services
          β”‚
          β–Ό
Applications
    β”‚
    β”œβ”€β”€ service A
    └── service B

The application layer can consume outputs from the foundation layer where appropriate.

The important part is to avoid creating unnecessary dependency chains.

Modules Versus Root Modules

Modules are reusable building blocks.

Root modules are deployment boundaries.

Those are different responsibilities.

For example:

modules/
β”œβ”€β”€ network/
β”œβ”€β”€ database/
└── application/

environments/
β”œβ”€β”€ dev/
β”‚   └── main.tf
β”œβ”€β”€ staging/
β”‚   └── main.tf
└── production/
    └── main.tf

The module describes reusable infrastructure logic.

The environment root decides:

  • which module version to use
  • which variables to provide
  • which providers to configure
  • which environment owns the resources
  • which backend/state to use

Avoid turning every small collection of resources into a module merely for abstraction’s sake.

A module should provide a useful boundary around repeated or conceptually related infrastructure.

Ownership And Permissions

Architecture and security are connected.

Suppose the network team owns:

network state

while the application team owns:

application state

You can then make permissions reflect that ownership.

Network team
    β”‚
    └── network state + network permissions

Application team
    β”‚
    └── application state + application permissions

This is stronger than simply telling everyone not to modify each other’s resources.

State boundaries can therefore become permission boundaries.

Accounts And Regions

AWS organizations often separate environments or workloads using accounts.

Terraform architecture should reflect those boundaries rather than pretending all infrastructure is one flat system.

For example:

AWS organization
β”‚
β”œβ”€β”€ development account
β”‚     └── Terraform states
β”‚
β”œβ”€β”€ staging account
β”‚     └── Terraform states
β”‚
└── production account
      └── Terraform states

Regions may create another dimension:

production
β”œβ”€β”€ us-east-1
└── eu-west-1

Whether each region needs its own state depends on the resources and operational boundaries.

Do not create separate states simply because separate states are possible. Create them when isolation provides a meaningful benefit.

Cross-Stack Dependencies

Once infrastructure is split, one stack may need information from another.

For example:

network
   β”‚
   └── VPC ID
        ↓
application

Terraform’s remote state capabilities can provide that relationship when appropriate.

But every cross-state dependency adds coupling.

Consider:

A β†’ B β†’ C β†’ D

If every stack depends on another stack, changing a foundation layer can become operationally complicated.

Prefer simple dependency graphs:

        network
       /       \
      ↓         ↓
application   data

rather than long chains whenever possible.

Provider And Terraform Version Management

Large Terraform environments need deliberate upgrade policies.

A root module should specify an appropriate Terraform version constraint:

terraform {
  required_version = ">= 1.6.0, < 2.0.0"
}

Providers should also be constrained:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

The precise constraints should reflect the versions your project has tested.

Commit .terraform.lock.hcl so provider selection and checksums are reproducible.

Terraform upgrades should be treated as changes, not background events.

A practical upgrade process is:

Select target version
       ↓
Read upgrade notes
       ↓
Update constraints
       ↓
Initialize
       ↓
Run validation/tests
       ↓
Review plan
       ↓
Apply in a controlled environment
       ↓
Promote

This reduces the chance that a version upgrade and an infrastructure change become one indistinguishable event.

Module Versioning

Production environments should generally consume deliberate module versions rather than floating unpredictably.

For example:

module "network" {
  source  = "example/network/aws"
  version = "2.3.0"

  name = "production"
}

A version gives you a known dependency point.

When a new module version is available:

Current: 2.3.0
       ↓
Review 2.4.0
       ↓
Test
       ↓
Plan
       ↓
Approve
       ↓
Upgrade

This is safer than allowing production infrastructure to change simply because a dependency released a new version.

A Realistic Repository Structure

A moderately sized Terraform repository might look like:

infrastructure/
β”œβ”€β”€ modules/
β”‚   β”œβ”€β”€ network/
β”‚   β”‚   β”œβ”€β”€ main.tf
β”‚   β”‚   β”œβ”€β”€ variables.tf
β”‚   β”‚   └── outputs.tf
β”‚   └── application/
β”‚       β”œβ”€β”€ main.tf
β”‚       β”œβ”€β”€ variables.tf
β”‚       └── outputs.tf
β”‚
β”œβ”€β”€ environments/
β”‚   β”œβ”€β”€ dev/
β”‚   β”‚   β”œβ”€β”€ main.tf
β”‚   β”‚   β”œβ”€β”€ variables.tf
β”‚   β”‚   └── outputs.tf
β”‚   β”œβ”€β”€ staging/
β”‚   β”‚   β”œβ”€β”€ main.tf
β”‚   β”‚   β”œβ”€β”€ variables.tf
β”‚   β”‚   └── outputs.tf
β”‚   └── production/
β”‚       β”œβ”€β”€ main.tf
β”‚       β”œβ”€β”€ variables.tf
β”‚       └── outputs.tf
β”‚
β”œβ”€β”€ .gitignore
└── README.md

This is not a mandatory structure. It illustrates a separation between reusable modules and deployment roots.

A different organization may use separate repositories or separate state boundaries. The correct choice depends on ownership and operational requirements.

A Useful Scaling Test

When deciding whether to split a Terraform root module or state, ask:

QuestionIf the answer is “yes”
Does another team own it?Consider a separate boundary
Does it require different permissions?Consider a separate boundary
Does it change at a very different frequency?Consider a separate boundary
Would failure affect unrelated systems?Consider a separate boundary
Does it need independent approval?Consider a separate boundary
Would splitting create many cross-state dependencies?Reconsider the split

This keeps architecture grounded in operational reality.

Use Architecture To Reduce Blast Radius

A useful production Terraform architecture makes a small change remain small.

For example:

Application change
      ↓
Application state
      ↓
Small plan
      ↓
Application permissions
      ↓
Application approval

instead of:

Application change
      ↓
One giant production state
      ↓
Huge plan
      ↓
Broad permissions
      ↓
Large blast radius

Scaling Terraform successfully is therefore less about adding layers and more about creating boundaries that reflect how infrastructure is actually owned and changed.

    flowchart TD
    Foundation["Foundation State<br/>Network / Shared Infrastructure"]
    Dev["Development State"]
    Stage["Staging State"]
    Prod["Production State"]
    Modules["Versioned Modules"]

    Modules --> Foundation
    Modules --> Dev
    Modules --> Stage
    Modules --> Prod

    Foundation --> Dev
    Foundation --> Stage
    Foundation --> Prod
  

The architecture is deliberately simple: shared reusable logic flows into independent deployment boundaries, while the foundation can be consumed by environment-specific infrastructure.

Last updated on