← Back to hometerraform

Terraform at Scale: Modules, State, and the OpenTofu Question

← All writing

Nobody outgrows Terraform because of the language. Teams outgrow their arrangement of Terraform: one giant state file that takes eleven minutes to plan, a modules/ directory that six services depend on at ref=main, secrets sitting in plaintext state, and a production apply that everyone is quietly afraid of. The HCL was never the problem. The architecture around it was.

We build and operate infrastructure for clients at Luminary, which means we inherit a lot of other people's Terraform and stand up a fair amount of our own. The projects that age well share a small set of structural decisions — about state, repos, modules, and CI — that are cheap to make early and brutally expensive to retrofit. There's also a decision that didn't exist a few years ago: whether you're running Terraform at all, or OpenTofu. Let's start there, because it colors everything else.

Illustration: an inspector comparing two miniature model cities, one drifted crooked

The fork: Terraform, OpenTofu, and how to actually choose

The short history, without the tribal framing. In August 2023, HashiCorp relicensed Terraform from the Mozilla Public License 2.0 to the Business Source License 1.1. BSL is source-available, not open source by the OSI definition: you can read, modify, and use the code — including in production, for free — but you can't use it to build a product that competes with HashiCorp's commercial offerings. For most companies running Terraform to manage their own infrastructure, the license change had zero practical effect. For vendors building Terraform-adjacent products (CI platforms, alternative backends, policy tooling), it was existential.

Those vendors, plus a lot of community contributors, forked the last MPL-licensed release into what became OpenTofu — now a Linux Foundation project that entered the CNCF sandbox in 2025, still under MPL 2.0. IBM's acquisition of HashiCorp, completed in 2025, didn't change the BSL terms, but it did add a second question to the calculus: not just "what does the license say" but "who sets the roadmap, and do their incentives align with mine."

Three years in, the honest technical summary is: the two tools are still highly compatible — same HCL, same provider ecosystem via their respective registries, same core workflow — but they are no longer identical binaries with different logos. OpenTofu has shipped features Terraform's open binary doesn't have, notably client-side state encryption, for_each on provider blocks, early evaluation of variables in places Terraform keeps static (like backend and module source strings), and an -exclude flag as the inverse of -target. Terraform, meanwhile, keeps its own cadence — the Stacks work and HCP integration land there first, and some newer language features exist on only one side. Migrating in either direction is easy today and gets a little less easy every release.

How we think about choosing:

  • You're a normal company managing your own infrastructure. The BSL does not restrict you. Choose on features, ecosystem fit, and governance preference. Staying on Terraform is a defensible default; so is OpenTofu.
  • You're building a product on top of the tool — anything that embeds, wraps, or resells plan/apply as a service. BSL risk is real and OpenTofu is the safe harbor. This is the clear-cut case.
  • You care about state encryption or open governance. OpenTofu's client-side state encryption is a genuinely useful feature with no open-Terraform equivalent, and Linux Foundation stewardship means the license can't be pulled out from under you again.
  • You're deep in the HashiCorp/IBM ecosystem (HCP Terraform, Sentinel, Vault Enterprise integration). The commercial platform is Terraform-shaped; fighting that is rarely worth it.

Our practical advice: pick one deliberately, pin the version in CI, and write your modules to the shared subset of the language. Avoid features exclusive to either tool unless you've consciously accepted the lock-in. That keeps the switching cost low, which is itself a negotiating position.

Everything below applies to both tools; we'll say "Terraform" and mean either.

Repo topology: how the code is arranged decides how the team behaves

Monorepo vs polyrepo

For most teams under ~50 engineers, a single infrastructure monorepo wins: one place to search, one CI pipeline to maintain, atomic changes across stacks, and easy code review. Polyrepo-per-team starts to pay off when ownership boundaries are real organizational boundaries — separate on-call, separate cloud accounts, separate compliance regimes — and the coordination cost of a shared repo exceeds the duplication cost of split ones.

