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 frequencyHow 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 servicesBut 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 stateNow 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 frequentlyPutting 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/
βββ everythingAdvantages
- 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 BThe 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.tfThe 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 statewhile the application team owns:
application stateYou can then make permissions reflect that ownership.
Network team
β
βββ network state + network permissions
Application team
β
βββ application state + application permissionsThis 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 statesRegions may create another dimension:
production
βββ us-east-1
βββ eu-west-1Whether 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
β
applicationTerraform’s remote state capabilities can provide that relationship when appropriate.
But every cross-state dependency adds coupling.
Consider:
A β B β C β DIf every stack depends on another stack, changing a foundation layer can become operationally complicated.
Prefer simple dependency graphs:
network
/ \
β β
application datarather 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
β
PromoteThis 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
β
UpgradeThis 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.mdThis 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:
| Question | If 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 approvalinstead of:
Application change
β
One giant production state
β
Huge plan
β
Broad permissions
β
Large blast radiusScaling 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.