Variables, Locals, and Outputs: The API Surface of Infrastructure Modules
Most infrastructure modules fail as software interfaces before they fail as infrastructure code.
Situation
Platform teams rarely start with a module strategy. They start with a repo full of working infrastructure: a VPC here, a cluster there, a few IAM roles, a database subnet group, a CI job that runs terraform plan, and a backlog of teams asking for “the same thing, but slightly different.”
The first abstraction usually looks obvious. Wrap the repeated Terraform into a module. Move the environment-specific values into variables. Reuse it from several stacks. Publish a README. Add examples.
That works until the module becomes a shared API.
At that point, the question is no longer whether the resource graph converges. The question is whether consumers can understand, change, and trust the contract. Variables, locals, and outputs are not incidental Terraform syntax. They are the public boundary between a platform team and every workload team that depends on it.
A module with too many variables becomes a cloud console encoded in HCL. A module with too few variables becomes a ticket generator. A module with leaking outputs couples callers to internals. A module with clever locals becomes impossible to reason about during review.
Infrastructure modules need the same interface discipline as application libraries: small surface area, explicit contracts, predictable defaults, and compatibility rules.
The Problem
The failure mode is subtle because Terraform will accept many bad interfaces.
A variable can expose an implementation detail that should have stayed private. A local can hide business logic that should have been modeled as an input. An output can export an entire resource object when callers only need one identifier. None of these choices necessarily breaks terraform plan on day one.
The breakage arrives later.
One team wants to override a security group rule. Another needs a different retention period. A third copies an output into another stack and accidentally depends on a naming convention. The platform team changes an internal resource name, and a caller breaks even though the infrastructure behavior was supposed to be unchanged.
The module has stopped being an abstraction. It has become a distributed agreement with no versioned design.
The core question is: how should platform teams decide what belongs in variables, what belongs in locals, and what belongs in outputs so infrastructure modules remain reusable without becoming unbounded configuration surfaces?
Core Concept
A good infrastructure module has three distinct layers: caller intent, internal policy, and exported contract.
Variables should describe what the caller is allowed to decide. Locals should encode how the module translates that intent into provider-specific shape. Outputs should expose only what downstream systems need to compose with the result.
flowchart TD
A[caller stack — workload intent] --> B[module variables — supported decisions]
B --> C[module locals — normalization and policy]
C --> D[provider resources — implementation detail]
D --> E[module outputs — composition contract]
E --> F[downstream stacks — dependency consumers]
G[platform standards — naming and tags] --> C
H[validation rules — allowed input shape] --> B
I[versioning policy — compatibility promise] --> E
This sounds simple, but it changes the design conversation.
A variable is not “anything someone might want to change.” It is a supported decision. If you expose instance_type, you are promising that callers may choose compute shape. If you expose iam_policy_json, you are promising that callers may influence permissions directly. If you expose subnet_ids, you are saying network placement belongs outside the module.
Those may be good decisions. They should be deliberate ones.
Locals are the private implementation layer. They are excellent for derived names, merged tags, normalized maps, defaulted structures, and provider quirks. They are a poor place to bury policy that callers must understand. If a local decides whether a database is public, encrypted, or retained after deletion, that behavior needs to be visible through inputs, documentation, or strongly named defaults.
Outputs are the module’s return values. They should be boring. IDs, ARNs, DNS names, connection endpoints, and carefully shaped objects are useful. Raw resource exports are dangerous because they let consumers bind to provider details the module owner may need to change.
This internal flexibility is exactly where Terraform moved blocks become critical. When the public API surface (variables and outputs) remains stable, platform teams can use moved blocks to rename internal resources, extract sub-modules, or refactor state safely. Because the moved block natively instructs Terraform to migrate the state during the caller’s next plan, the consumer experiences zero disruption.
The clean test is this: if you changed the internal resources but preserved the intended capability, should callers need to change? If the answer is no, the relevant detail should not be part of the output contract.
In Practice
Context: Terraform’s own execution model treats variables, locals, resources, and outputs differently. Input variables receive values from the caller or environment. Locals are named expressions evaluated inside the module. Outputs are values exported from a root module or made available to a parent module. Additionally, Terraform provides moved blocks to document state-migration paths for logical resources. This behavior is documented in Terraform’s language model, not a team-specific convention.
Action: Design the module as a contract before writing the resources. Start by listing the caller decisions in plain language. Convert only those decisions into variables. Then list the invariants the platform owns: naming, tagging, encryption defaults, retention behavior, network assumptions, and observability conventions. Encode those as locals and resource arguments. Finally, list the values required for composition and expose only those as outputs. When refactoring later, write moved blocks to shift state internally without touching the public outputs.
For example, a database module might accept name, engine_version, instance_class, storage_gb, and backup_retention_days. It might keep final identifier construction, common tags, subnet group naming, parameter group defaults, and deletion protection policy inside locals. It might output endpoint, port, database_name, and security_group_id, but not the entire database instance resource.
Result: Callers get a smaller and more stable interface. Using moved blocks behind a strict output contract, the platform team can change internal naming, split resources, add tagging policy, or replace a resource implementation without forcing every consumer to run manual state migrations or edit their stack. Review also gets easier because pull requests show changes to intent rather than provider sprawl.
The documented pattern is module composition: small modules expose just enough output for other modules or root stacks to depend on them. HashiCorp’s guidance on module composition emphasizes passing selected outputs between modules rather than treating modules as global mutable objects. That pattern keeps dependency edges explicit.
Learning: Terraform modules are not only code reuse. They are governance boundaries. A reusable module should make the safe path easy while still leaving real product decisions in the caller’s hands. The harder part is deciding which choices are product decisions and which choices are platform policy.
The wrong abstraction has a recognizable smell: every new consumer adds another variable. That usually means the module is modeling provider flexibility instead of business intent. At that point, split the module, raise the abstraction, or make the policy explicit. Do not keep widening the input surface until the module is just a thin wrapper around the provider.
Where It Breaks
| Failure mode | What it looks like | Better design |
|---|---|---|
| Variable explosion | Dozens of optional inputs mirror provider arguments | Expose supported decisions and keep provider detail private |
| Hidden policy | Locals decide critical behavior with unclear names | Promote policy to explicit variables or documented defaults |
| Leaky outputs | Callers depend on raw resource objects | Export stable identifiers and shaped objects only |
| Boolean traps | Inputs like enable_advanced_mode change too much behavior | Use named modes or separate modules |
| Weak validation | Invalid combinations fail only during provider apply | Add variable validation and type constraints |
| Compatibility drift | Output names and shapes change casually | Treat outputs as versioned return values |
| Over-composition | Every module calls every other module | Compose at root stacks and pass explicit values |
The most common tradeoff is between flexibility and supportability. A platform module that exposes everything is flexible in the same way a blank AWS account is flexible. It gives callers power, but it does not reduce operational risk.
The better target is constrained flexibility. Let callers choose the workload-specific parts. Keep the operational standards close to the resources. Make exceptions visible enough that reviewers can reason about them.
What to Do Next
-
Problem: Audit one shared module and count its variables, locals, and outputs. Mark each variable as caller intent, platform policy, or provider detail. Provider detail in the variable list is usually the first place to simplify.
-
Solution: Rewrite the interface around supported decisions. Use typed objects for related inputs, validation for invalid combinations, locals for normalization, and narrow outputs for composition. Include
movedblocks alongside any structural changes to protect downstream state. -
Proof: Verify the module with at least two realistic callers. If both callers need many one-off overrides, the abstraction is probably at the wrong level. If an internal resource rename without a
movedblock would break callers, the output contract is leaking internals. -
Action: Version module interfaces like application APIs. Add new variables with defaults, deprecate old outputs before removing them, and document which inputs are product decisions versus platform-owned policy.