The trap is the middle path: a monorepo for "shared" infrastructure plus a scattering of per-service repos that each grow their own conventions. If you split, split along account/ownership lines and publish shared modules through a registry (more below) rather than cross-repo git:: sources.

Directories, not workspaces, for environments

Terraform workspaces (the CLI feature, not HCP workspaces) look like the obvious environment mechanism and almost never survive contact with production. The problems:

  • All workspaces share one backend configuration and one set of code. You cannot have prod in a different account, region, or bucket without conditionals threaded through everything.
  • terraform.workspace string-switching (var.instance_size[terraform.workspace]) turns your configuration into a lookup table where the actual prod values are one typo away from the staging ones.
  • It's invisible. terraform apply in the wrong workspace looks exactly like terraform apply in the right one. Directory-per-environment makes the target unmissable — it's your working directory.
  • Environments are never actually identical. Prod has deletion protection, different capacity, extra alerting. Workspaces punish divergence; directories absorb it.

Workspaces are fine for genuinely fungible copies of a stack — per-developer sandboxes, ephemeral PR environments. For dev/staging/prod, use directories:

infra/
├── modules/                  # shared building blocks (or a separate registry)
│   ├── vpc/
│   ├── ecs-service/
│   └── rds-postgres/
├── envs/
│   ├── dev/
│   │   ├── network/          # one state per stack per env
│   │   ├── platform/
│   │   └── app/
│   ├── staging/
│   │   └── ...
│   └── prod/
│       ├── network/
│       ├── platform/
│       └── app/
└── global/                   # DNS zones, IAM, org-level config

Each leaf directory is a root module with its own backend and its own state. Which brings us to the decision that matters most.

State architecture: blast radius is the design constraint

Remote backend, locked

Nobody at scale runs local state, but plenty of teams run remote state without locking and discover it during a corrupted-state incident. On AWS the long-standing pattern was S3 plus a DynamoDB table for locks. As of Terraform 1.10 (experimental) and GA in 1.11, the S3 backend supports native locking via S3 conditional writes — a .tflock object next to the state — and the DynamoDB arguments are deprecated. New projects should skip DynamoDB entirely:

terraform {
  backend "s3" {
    bucket       = "acme-tfstate-prod"
    key          = "prod/network/terraform.tfstate"
    region       = "eu-west-1"
    use_lockfile = true
    encrypt      = true
  }
}

Existing projects can set use_lockfile = true alongside the dynamodb_table argument during migration — Terraform will honor both — then drop the table. One less piece of infrastructure, one less IAM policy, one less thing to explain.

Whatever the backend: versioning on, encryption on, access restricted more tightly than you think necessary. State is a secrets file that occasionally moonlights as a dependency graph.

Split state until an apply stops being scary

A single state file for an environment fails three ways at once. Plans get slow because every resource is refreshed. Locks get contended because every change queues behind every other change. And blast radius goes total: a botched refactor of a security group rule can, through the wonders of a shared graph, threaten your database.

Split state along two axes:

  1. Rate of change. Networking changes quarterly; app services change daily. They should not share a lock or a failure domain.
  2. Ownership. The team that can break it should be the team that reviews it.

A typical layering: network (VPCs, subnets, peering) → platform (clusters, shared databases, queues) → per-service or app stacks on top. Lower layers change rarely and are consumed read-only by upper layers.

Passing data between states

Two options, and the choice matters more than it looks:

  • terraform_remote_state reads another stack's outputs directly from its state file. Simple, but the consumer needs read access to the entire state — including every secret in it — and it couples you to the producer's output names and state location.
  • Plain data sources (aws_vpc by tag, aws_ssm_parameter, resource lookups by name) query the provider for the real object. Looser coupling, least-privilege access, works even if the producer isn't managed by Terraform at all.

We default to data sources across team boundaries, with an explicit "published interface" convention — the producing stack writes well-known SSM parameters or tags, and that contract is documented. terraform_remote_state is acceptable within a single team's closely related stacks, where the shared-state-access problem is moot.

