Pass Variables Between Modules
Learn how to pass variables between modules in Terraform — a clear guide with examples to connect your infrastructure modules.
Focus: pass variables between modules
You've started structuring your Terraform configuration into modules — great. But now you're facing the real challenge: your VPC module needs to tell the subnet module its CIDR range, and the database module needs the VPC ID. Copy-pasting values is fragile, hardcoding is worse, and before you know it, your modules are brittle islands. This lesson shows you exactly how to pass variables between modules cleanly and predictably, so your infrastructure stays composable and your code stays DRY.
The problem this lesson solves
Modules are the building blocks of Terraform, but they don't magically share data. When you create a vpc module and a subnet module, Terraform treats them as separate universes unless you explicitly wire them together. If you hardcode the VPC ID inside the subnet module, you break reusability — change the VPC and you must hunt down every reference. If you copy-paste outputs, you create drift and confusion.
This lesson solves the communication problem between modules: how to pass values down (as input variables) and up (as outputs), so they read like a clear data flow instead of a spaghetti of hardcoded strings.
Core concept / mental model
Think of modules as functions in a programming language. A function takes arguments, does work, and returns values. Terraform modules work the same way:
- Input variables act as parameters — they let the caller pass values into the module.
- Outputs act as return values — they expose selected attributes back to the caller.
When you pass variables between modules, you connect the output of one module to the input of another. In Terraform code, that looks like this:
module "vpc" {
source = "./modules/vpc"
name = "prod"
}
module "subnet" {
source = "./modules/subnet"
vpc_id = module.vpc.vpc_id # pass output from vpc module
cidr_block = "10.0.1.0/24"
}
The dotted reference module.vpc.vpc_id is the magic glue. It reads the vpc_id output from the vpc module and feeds it into the subnet module's input variable.
The contract between modules
For this to work, three conditions must hold:
- The source module must define an output for the value you want (e.g.,
output "vpc_id" { value = aws_vpc.this.id }). - The destination module must declare an input variable to receive it (e.g.,
variable "vpc_id" { type = string }). - The root module must reference
module.<source_name>.<output_name>.
If any link is missing, Terraform raises an error — and that's actually a good thing. It forces you to define explicit contracts between your modules, making your infrastructure self-documenting.
How it works step by step
-
Define the output in the source module. Inside
modules/vpc/outputs.tf, add:hcl output "vpc_id" { value = aws_vpc.main.id }Only outputs are visible outside the module — private resources are not. -
Declare the input variable in the destination module. Inside
modules/subnet/variables.tf, add:hcl variable "vpc_id" { type = string description = "The VPC ID where the subnet will be created" } -
Reference the output in the root configuration. In
main.tf, usemodule.vpc.vpc_idas the value forvpc_idin the subnet module block. -
Run
terraform init(if new modules are added) andterraform planto verify the values flow correctly. The plan output will show the resolved values — a great way to confirm your wiring before applying. -
Apply and, if you like, use
terraform outputto retrieve the final values.
Outputs with sensitive values
If the output contains a secret (like a database password), mark it as sensitive = true in the output definition. Terraform will avoid printing it in plan/apply output, but you can still pass it to another module.
Hands-on walkthrough
Let's build a complete example with two modules: vpc and subnet. We'll pass variables between them using outputs.
1. Directory structure and VPC module
terraform-demo/
├── main.tf
├── variables.tf
└── modules/
├── vpc/
│ ├── main.tf
│ └── outputs.tf
└── subnet/
├── main.tf
└── variables.tf
modules/vpc/main.tf
resource "aws_vpc" "main" {
cidr_block = var.cidr_block
}
modules/vpc/variables.tf (create this too)
variable "cidr_block" {
type = string
}
modules/vpc/outputs.tf
output "vpc_id" {
value = aws_vpc.main.id
}
output "vpc_cidr" {
value = aws_vpc.main.cidr_block
}
2. Subnet module
modules/subnet/variables.tf
variable "vpc_id" {
type = string
description = "The VPC ID from the VPC module"
}
variable "cidr_block" {
type = string
}
modules/subnet/main.tf
resource "aws_subnet" "main" {
vpc_id = var.vpc_id
cidr_block = var.cidr_block
}
3. Root configuration
main.tf
provider "aws" {
region = "us-east-1"
}
module "vpc" {
source = "./modules/vpc"
cidr_block = "10.0.0.0/16"
}
module "subnet" {
source = "./modules/subnet"
vpc_id = module.vpc.vpc_id
cidr_block = "10.0.1.0/24"
}
Running the example
From the root directory, run:
export AWS_PROFILE=your-profile # optional
export TF_VAR_region=us-east-1 # if you use variables
terraform init
terraform plan
The plan output should show the subnet being created inside the VPC. Notice how the vpc_id is passed from the module output to the subnet's input.
Adding a database module that depends on both
Now extend the pattern: create a db module that uses the VPC ID and subnet ID.
modules/db/main.tf (simplified)
resource "aws_db_subnet_group" "main" {
name = "main"
subnet_ids = [var.subnet_id]
}
resource "aws_security_group" "db" {
vpc_id = var.vpc_id
}
modules/db/variables.tf
variable "vpc_id" {
type = string
}
variable "subnet_id" {
type = string
}
Root main.tf (fragment)
module "db" {
source = "./modules/db"
vpc_id = module.vpc.vpc_id
subnet_id = module.subnet.subnet_id # need to add this output
}
...and you must add output "subnet_id" inside modules/subnet/outputs.tf:
output "subnet_id" {
value = aws_subnet.main.id
}
Now you see the dependency graph: db waits on subnet, which waits on vpc. Terraform builds this graph automatically from your references.
Compare options / when to choose what
There are several ways to share data between modules, but not all are equal. Here's a quick comparison:
| Method | Pros | Cons | Best when |
|---|---|---|---|
| Outputs to input variables | Explicit, type-checked, creates dependency graph | Must reference outputs; extra boilerplate | Always the default for passing values up and down |
| Outputs only (no inputs) | Simple for validation or display | Doesn't move data between modules | When you just need to view results in CLI |
| Local module variables | Convenient inside a module | Not visible outside; doesn't solve cross-module | Reusable expressions within one module |
Remote state data sources (e.g., data "terraform_remote_state") |
Works even when modules are in different configurations/workspaces | Adds coupling to remote state; slower plan; requires state storage | When modules are used across independent configs or terraform applies |
Pro tip: Prefer output-to-input wiring over remote state when both modules live in the same root configuration. It's simpler, faster, and the dependency graph is visible at plan time.
Variations to consider
- Use
countorfor_eachwith modules to pass a map of values to multiple module instances. - Use
tfvarsfiles to set input variable values externally, then pass those into modules — good for environment-specific config. - For more advanced scenarios, use
providersmeta-argument to pass provider configurations, anddepends_onto fine-tune ordering when necessary.
Troubleshooting & edge cases
-
"Unsupported attribute" error: You forgot to define the output in the source module, or the output name is misspelled. Check
outputs.tf. -
"Missing required variable" error: The destination module expects a variable that you didn't pass. Ensure you declare
variableand reference it correctly. -
Circular dependency: Module A uses output from B, and B uses output from A. Terraform returns a dependency cycle error. Break the cycle by removing one reference or restructuring.
-
Sensitive values leaking: If a sensitive output is passed to a module that logs it, information leaks. Use
sensitive = trueon the output, but remember it only hides display — the value still flows internally. -
Using a value before it exists: If a module creates a resource asynchronously (like an RDS instance), and another module tries to use its endpoint immediately, Terraform handles the dependency automatically. But if you manually set
depends_on, you might create an unnecessary wait. Usually you don't need it. -
Wrong provider region: If modules use different providers, ensure you pass the correct provider instance. Use the
providersblock or configure a default provider alias.
What you learned & what's next
You now know how to pass variables between modules using outputs as the return channel and inputs as the parameter channel. You built a multi-module setup where a VPC module feeds its ID into a subnet module, and that subnet ID into a database module. You also learned when to prefer direct references over remote state, and how to troubleshoot common wiring errors.
This is the backbone of composable Terraform — you can mix and match modules like LEGOs. Next up, we'll look at module composition and how to structure larger projects — where you'll apply these same patterns at scale, including how to share variables across many modules cleanly.
Keep practicing: try adding a third module that receives both vpc_id and subnet_id and verify the dependency graph with terraform graph | dot -Tpng > graph.png (install graphviz) to see the connections visually.
Practice recap
Create a small Terraform project with two modules: a VPC and a subnet. Pass the VPC ID from the VPC module to the subnet module using outputs. Run terraform plan and confirm the subnet is placed in the correct VPC. Then add a third module that uses both the VPC ID and subnet ID, and observe the dependency graph with terraform graph.
Common mistakes
- Forgetting to define an output in the source module — Terraform will error with 'Unsupported attribute'.
- Not declaring an input variable in the destination module — plan fails with 'Missing required variable'.
- Hardcoding a value like a VPC ID in a module instead of passing it as a variable — breaks reusability.
- Using remote state when both modules are in the same configuration — adds unnecessary latency and coupling.
- Creating circular dependencies — Terraform will throw a cycle error; refactor to break the loop.
Variations
- Use output values directly in the root module without passing them into another module — for display or conditional logic.
- Leverage remote state data sources (e.g.,
data "terraform_remote_state") to pass outputs between separate Terraform configurations. - Pass entire objects or maps as variables to reduce the number of input arguments — keep the module interface clean.
Real-world use cases
- Create a VPC module and pass its ID to security group and subnet modules, ensuring all resources are in the same network.
- Pass a database endpoint output to an application module to configure its connection string automatically.
- When using modules across different repositories, use remote state outputs to share a common network or IAM role.
Key takeaways
- Modules communicate via input variables (parameters) and outputs (return values).
- Reference a module's output with
module.<name>.<output_name>to pass data to another module. - Always define outputs in the source module and variables in the destination module to maintain a clear contract.
- Output-to-input wiring creates an implicit dependency graph — Terraform plans and applies in the right order.
- Prefer direct references over remote state when modules live in the same root configuration.
- Troubleshooting involves checking output definitions, variable declarations, and avoiding circular references.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.