- A small library of composed, versioned modules per cloud
- State split by blast radius, with locking and restricted access
- Secrets moved out of state using ephemeral values and write-only arguments
- Every change planned, policy-checked and applied through pipelines
Anonymised composite. Client names, identifying details and confidential figures are withheld; public facts are cited in the references.
The situation
A company had grown partly through acquisitions, and each acquired team had brought its own cloud and its own way of using Terraform. The result was infrastructure across AWS, Azure and GCP, described by hundreds of folders. Modules had been copied rather than shared, so fixes landed in one place and not the others. Some state files described entire environments, so a careless plan could touch hundreds of resources. Database passwords and keys sat in state, readable by anyone with access to the bucket.
Engineers had learned to avoid infrastructure changes. That was the real cost: features waited on a handful of people brave enough to run apply.
What had to be true
- Small, composable modules per cloud, versioned and shared, rather than copies.
- Small blast radius: no single plan able to damage unrelated systems.
- No plaintext secrets in state, and narrow access to state that remains.
- Every change through a pipeline, with plan review and policy checks; no laptop applies to production.
- Teams self-serve common infrastructure through a paved road, with the platform team owning the road, not every change.
Architecture
The data flow, end to end
- ChangeTeam edits a stack built from a template, pinned to module versions
- PlanCI runs a plan against that stack's own state only
- CheckPolicy checks: tags, encryption, public exposure, secrets in arguments
- ReviewPlan output reviewed in the pull request by the owning team
- ApplyPipeline identity applies after approval; humans cannot apply to production
- WatchScheduled plans detect drift and open issues for the owners
The hard part: resisting the universal module
The first instinct was a set of cloud-agnostic modules: one "database" module that worked on all three clouds. It would have hidden the features that made each managed service worth using and created a large abstraction nobody could debug. HashiCorp's guidance favours composition instead: a root module assembling small building blocks and passing dependencies between them as inputs and outputs1.
- Building blocks were provider-specific, thin and opinionated about security defaults.
- Shared interfaces covered what really is common: naming, tagging, ownership labels and output shapes.
- Templates composed blocks into patterns teams actually use, such as "service with database and queue".
- Each block had examples and tests, and was released with semantic versions.
Secrets were the other urgent fix. Terraform 1.10 introduced ephemeral values that are not persisted in plan or state, and 1.11 added write-only arguments on managed resources, so secrets can flow from a secrets manager into resources without landing in state23. Every secret that had ever been in state was rotated as part of the move. The checklist is in keeping secrets out of Terraform state.
Multi-cloud doesn't need multi-cloud modules. It needs consistent interfaces, good building blocks for each cloud, and one way to ship changes.
How it ran
- Module library and registry
- Pipelines and policy
- Templates and docs
- Migrate stacks to templates
- Own plans and reviews
- Self-serve new services
- Policy rules
- State access model
- Secret rotation
- Inventory stacks and state
- Find secrets in state
- Map ownership
- Pipelines and policy
- State per blast radius
- First building blocks
- Highest-risk stacks first
- Secrets out of state
- Templates for common patterns
- Team self-service
- Drift detection
- Module release process
Key decisions and trade-offs
| Decision | Alternative | Why |
|---|---|---|
| Provider-specific building blocks | Universal cross-cloud modules | Keeps each service's strengths and stays debuggable |
| State split by blast radius and owner | One state per environment | Limits damage and lets access follow ownership |
| Pipeline-only applies | Engineers apply from laptops | Consistent, reviewed and auditable changes |
| Ephemeral values and write-only secrets | Mark secrets as sensitive | Sensitive hides values from output; it doesn't keep them out of state |
| Modules compatible with Terraform and OpenTofu | Commit to one tool now | Keeps the licensing decision open without blocking work |
The tooling question came up early. OpenTofu was forked from Terraform under the Linux Foundation after HashiCorp's 2023 licence change4. Rather than let that decision block the refactor, modules were kept compatible with both while legal and procurement reached a view; the trade-offs are in OpenTofu or Terraform in 2026.
What I'd do again, and differently
Again: put pipelines, policy and state boundaries in place before migrating anything, and rotate every secret that touched state. Differently: publish the module interface conventions (names, tags and outputs) in the first week. Teams started writing new stacks immediately, and every one written before the conventions existed needed rework. More on structure in Terraform module design for multi-cloud.
If your infrastructure code has become something people avoid touching, my fractional CTO work covers estates like this, or analyse your challenge and bring the result to a call.
- 1Module compositionHashiCorp developer documentation
- 2Terraform 1.10 improves handling secrets in state with ephemeral valuesHashiCorp · Nov 2024
- 3
- 4Linux Foundation joins OpenTF to fork Terraform into OpenTofuThe New Stack · Sep 2023