A key insight for interviews: every Terraform configuration is a module — even a plain directory of .tf files with no module blocks anywhere is technically "the root module." What people usually mean by "a module" is a reusable unit designed to be called from elsewhere.
Calling a module
module "vpc" {
source = "./modules/vpc"
cidr_block = "10.0.0.0/16"
environment = "prod"
}
Inside modules/vpc/, you'd have .tf files declaring variable blocks (cidr_block, environment) as its inputs, resources it creates internally, and output blocks exposing values like the created VPC's ID back to the caller.
Why modularize
- Encapsulation. A module hides implementation details behind a small, deliberate interface (its variables and outputs) — consumers don't need to know it internally creates a VPC, three subnets, a route table, and an internet gateway; they just pass
cidr_blockand get backvpc_id. - Reuse across environments/teams. The same
modules/vpccan be called from dev, staging, and prod root configurations with different variable values, guaranteeing structural consistency instead of three hand-written, slowly-diverging copies of near-identical VPC code. - Enforcing standards centrally. If your org requires every resource to have specific tags, or every S3 bucket to have encryption enabled, baking that into a shared module means every consumer gets it automatically — fixing an issue in one place fixes it everywhere the module is used (after consumers bump their version pin).
- Safer, smaller blast radius for changes. A well-tested, versioned module can be changed and validated once, rather than every team member independently copy-pasting and modifying raw resource blocks with slightly different mistakes.
The tradeoff
Modules add a layer of indirection — over-modularizing trivial, single-use configuration can make it harder to see what's actually being created without digging into module internals. The rule of thumb: modularize genuinely repeated patterns (a standard VPC, a standard microservice deployment), not every single resource.
Related Resources
The source argument on a module block controls where Terraform fetches the module's code from, and each source type has a different (or absent) versioning story.
Local path — no versioning
module "vpc" {
source = "./modules/vpc"
}
Always uses whatever's currently on disk at that relative path. There's no version pinning because there's nothing to pin — the code lives in the same repository/checkout. Good for tightly-coupled, same-repo modules; not suitable for sharing across repositories/teams since there's no way to "bump a version."
Terraform Registry — explicit version constraints
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
}
The public registry (registry.terraform.io) or a private registry (Terraform Cloud, or self-hosted) hosts versioned, publishable modules. The version argument uses the same constraint syntax as providers (~> 5.0 = "5.x only," >= 5.1, < 6.0, etc.), and terraform init resolves the highest version matching the constraint.
Git — pinned to a ref
module "vpc" {
source = "git::https://github.com/my-org/terraform-modules.git//vpc?ref=v1.2.0"
}
Directly references a Git repository (optionally a subdirectory via //path), pinned via ?ref= to a tag, branch, or commit SHA. Useful for private/internal modules not published to a registry. Pinning to a tag or commit SHA (not a branch like main) is important for reproducibility — a branch ref can change underneath you between two init runs.
Why pinning matters
Unpinned sources (a bare branch ref, or omitting version entirely for registry modules if that were possible) mean the exact same configuration can resolve to different module code on different days, purely based on when terraform init -upgrade happened to run — a classic source of "it worked yesterday" bugs. In shared/production code, always pin to an explicit version or immutable ref, and bump it deliberately (reviewed in a PR) rather than letting it float.
Related Resources
This question tests whether you understand how data actually flows through a tree of modules, not just that modules exist.
Root module
The root module is simply the configuration in the directory where you invoke terraform plan/apply — there's no special syntax marking it as "root"; it's root by virtue of being the entry point.
main.tf <- root module
modules/
vpc/
main.tf <- child module "vpc"
Calling a child module
# root main.tf
module "vpc" {
source = "./modules/vpc"
cidr_block = "10.0.0.0/16"
}
resource "aws_instance" "web" {
subnet_id = module.vpc.public_subnet_id # consuming the child's output
}
# modules/vpc/main.tf
variable "cidr_block" { type = string }
resource "aws_vpc" "main" {
cidr_block = var.cidr_block
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.cidr_block, 8, 0)
}
output "public_subnet_id" {
value = aws_subnet.public.id
}
How data flows
- Downward: the root passes
cidr_block = "10.0.0.0/16"into the module block; this populates the child module'svariable "cidr_block". - Upward: the child module's
output "public_subnet_id"becomes accessible in the root asmodule.vpc.public_subnet_id.
Multi-level nesting
If modules/vpc itself calls a further child module (a "grandchild" relative to root), the grandchild's outputs are only visible to its direct parent (modules/vpc) unless that parent explicitly re-declares an output block re-exposing the grandchild's value. Outputs don't automatically bubble up multiple levels — each module boundary requires an explicit output to pass a value further up the tree.
Why this matters
Understanding this one-level-at-a-time propagation is essential for designing module interfaces: if you nest modules several levels deep, you must deliberately thread outputs upward at every level, which is itself a good argument for keeping module hierarchies shallow.
Related Resources
Structuring environments correctly is one of the most consequential Terraform architecture decisions a team makes — get it wrong and every mistake in dev risks touching prod.
The common pattern: separate root modules per environment
environments/
dev/
main.tf # calls shared modules, dev-specific backend
terraform.tfvars
backend.tf # state key: "dev/..."
staging/
main.tf
terraform.tfvars
backend.tf # state key: "staging/..."
prod/
main.tf
terraform.tfvars
backend.tf # state key: "prod/..."
modules/
vpc/
ecs-service/
rds/
Each environment directory is its own root module with its own state file (via a distinct backend key or even a separate backend entirely), its own .tfvars for environment-specific values, and — critically — its own module version pins, so environments/prod/main.tf can call source = "../../modules/vpc" ; version = "2.3.0" while environments/dev/main.tf tracks "~> 2.4" to test upcoming changes safely.
Why this beats alternatives
- A single workspace-per-environment setup shares one codebase and one set of provider credentials across all workspaces, with no clean way to point different workspaces at different AWS accounts or pin different module versions per environment (see the earlier workspaces question).
- One giant configuration with
if environment == "prod"conditionals everywhere couples every environment's blast radius together — a bug in the conditional logic, or a badapply, can affect all environments from a singleterraform applyrun.
Benefits of the separate-root-module approach
- Isolated state — a broken
applyin dev cannot corrupt or lock prod's state, since they're entirely separate state files (and often separate backend buckets/accounts). - Independent module version pins — prod can stay on a proven module version while dev/staging validate the next one.
- Environment-specific access control — CI pipelines can require stronger approval gates and more restrictive IAM roles specifically for the
proddirectory. - Structural consistency without duplication — since all environments call the same shared modules, they can't structurally drift apart the way three independently hand-written configurations would.
The main cost is some duplication in the environment directories' wiring code (each main.tf still has to call the same modules with different variables) — teams often manage this with tools like Terragrunt to reduce that boilerplate further.
Related Resources
Provider aliases solve a specific problem: what if a single configuration needs to talk to the same provider (e.g., AWS) more than once, with different settings — different regions, different accounts, different credentials?
Declaring an alias
provider "aws" {
region = "us-east-1" # the "default" provider
}
provider "aws" {
alias = "eu"
region = "eu-west-1"
}
A resource then opts into the non-default provider explicitly:
resource "aws_instance" "eu_replica" {
provider = aws.eu
ami = var.ami_id_eu
instance_type = "t3.micro"
}
Common use cases: multi-region deployments (a primary region plus a DR region), cross-account resources (a shared logging account vs. the workload account), or managing resources across two different Terraform-supported platforms configured as the same provider type but distinct endpoints.
Passing aliased providers into modules
By default, a child module does not automatically inherit the root's aliased providers — each module gets its own implicit default provider configuration inherited from the caller, but extra aliases must be passed explicitly. The module must first declare that it accepts one, via configuration_aliases:
# modules/replica/main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
configuration_aliases = [aws.eu]
}
}
}
resource "aws_instance" "replica" {
provider = aws.eu
# ...
}
Then the caller wires it up explicitly:
module "replica" {
source = "./modules/replica"
providers = {
aws = aws # default
aws.eu = aws.eu # explicitly passed alias
}
}
Why the explicitness matters
This design is deliberate: implicit provider inheritance across module boundaries would make it easy to lose track of which region/account a deeply-nested resource actually targets. Requiring configuration_aliases and an explicit providers = { ... } map means anyone reading the module call can see exactly which provider configurations flow into it — critical for auditing multi-account/multi-region setups where "which account did this resource land in?" is a serious operational question, not just a style preference.