| Tested on | Ubuntu 26.04 LTS (Resolute Raccoon) |
|---|---|
| Package | terraform 1.15.8-1hashicorp/local 2.6.1 (providers demo) |
| Applies to | Any host with Terraform installed |
| Lab environment | Single Ubuntu VM with Terraform — Terraform lab environment on Ubuntu |
| Privilege | Normal user |
| Scope | Module data flow — child input variables, module arguments, variable scope, child outputs, module.NAME.OUTPUT references, chaining outputs into another module input, implicit dependencies, providers meta-argument mapping, brief sensitive propagation, and interface design. Does not cover root variable precedence, tfvars, variable type theory, output CLI depth, provider alias tutorials, Registry discovery, module source or version mechanics, or module publishing. |
| Related guides | Terraform modules Terraform variables Terraform output values Terraform sensitive data Terraform Associate certification course |
The Terraform modules lesson showed how to create a local child module and call it from the root. This lesson answers the next question: how does data move between modules?
Root variable
↓
module argument
↓
Child input variable
↓
Child resources
↓
Child output
↓
module.NAME.OUTPUT
↓
Root / another moduleYou build that chain with two child modules — application and monitoring — under ~/terraform-labs/terraform-module-input-output/. The walkthrough runs in main/; error demos live in sibling errors/ directories.
terraform_data from the built-in provider. The providers section adds a short hashicorp/local demo in providers-demo/.
How module inputs and outputs connect
A module boundary is an interface. The parent passes inputs as arguments on the module block; the child exposes outputs the parent reads with module.<label>.<output>.
| Direction | Mechanism | Example |
|---|---|---|
| Parent → child | Module block argument maps to child variable |
name = var.application_name |
| Child → parent | Child output read by parent |
module.application.application_name |
| Child → child | One module output wired into another module argument | application_name = module.application.application_name |
Child modules do not automatically inherit arbitrary Terraform variables from the root. Values must cross the boundary through declared inputs and outputs.
Pass input values to a child module
Create the shared application module under modules/application/. Declare the input in variables.tf:
variable "name" {
type = string
}
variable "environment" {
type = string
default = "dev"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "The environment value must be dev, staging, or prod."
}
}The name variable has no default, so callers must supply it. environment is optional with a Terraform validation checks rule scoped to that variable.
In main/variables.tf, declare a root variable:
variable "application_name" {
type = string
default = "payments-api"
}Wire the root into the child in main/main.tf:
module "application" {
source = "../modules/application"
name = var.application_name
environment = "prod"
}var.application_name exists only in the root module namespace. The name = argument is what the child receives as var.name. Without that mapping, the child cannot see the root variable.
Initialize from main/:
cd ~/terraform-labs/terraform-module-input-output/main && terraform initInitializing modules...
- application in ../modules/application
- monitoring in ../modules/monitoring
Terraform has been successfully initialized!Read outputs from a child module
Expose values from modules/application/outputs.tf:
output "application_name" {
value = terraform_data.application.output
}
output "environment" {
value = var.environment
}Outputs are the child module's public API. Callers should use these names — not internal resource attributes.
Read the output in the root module:
output "application_name" {
value = module.application.application_name
}Plan to confirm the chain resolves:
terraform plan# module.application.terraform_data.application will be created
+ resource "terraform_data" "application" {
+ input = "payments-api"
...
}
Plan: 2 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ application_name = (known after apply)Apply the plan, then query the output by name:
terraform apply -auto-approveThe apply transcript ends with application_name = "payments-api". Confirm with the output subcommand:
terraform output application_name"payments-api"The root printed payments-api because module.application.application_name forwarded the child output after apply.
Pass values between Terraform modules
Chaining modules is where interfaces earn their keep. Add modules/monitoring/ that accepts an application name and stores a monitor record:
variable "application_name" {
type = string
}resource "terraform_data" "monitor" {
input = "monitoring:${var.application_name}"
}output "monitor_id" {
value = terraform_data.monitor.id
}Wire the application output into the monitoring input in main/main.tf:
module "monitoring" {
source = "../modules/monitoring"
application_name = module.application.application_name
}
output "monitor_id" {
value = module.monitoring.monitor_id
}module.application
│
│ output application_name
▼
module.monitoring
│ input application_name
▼
monitoring:payments-apiThat reference creates an implicit dependency between the module instances. Terraform knows that module.monitoring depends on the value exported by module.application and orders the relevant operations accordingly — no explicit depends_on is required.
After apply, verify both modules contributed:
terraform outputapplication_name = "payments-api"
monitor_id = "876c4535-3584-3cb6-39e7-695421ca4ae2"The monitoring resource received monitoring:payments-api as its input.
Design clear module inputs and outputs
Module quality is mostly interface design. Inputs should be easy to supply; outputs should expose what callers need — nothing more.
| Input practice | Why |
|---|---|
Meaningful names (application_name, not n) |
Callers understand what to pass without reading main.tf |
| Type constraints | Catch wrong shapes at plan time |
description |
Documents intent in Registry and IDE tooling |
Sensible default for optional settings |
Reduces boilerplate at call sites |
validation on the same variable |
Enforces allowed values at the boundary |
| Output practice | Why |
|---|---|
| Expose stable identifiers and endpoints | Callers compose modules without knowing internal resources |
| Avoid leaking every resource attribute | Implementation can change without breaking parents |
| Name outputs for consumer tasks | application_name beats terraform_data_result |
Poor interface:
# modules/app/outputs.tf — avoid
output "data" {
value = terraform_data.application
}Improved interface:
output "application_name" {
value = terraform_data.application.output
}
output "environment" {
value = var.environment
}The improved version exposes plain strings parents can pass onward. Exporting the whole resource object couples callers to internal structure.
Pass providers to child modules
Provider behavior splits into two ideas:
- Provider configuration — credentials, region, endpoints, aliases (defined with
providerblocks, usually in the root) - Provider requirements —
required_providersversion constraints (each reusable child module declares its own)
Child modules inherit default provider configurations from the parent automatically. When the child needs a non-default configuration — typically an alias — pass it with the providers meta-argument. Provider configurations can cross module boundaries; provider source and version requirements are not inherited — each reusable child declares its own required_providers.
The providers-demo/ directory uses hashicorp/local with a secondary alias. Root providers.tf:
provider "local" {}
provider "local" {
alias = "secondary"
}The child module declares what plugin it needs in modules/file-writer/terraform.tf:
terraform {
required_providers {
local = {
source = "hashicorp/local"
version = "2.6.1"
}
}
}The child's resource uses the default local provider name inside the module — the parent decides which configuration satisfies it:
variable "content" {
type = string
}
resource "local_file" "written" {
filename = "${path.root}/secondary.txt"
content = var.content
}Map the parent's aliased configuration into the child's default local slot in root main.tf:
module "via_secondary" {
source = "./modules/file-writer"
providers = {
local = local.secondary
}
content = "written through secondary provider alias"
}The providers map passes configuration from the caller. The child's required_providers block declares requirements. You do not need configuration_aliases here — that meta-argument is for children that expect aliased provider names such as local.secondary inside the module. This mapping connects the parent's local.secondary to the child's ordinary local provider name.
Plan from providers-demo/:
cd ~/terraform-labs/terraform-module-input-output/providers-demo && terraform initAfter init, plan to see one file on the default provider and one through the mapped alias:
terraform plan# local_file.root_default will be created
+ resource "local_file" "root_default" { ... }
# module.via_secondary.local_file.written will be created
+ resource "local_file" "written" { ... }
Plan: 2 to add, 0 to change, 0 to destroy.The root file uses the default local provider; the child file is created through local.secondary because the parent mapped that configuration into the child's local slot. Full alias patterns live in the Terraform providers lesson.
provider configuration blocks inside reusable child modules. Configure providers in the root and pass aliases through providers when needed. Reusable children still declare required_providers for the plugins they need.
Sensitive values across module boundaries
sensitive on a variable or output controls redaction in normal CLI views — it does not remove values from state. Mark a root variable sensitive in main/sensitive.tf:
variable "api_token" {
type = string
sensitive = true
default = "demo-token-not-real"
}
output "api_token_echo" {
value = var.api_token
sensitive = true
}Plan and apply redact the value in normal listings — look for (sensitive value) under Changes to Outputs during plan, and <sensitive> in the apply summary:
cd ~/terraform-labs/terraform-module-input-output/main && terraform apply -auto-approveChanges to Outputs:
+ api_token_echo = (sensitive value)
...
api_token_echo = <sensitive>
application_name = "payments-api"sensitive propagates through module arguments when you pass a sensitive value into a child input. For persistence limits, state exposure, and ephemeral alternatives, see Terraform sensitive data.
Common module interface problems
| Symptom | Likely cause | Fix |
|---|---|---|
The argument "name" is required, but no definition was found |
Missing required module argument | Add the argument on the module block: name = var.application_name |
An argument named "foo" is not expected here |
Typo or argument not declared in child | Match argument names to child variable blocks; remove unsupported keys |
This object does not have an attribute named "does_not_exist" |
Wrong output name on module.NAME |
Use an output declared in the child; check spelling |
Reference to undeclared input variable inside child |
Child references var.x never declared in that module |
Declare variable "x" in the child, then pass x = ... from the parent |
Child tries to read root var.application_name directly |
Variable scope stops at module boundary | Pass application_name = var.application_name as a module argument |
| Provider errors about missing configuration | Child needs an alias the root did not pass | Add providers = { local = local.secondary } (or the relevant map) |
| Callers depend on internal resource addresses | Over-exposed outputs or direct resource references | Publish stable outputs; hide implementation details |
Missing required module argument
Remove name from the module "application" block in errors/missing-input/ and plan:
cd ~/terraform-labs/terraform-module-input-output/errors/missing-input && terraform planError: Missing required argument
on main.tf line 1, in module "application":
1: module "application" {
The argument "name" is required, but no definition was found.Invalid output reference
Reference a nonexistent output in errors/invalid-output/:
cd ~/terraform-labs/terraform-module-input-output/errors/invalid-output && terraform planError: Unsupported attribute
on main.tf line 9, in output "bad":
9: value = module.application.does_not_exist
├────────────────
│ module.application is object with 2 attributes
This object does not have an attribute named "does_not_exist".Terraform lists how many outputs the module exposes — use one of those names.
When you finish testing in main/, remove lab resources:
cd ~/terraform-labs/terraform-module-input-output/main && terraform destroy -auto-approveDestroy complete! Resources: 2 destroyed.References
- Modules overview — HashiCorp Terraform language docs
- Module blocks — inputs, outputs, and
providers - Provider configuration — default inheritance and aliases
Summary
Module boundaries are contracts. The root passes inputs as module block arguments; each child declares matching variable blocks. Results flow back through output blocks and module.NAME.OUTPUT references. When module.monitoring takes application_name = module.application.application_name, Terraform records an implicit dependency between the module instances and respects it during evaluation and apply — no explicit depends_on required.
Design interfaces deliberately — typed inputs, focused outputs, validation at the boundary. Provider configurations stay in the root; each child still declares required_providers. Pass non-default configurations through providers when a child needs an alias. sensitive redacts normal CLI listings but does not make values disappear from state.
If you have not built a local module yet, start with Terraform modules. Next, learn Registry addresses and version pinning in the module source lesson, or deepen variable and output syntax in the dedicated variables and output chapters when you need root-level detail.

