When you run infrastructure on AWS, Azure, and Google Cloud, you write Terraform configuration that talks to each cloud’s API through its own provider. A provider is a Terraform plugin that translates Terraform’s declarative configuration into API calls for one cloud. The reusable pieces of that configuration are modules: named blocks of Terraform code that create a related set of resources, such as a virtual network with its subnets, or a compute instance with its security group.
How you structure modules and state determines whether multi-cloud Terraform stays manageable. State is the file Terraform keeps to record what infrastructure actually exists; every plan and apply command reads it to figure out what to create, change, or delete. Teams that keep a monolithic module and one giant state file hit long plan times, lock contention, and accidental changes to the wrong environment. Lock contention is the wait when two applies try to change the same state at the same time. Teams that split modules into small composable pieces and give each service its own state file can manage 50 or more services across clouds without those failures.
Module Structure Determines Whether Multi-Cloud Scales
A monolithic Terraform repository that defines every environment and service in one place fails in predictable ways. One team managing a large deployment saw plan times reach 12 minutes, and a typo in a dev variable triggered a change to production because everything shared the same configuration and state. The fix was to split the repository into layers: base, platform, and service, where each layer depends only on the layer below it.
Remote state data sources connect the layers by reading output values from another configuration’s state file, so one layer can use resources another layer created. A service layer, for example, reads the VPC ID and subnet IDs from the platform layer’s state instead of redefining them.
The same logic applies to state files. A single state file works when you have a handful of resources, but once you cross a few hundred, plan operations start timing out and lock contention becomes a daily problem, as engineers at Salesforce found managing multi-cloud deployments across AWS and GCP.
One state file per service and per environment contains the blast radius, the set of infrastructure a single bad change can damage. If a service’s configuration is wrong, only that service’s state is affected, and the other services keep deploying normally.
Terraform handles relatively static infrastructure well and struggles with highly dynamic resources. It is the wrong tool for managing Kubernetes workloads inside a cluster, where Helm charts and operators handle the lifecycle, or for serverless functions that change frequently. Keep Terraform for the network, compute, storage, and identity resources that change slowly.
Compose Small Modules and Standardize the Interface
Within that boundary, how you design modules decides how much of your infrastructure stays maintainable. The most common mistake is building one module that tries to handle every situation. A module with 40 variables and 15 conditional blocks forces every consumer to understand the whole thing before using any of it. The alternative is composition: small modules that each do one thing, connected explicitly at the workspace level. A workspace is a named Terraform configuration with its own state; the root configuration in a workspace composes modules into a deployable unit.
The VPC example shows why composition wins. One team built a VPC module that included peering logic, and teams abandoned it within a month because their peering requirements differed. Splitting it into a core VPC module and a separate opt-in peering module worked, because each team could use one without inheriting the other’s assumptions.
For multi-cloud, standardize the interface rather than the implementation: separate modules per cloud expose identical input variable names and output structures, while the resources inside differ. A team moving a service from AWS to GCP can then swap the module reference and keep the same variables, instead of learning a new interface. The wrapper pattern, where a higher-level module selects between cloud-specific modules based on a variable, sounds convenient but creates a maintenance nightmare as every cloud-specific option gets threaded through the wrapper. Flat modules composed at the workspace level avoid that indirection.
The interface must paper over real provider differences. Region names are not interchangeable: Sydney is ap-southeast-2 on AWS, australiaeast on Azure, and australia-southeast1 on GCP. Machine sizes do not line up either; AWS t3.micro, Azure Standard_B1s, and GCP e2-micro are each provider’s smallest burstable instance, but they have different vCPU and memory allocations. Bucket names are globally unique across S3 and GCP, so a name that works in one cloud may already be taken in another. Compute a name_prefix once at the root configuration and pass it to every module so resources across clouds share a consistent, collision-free naming scheme.
Version Modules, Pin Providers, and Lock Dependencies
Versioning controls which changes reach your infrastructure. Modules need the same version discipline as libraries. Use semantic versioning: bump the major version for breaking changes, the minor version for new optional variables, and the patch version for bug fixes. A major-version constraint like ~> 2.0 allows minor and patch updates but blocks the next major version. Exact pinning is safer when you consume modules you do not control: a module update that changed a default behavior once broke 20 services in production at once, because every service picked up the new default. Pin to exact versions and run automated tests before upgrading.
Git-sourced modules make versioning harder, because Terraform resolves Git references by tag or commit, and a v1.2.3 tag does not automatically act as a v1 tag. DAZN Engineering built a GitHub Action that creates or overrides the v1 and v1.2 tags whenever a v1.2.3 tag is pushed, so Terraform configurations can specify a major or minor version range for modules sourced from Git.
Providers need the same treatment. A multi-cloud project should pin provider versions in its configuration. One reference setup pins AWS provider ~> 5.0, AzureRM ~> 4.0, Google ~> 6.0, with Terraform itself at >= 1.10.0. Commit the .terraform.lock.hcl file that Terraform generates so every engineer and every CI run uses the same provider versions; without it, different team members can produce different plans from the same configuration. Treat major provider upgrades as a project, since they can change the state file format or resource schema in ways older Terraform versions cannot read.
Split State by Service and Environment
Versioning controls which changes reach your infrastructure, and state controls how Terraform tracks what it manages. Terraform’s state file is a private API. It records every resource the configuration manages, and plan and apply commands read it to compute differences. Never edit it by hand and never write code that reads it directly; use terraform import or the terraform state commands instead.
Local state files fail as soon as more than one person applies changes. Two engineers can overwrite each other’s updates, and a local file holds every secret the infrastructure uses in plaintext. A remote backend fixes both problems. On AWS, the standard setup is an S3 bucket with DynamoDB-backed locking, with versioning and encryption enabled on the bucket. Terraform can use native S3 locking with use_lockfile = true, which creates a .tflock object instead of requiring a DynamoDB table. Locking matters because without it, concurrent applies race: the second apply reads stale state, overwrites the first engineer’s changes, and can corrupt the state file. Keep state files out of Git entirely; add *.tfstate and *.tfstate.backup to .gitignore and rely on backend versioning for history.
The backend block itself does not accept variables, so each configuration copies the backend settings directly and must use a unique state key. Configurations that copy-paste backend settings risk pointing two configurations at the same state file, which is how unrelated infrastructure ends up fighting over one lock.
Separate state per environment, so production and staging never share a state file. A change intended for staging cannot accidentally touch production resources.
Terraform workspaces let one configuration maintain multiple named states, but they do not isolate infrastructure by themselves. Two workspaces can create resources with the same name that collide at the cloud provider. If you use workspaces, prefix resource names with terraform.workspace so each workspace’s resources are distinct. The S3 backend stores workspace states under an env: prefix, and Azure stores them as separate blobs, but that state-level isolation does not create provider-level isolation.
When services need each other’s resources, such as a database endpoint or a VPC ID, one configuration reads output values from another configuration’s state through a remote state data source. This keeps dependencies explicit, but cross-service dependencies can create circular reference problems when service A reads service B’s state and service B reads service A’s state. Design the dependency graph so it flows one way. Restrict who can read each state file with IAM policies that limit access to specific state paths, since state contains sensitive values in plaintext even when the backend encrypts it at rest.
Test Modules in CI and Let a Platform Team Own Them
State splitting and locking protect against concurrent damage, but they do not catch a module that is wrong in the first place. Modules are code, and they need the same CI gates as application code. At minimum, run terraform validate to catch syntax and schema errors and tflint to catch provider-specific mistakes. The stronger check is an integration test that creates real resources from the module and then destroys them, which verifies the module works against the actual cloud API rather than just parses.
Policy checking catches mistakes that validation cannot. Open Policy Agent (OPA) integrates with Terraform to enforce rules before apply, such as rejecting undersized instances, requiring backup configurations, and enforcing tagging standards. A platform team that owns the core modules and the policy rules can enforce those constraints once, instead of reviewing every service’s configuration by hand. That ownership model matters at scale: giving 50 engineers direct access to production Terraform configurations produces drift and state corruption, because there is no review point and no single owner for shared resources. Application teams consume the modules through versioned releases, so when the platform team changes a module, the versioning and CI gates protect the consumers.
Two habits to avoid are repeated -target applies and credentials in configuration. terraform apply -target=module.foo limits the operation to one module, which is useful while debugging a failure, but if you use it routinely, the state drifts, because it ends up reflecting partial applies that a full plan would change. Credentials belong in environment variables or a secrets manager, never in .tfvars files (plain-text variable definitions) committed to Git or in shell history, where they leak into logs and backups.