Module design that survives growth

Thin roots, versioned modules

The pattern that scales: root modules are thin — backend config, provider config, a handful of module calls, environment-specific values — and all real logic lives in reusable modules. A root module longer than ~200 lines is usually two stacks wearing a trenchcoat.

Modules themselves should behave like libraries, because that's what they are:

  • Semantic versioning, enforced. Consumers pin version = "~> 3.1", never a branch. A module referenced at ref=main is a supply-chain incident with extra steps — someone merges to main and every environment that plans afterward silently absorbs the change.
  • A registry, not git URLs. HCP Terraform, Spacelift, env0, GitLab's registry, or even the humble S3-backed module mirror — anything that gives you immutable versions and discoverability. Git tags work at small scale but offer no protection against tag rewrites.
  • Narrow interfaces. Every input variable is API surface you maintain forever. Prefer a few opinionated variables with validated defaults over forty passthrough knobs. Use validation blocks aggressively; a good error_message at plan time saves a support conversation later.
  • No providers inside reusable modules. Modules receive providers from the root. A module that declares its own provider block can't be used with for_each or destroyed cleanly.

Test the contract, not the cloud

terraform test (native since Terraform 1.6, also in OpenTofu) changed the economics of module testing. Plan-mode tests run in seconds with no real infrastructure, and they catch the failures that actually happen — broken conditionals, bad defaults, regressions in resource counts:

# modules/vpc/tests/vpc.tftest.hcl
variables {
  name       = "test"
  cidr_block = "10.20.0.0/16"
  az_count   = 3
}

run "creates_one_private_subnet_per_az" {
  command = plan

  assert {
    condition     = length(aws_subnet.private) == 3
    error_message = "Expected one private subnet per availability zone."
  }
}

run "rejects_undersized_cidr" {
  command = plan

  variables {
    cidr_block = "10.20.0.0/28"
  }

  expect_failures = [
    var.cidr_block,
  ]
}

Reserve command = apply tests — or Terratest, if you need to make real assertions against real APIs in Go — for your most critical modules, run nightly against a sacrificial account rather than on every PR. Apply-mode tests are valuable and slow; treat them like integration tests, because they are.

CI for IaC: plan on PR, apply on merge, policy in between

The workflow that works, tool-agnostic (Atlantis, Spacelift, env0, HCP Terraform, or plain GitHub Actions):

  1. PR openedterraform plan for every affected stack, plan output posted to the PR. The plan is the review artifact; nobody should approve a diff of HCL without seeing the diff of infrastructure.
  2. Policy checks run against the plan JSON. OPA (via Conftest or native integrations) or Sentinel, depending on your platform. This is where "no public S3 buckets," "mandatory cost tags," and "no IAM wildcards outside sandbox" live — as code, not as a reviewer's memory. terraform show -json plan.out | conftest test - is the whole integration at its simplest.
  3. Mergeterraform apply of the saved plan artifact, not a fresh plan. Applying a re-plan means applying something nobody reviewed.
  4. Scheduled drift detection. A nightly terraform plan -detailed-exitcode per stack; exit code 2 means drift, which pages a human or opens an issue. Drift you don't detect becomes drift you deploy on top of.

