Go to content
Infrastructure

Scaling Terraform for Large-Scale Deployments

A Terraform codebase that worked fine for one environment eventually starts fighting you: Terraform takes minutes to produce a plan because it calls the cloud provider to check the status of every resource it tracks. Two developers run apply at the same time, and the state file gets corrupted. The state file is the JSON record that maps each resource address in your configuration to the real-world object it manages. A one-line change to a load balancer config threatens to recreate a database because the database lives in the same state file. These are the symptoms of a setup that has not been scaled, and fixing them means changing how you organize state and code.

Scaling Terraform comes down to a few structural decisions. You choose where the state file lives, how you split the configuration into independently managed pieces, and how you package and version reusable code. You also decide who can run changes. Each decision has a cost and a payoff, and the right choice depends on your team size and how many environments you run.

Two terms come up immediately. A plan is Terraform’s dry run, a report of what it will create, change, or delete. Apply is the step that makes those changes to real infrastructure. Both read the state file to figure out what already exists. Put the state file first, then split the configuration, package code into modules, organize the repository, and set up security and orchestration.

Put state in a remote backend with locking

The state file is the source of truth for what Terraform manages. Every time you run plan or apply, Terraform reads the state to learn which resources already exist, then compares it with your configuration and with the real objects in the cloud. If you lose the state file, Terraform can no longer match your configuration to the infrastructure it created.

For any project with more than one person, the state file must live in a remote backend, the storage location where Terraform reads and writes state. Options include S3, Azure Blob Storage, GCS (Google Cloud Storage), HCP Terraform (HashiCorp’s managed platform, formerly Terraform Cloud), Consul, and Kubernetes. A laptop is not a suitable source of truth: a state file kept only on a developer’s machine drifts from what everyone else deploys, and committing it to Git creates meaningless merge conflicts and risks leaking secrets.

Remote backends also lock the state file. When one person runs apply, the backend holds a lock on the state file, and any concurrent plan or apply waits or fails instead of writing over the first operation. Backends like S3, GCS, Azure Blob Storage, and HCP Terraform support locking automatically. If a lock goes stale, for example after a crashed process, terraform force-unlock releases it.

Backend blocks cannot use variables or expressions, so every value such as the bucket name must be hardcoded or supplied with -backend-config at terraform init time. Marking variables or outputs sensitive = true only redacts them from CLI output; the state file still stores the real values in plain text. Never commit state to version control, and restrict who can read the state storage as strictly as you restrict the cloud account itself.

Split infrastructure into smaller compositions

The number of resources in a composition has the largest effect on plan and apply speed. A composition, also called a root module, is the top-level configuration you run plan and apply against, and it has its own state file. During a plan, Terraform makes cloud API calls to verify the status of every resource in the state, so a composition with hundreds of resources takes much longer to plan than one with dozens. Smaller compositions speed up both plan and apply.

Watch plan time: plan should never take significantly longer than making the change in code. When a plan drags on for minutes, engineers stop waiting for it and start changing infrastructure by hand in the cloud console, and the codebase stops reflecting reality. Large states also produce enormous plan outputs, where one unintended destroy hides among hundreds of lines of diff. A single large state also serializes the team: one person’s apply locks the state for everyone, so a change to one service waits on an unrelated deployment.

Smaller compositions also contain failures. If a bad change or a compromised credential hits one composition, unrelated resources in other compositions stay insulated, which shrinks the blast radius, the set of resources one failed operation can damage.

Within a composition, keep a consistent structure with separate files rather than one long file. Put resources in main.tf, variables in variables.tf, outputs in outputs.tf, and provider versions in versions.tf. Consistent structure and naming also make state moves easier to reason about when you refactor later. Reserve terraform.tfvars for composition only, where it supplies environment-specific values, and keep it out of reusable modules.

Use data sources and terraform_remote_state as glue between modules within a composition. A data source performs a read-only lookup that fetches information about existing infrastructure, and terraform_remote_state reads another composition’s state file. Both let one module reference a value such as the VPC ID that another module created, without hardcoding it. Resource modules should not hardcode values that vary between environments; accept them as variables or fetch them from data sources.

Workspaces are another way to run multiple environments, but they do less than people expect. Each workspace is a named instance of a configuration with its own state file, so you can run the same configuration as separate dev, staging, and prod environments from one codebase. State isolation is not the same as infrastructure isolation: two workspaces can still create resources with identical names in the same account and collide. Prefix resource names with terraform.workspace to prevent naming collisions, and for true isolation, configure separate providers per workspace so each environment uses a different cloud account or region. You can also restrict access to individual workspace state files with IAM policies. When you read another workspace’s state with terraform_remote_state, specify the workspace explicitly so you never consume the wrong environment’s outputs.

Design modules that stay simple and versioned

A module is a reusable block of Terraform configuration: it takes defined inputs, called variables, and exposes defined outputs. Modules let you avoid pasting the same resource definitions into every environment, but they come with their own failure mode: over-abstraction.

A module should have a single responsibility, such as managed database, load balancer, or VPC. It should expose the knobs that vary between uses. A module that tries to provision half of a service’s infrastructure is hard to test and eventually fights every real requirement. Do not create a module until the same Terraform code has been written at least twice; a module built for one imagined future use is a bet against reality. Keep the module hierarchy shallow. Nested modules more than two levels deep are hard to debug because you lose track of which layer set which value.

