A caller of your Terraform module needs the ID of a subnet the module creates, but the module declares no output for it. Why can't the caller reference the resource directly, and what is the correct fix?
answer
- only module.<name>.<output> resolves
- internals are not addressable, by design
- add the output, do not reach in
- adding is safe, renaming is not
- grandchild values need re-exporting
basics
~20 sResources inside a child module are not addressable from outside it: module.<name> resolves only declared outputs, so module.network.aws_subnet.private is an error. The fix is to add an output to the module — its outputs are its public API, and adding one is a backward-compatible change.
solid answer
~50 sTerraform deliberately encapsulates a child module. From the caller, `module.<name>.<output_name>` is the only address that resolves; there is no path to the resources inside, so `module.network.aws_subnet.private[0].id` fails to parse as a valid reference. The fix is to declare the value as an `output` in the module and reference `module.network.private_subnet_ids`. Resist the workarounds people reach for instead — hard-coding the ID, adding a `data` source in the root that re-looks-up something the module just created, or reading the module's resources out of state. Each one couples the caller to an implementation detail that the module author is free to change. The design lesson is that `variables.tf` and `outputs.tf` are the contract: adding an output is additive and safe, while renaming one, removing one, or quietly changing what it means breaks every caller. Choose outputs that are *stable identities* — IDs, ARNs, names, endpoints — rather than dumping whole resource objects that expose internals you will regret.
code
hcl · 16 lines# modules/network/outputs.tf — the module's public API
output "private_subnet_ids" {
description = "IDs of the private subnets, one per availability zone"
value = [for s in aws_subnet.private : s.id]
}
# root main.tf
module "network" {
source = "./modules/network"
}
resource "aws_db_subnet_group" "this" {
name = "app"
# module.network.aws_subnet.private[0].id would NOT resolve
subnet_ids = module.network.private_subnet_ids
}go deeper
Remember that values leave a module only through its output blocks, referenced as module.<name>.<output>, and that the fix for a missing value is to add an output rather than to hard-code an ID.
Explain that encapsulation is enforced by the language — internal resource addresses simply do not resolve for a caller — and be able to critique the usual workarounds: literal IDs, re-lookup data sources, and reading another module's state.
Show contract discipline: which outputs you would publish, why exporting whole resource objects traps you, and why silently changing an existing output's meaning is more dangerous than removing it outright.
Own the policy across teams — who may change a shared module's interface, how additive-only evolution is enforced in review, and how consumers are given a migration path when an output genuinely must change.
## Encapsulation is the point Terraform's module system gives a child module exactly two openings: `variable` blocks for values coming in, and `output` blocks for values going out. From outside, the only expression form that resolves is: ``` module.<module_name>.<output_name> ``` There is no `module.network.aws_subnet.private[0].id`. It is not that Terraform cannot see the resource — it can, it is right there in the plan and in state as `module.network.aws_subnet.private[0]` — it is that the *configuration language* refuses to let you depend on it. Encapsulation is enforced, not advised. That is a deliberate design. If callers could reach any resource inside a module, then every resource, every attribute and every `for_each` key would be part of the module's public surface, and no author could ever refactor. With outputs, the author decides what is promised. ```hcl # modules/network/outputs.tf output "private_subnet_ids" { description = "IDs of the private subnets, one per availability zone" value = [for s in aws_subnet.private : s.id] } ``` ```hcl # root resource "aws_db_subnet_group" "this" { name = "app" subnet_ids = module.network.private_subnet_ids } ``` ## The workarounds, and why each is worse When the output is missing, people reach for one of these: **Hard-code the value.** Paste the subnet ID as a literal string. It works until the subnet is replaced, and it silently makes the configuration environment-specific — the same code will point at production's subnet from staging. **Add a data source in the root.** Look the subnet back up by tag or name filter. Now there is a hidden coupling to the module's tagging scheme, and a dependency Terraform cannot order correctly: the data source may read before the resource exists on the first apply, or return a stale value. **Read it out of state.** Parse `terraform show -json`, or point a `terraform_remote_state` data source at the state and dig into the module's internal resources. This makes the internal layout of another team's module into a contract they never agreed to, and it breaks the moment they rename a resource. **Fork the module.** The heaviest cost of all, for one missing line. The correct fix is a one-line pull request against the module. Adding an output is *additive*: no existing caller changes behaviour, no plan diff appears for anyone. This is the cheapest kind of interface change there is, and a module maintainer should accept them readily. ## Outputs propagate one level at a time Encapsulation applies at every level, so a value from a grandchild module is not automatically visible to the grandparent. If `modules/platform` calls `modules/network`, and the root calls `modules/platform`, then to expose the VPC ID the root must see, `platform` has to re-export it: ```hcl # modules/platform/outputs.tf output "vpc_id" { value = module.network.vpc_id } ``` This pass-through plumbing is real work at each layer and is one of the concrete costs of a deep module tree. ## Designing the output surface A few rules of thumb separate a module that ages well from one that becomes a liability: - **Export stable identities, not implementation shapes.** `vpc_id`, `cluster_endpoint`, `role_arn`, `security_group_id`. These are the values other configuration genuinely needs. - **Be careful exporting whole objects.** `output "bucket" { value = aws_s3_bucket.this }` is convenient and then permanent: every attribute of that resource becomes something a caller may be relying on, and you will not know which. - **Give every output a `description`.** It is what documentation generators publish and what a consumer reads first. - **Name for meaning, not for the resource.** `private_subnet_ids` survives a refactor from `aws_subnet` to a different construction; `aws_subnet_private_ids` advertises the implementation. - **Prefer plural collections with predictable shapes.** A map keyed by a meaningful key (availability zone, environment name) is far easier for a caller to consume than a list whose order depends on internal iteration. ## Changing an existing output Adding is safe; the other three operations are not. Removing an output breaks any caller referencing it, with an immediate configuration error — loud, but breaking. Renaming is removal plus addition. The dangerous one is *changing the meaning or type* of an existing output — switching a list to a map, or making `subnet_ids` return public subnets where it used to return private ones. That produces no error at all, just a plan that quietly rewires production. Treat the set of outputs, their names, their types and their semantics as the thing consumers depend on, because it is.
- Is adding a new output to a widely used module a breaking change?No — it is purely additive. Existing callers reference outputs by name, so a new one changes nothing for them and produces no plan diff. Removing an output, renaming it, or changing its type or meaning are the breaking operations, and the last of those is the most dangerous because it errors nowhere and silently rewires callers.
- Why is exporting an entire resource object as an output risky?Because every attribute of that object becomes part of your contract, and you cannot tell which ones callers use. Any future refactor — a different resource type, a wrapper, a computed value — becomes a breaking change. Exporting the two or three identities callers actually need keeps your freedom to change the implementation.
- How do you expose a value from a module that is nested two levels deep?Each level must re-export it: the intermediate module declares an output whose value is `module.<child>.<output>`, and the root then reads the intermediate's output. Encapsulation applies at every level, so there is no skipping. This pass-through plumbing is one of the concrete costs of nesting modules deeply.
saying these in an interview costs you the question
- Tries to reference module.name.aws_instance.foo from the caller
- Adds a data source to re-look-up what the module just made
- Hard-codes the ID rather than adding an output
- Forks the module instead of sending a one-line PR
- Thinks renaming an output is a harmless tidy-up