Two rules we hold firmly: no local applies against shared environments (CI's role is the only role with write access — humans get read-only, which also ends the "who applied this" mystery), and plans must be current (re-plan on merge conflict or staleness, then re-review if the plan changed).

Secrets in state, and staying out of that business

Terraform state stores every attribute of every resource in plaintext — including ones providers mark sensitive, which only affects CLI display. A generated RDS password, an API key created by a provider, a certificate private key: all readable by anyone who can read the state object.

Mitigations, in order of preference:

  1. Don't put secrets through Terraform. Have Terraform create the container (the Secrets Manager secret, the Vault path) and let something else set the value — or use provider features like RDS's manage_master_user_password, where the secret is generated and stored cloud-side and never transits state.
  2. Write-only arguments (Terraform 1.11+) for the cases where a value must pass through configuration: the provider sends it to the API but it's never persisted to state or plan.
  3. Ephemeral values/resources for secrets you need to read during a run without persisting them.
  4. Encrypt and restrict the state itself — backend encryption always; OpenTofu's client-side state encryption if you're on that side of the fork. This is defense in depth, not a license to put secrets in state.

If you inherit a state file full of secrets (we regularly do), rotate them as you refactor. The state's version history remembers what the current version forgot.

Refactoring without the midnight state surgery

The old way to restructure Terraform was terraform state mv by hand — imperative, unreviewable, easy to fumble. The modern language features make refactors declarative and PR-reviewable:

moved blocks record renames and restructures in code, so the plan shows a move instead of a destroy/create:

# app server was promoted from a bare resource into a module
moved {
  from = aws_instance.app
  to   = module.app.aws_instance.this
}

import blocks (Terraform 1.5+) bring existing infrastructure under management through a plan — reviewable, repeatable, and paired with terraform plan -generate-config-out=generated.tf to draft the HCL for you:

import {
  to = aws_s3_bucket.assets
  id = "acme-assets-prod"
}

removed blocks do the inverse: drop a resource from state without destroying it, in code, with review.

The discipline that makes these work: refactor and change in separate PRs. A moved block plus a behavior change in one diff is how "this plan shows no changes" turns into a lie. Move first, verify a no-op plan, then change.

When Terraform is the wrong tool

Knowing the boundaries is part of maturity:

  • Application configuration and deploys. Terraform managing your long-lived ALB is right; Terraform as your app deployment mechanism usually isn't — release cadence and rollback semantics belong to your deploy pipeline (or to Kubernetes controllers reconciling continuously, which is a different model than plan/apply).
  • One-off operational tasks. Rotating a credential once, backfilling data, a migration script — imperative jobs don't want a declarative state file remembering them forever. A script with logging beats a null_resource with a local-exec every time.
  • Resources that change under their own power. Autoscaled capacity, DNS records written by external-dns, anything another controller owns. Terraform fighting a reconciler produces permanent drift; use ignore_changes deliberately or cede the resource entirely.
  • Data and content. Database rows, feature flags that product managers toggle hourly, CMS content. If humans change it through a UI as part of their job, Terraform's job is at most to create the container it lives in.

The test we apply: does this thing want to converge to a version-controlled desired state, changed through review? If yes, Terraform. If it changes continuously, imperatively, or by non-engineers — something else.

Takeaways

  • The Terraform/OpenTofu split is a governance and product question, not a religious one. Normal production use is unrestricted under BSL; vendors building on the tool need OpenTofu. Pin versions, write to the shared subset, keep switching costs low.
  • Prefer an infra monorepo until ownership boundaries are organizational; use directory-per-environment, not CLI workspaces, for dev/staging/prod.
  • Split state by rate of change and ownership. On S3, use native locking (use_lockfile = true, Terraform 1.11+) and skip DynamoDB for new projects.
  • Prefer plain data sources over terraform_remote_state across team boundaries; remote state grants read access to everything, including secrets.
  • Keep root modules thin; version shared modules semantically through a registry; never consume a module at ref=main.
  • Use terraform test in plan mode on every PR; save apply-mode tests and Terratest for critical modules on a schedule.
  • CI owns the credentials: plan on PR, policy checks (OPA/Sentinel) against plan JSON, apply the reviewed artifact on merge, nightly drift detection via -detailed-exitcode.
  • Keep secrets out of state: cloud-side generation, write-only arguments, ephemeral values — and encrypt the backend regardless.
  • Refactor with moved, import, and removed blocks in their own PRs, verified by a no-op plan.
  • Terraform is for infrastructure that wants reviewed, convergent state. App deploys, one-off jobs, and reconciler-owned resources belong to other tools.

Enjoyed the read? We build this stuff for clients too.

Start a project