Give every input variable sensible defaults and a validation block that rejects bad values early. A typo then fails at plan time with a readable message instead of surfacing deep inside the cloud provider. Export outputs for every resource identifier another module might need, but resist exporting everything; every output is a contract, and each one you add reduces your freedom to change the module later.

Use environment-specific root modules composing shared modules: the root module holds the environment’s values, and the modules hold the reusable logic. Configure the backend at the root module level, not inside child modules, so you keep centralized control over where state lives.

Version every module with semantic versioning: MAJOR for breaking changes, MINOR for new features, PATCH for bug fixes, and tag each release in Git. Root configurations must pin the module versions they use, either exact versions or compatible ranges like ~> 2.3, so a new module release never silently changes production infrastructure. If you automate version bumps, the automation needs a defined source of versions, along with a complete change range, permissions, and a retry policy. You can share modules internally with a private registry like HCP Terraform, versioned archives in S3, or plain Git refs without a registry.

Test a module before you publish it. Static analysis with terraform fmt, terraform validate, tfsec, and tflint catches formatting and security problems cheaply. Integration testing with Terratest spins up real infrastructure and verifies it behaves as expected. Example configurations double as smoke tests and as documentation people can copy.

Match the repository structure to your team

Where module code lives is an organizational decision as much as a technical one. A mono-repo, all Terraform code in one repository, fits teams under 20 engineers or fewer than 30 modules, while a multi-repo, one repository per module or group of modules, suits organizations with many modules or several platform teams.

A mono-repo makes refactoring easier and keeps testing unified. Everyone can see the full picture when something changes. As the repo grows, every user has full access to everything and CI runs get slower. A change in one module can pull in unrelated work. A multi-repo gives each team independent versioning and ownership, and a breach in one repository does not expose the others. The costs are code duplication and dependency management friction, plus harder end-to-end testing: when module A depends on module B, you now manage version upgrades across repositories.

A hybrid structure uses both layouts. Foundational modules live in a mono-repo maintained by a platform team, and domain-specific modules live in separate repos owned by product teams. The platform team controls the core building blocks; product teams control their own abstractions.

Refactor with state moves, not destroy and recreate

When you rename a resource or move it from one module to another, Terraform sees the old address disappear from the configuration and a new address appear, and Terraform plans to destroy the old object and create a new one. For a database or a load balancer with attached DNS, that default is unacceptable.

The moved block declares that two resource addresses refer to the same object. When you add it during a refactor, Terraform updates the mapping in state instead of planning a replacement.

For manual state surgery, the state commands give you the same control. terraform state mv moves a resource to a new address in state without touching the real object, which is how you fix an address after moving code between modules. terraform state rm removes a resource from state while the real object survives, so Terraform stops managing it and the next apply leaves it alone. terraform apply -refresh-only updates state to match the real world without changing infrastructure. It is the supported replacement for the deprecated terraform refresh command; run it after someone changed something in the cloud console outside Terraform.

Secure the apply pipeline

The pipeline that runs Terraform has two things worth protecting: the state file, which contains secrets in plain text, and the cloud credentials that give the pipeline the power to create and destroy infrastructure.

Start with the state file. Remote storage with encryption and state locking keeps the file safe in transit and at rest, and prevents concurrent writes from corrupting it. Restrict read access to the state files to the people and services that actually run Terraform.

For credentials, enforce least privilege. Give Terraform an IAM role or service account with only the permissions the configuration needs, rather than reusing personal credentials or a broad admin role. Never hardcode secrets in .tf files; pass them through environment variables or a secret management service.

Control who can run what. Role-based access control on the CI/CD system or HCP Terraform decides who can plan, who can apply, and who can modify the configuration, so a developer can propose a change without holding the power to apply it to production. Add security scanners to the pipeline so tools like tfsec, Checkov, and Sentinel policies examine the configuration for known misconfigurations, such as a publicly readable storage bucket or an unencrypted database, and fail the build before apply runs.

Choose an orchestration layer when plain Terraform is not enough

When you run more than a few environments, repeating the same Terraform setup becomes the problem. Every composition needs the same backend block, provider configuration, and environment-specific variables, and someone has to apply them in the right order.

Orchestration tools sit on top of Terraform and manage that repetition and ordering. These tools fall into at least five groups: plain Terraform driven by scripts, Terragrunt, and in-house orchestration scripts. The other two are Ansible or similar configuration tools and Crossplane or Kubernetes-inspired approaches, where Crossplane manages infrastructure through Kubernetes-style APIs.

Terragrunt, a thin wrapper around Terraform, is the most common middle ground. Its DRY (don’t repeat yourself) pattern lets you define backend, provider, and input configuration once and reuse it across directories. That removes the copy-paste that grows with each new environment. The decision rule is simple: if you find yourself copying the same blocks into every directory or writing scripts that grow their own dependency logic, an orchestration tool is cheaper than maintaining the script. If you have two environments and a small team, plain Terraform with a CI job per environment is still the simplest thing that works.

More in Infrastructure Back to top ↑