Skip to content

01 · Modules — Structure & Inputs/Outputs

Every .tf file you've written so far lives in the root module — the directory you run terraform commands from. A module is just any directory containing .tf files; the root module is a module too, and it can call other modules to reuse configuration instead of copy-pasting it.

Why modules exist

By Level 1's capstone you likely had one flat directory with every resource in it. That's fine for a single environment, but the moment you need the same set of resources twice (a dev VPC and a prod VPC, or ten near-identical S3 buckets for ten teams), copy-pasting .tf files means every future fix has to be repeated in every copy. A module packages a piece of infrastructure as a reusable, parameterized unit — call it twice with different inputs and get two independent copies of that infrastructure.

Anatomy of a module

A conventional module directory:

modules/
  s3-bucket/
    main.tf       # resources
    variables.tf  # input variables (the module's parameters)
    outputs.tf    # output values (what the module exposes to its caller)
    README.md     # optional, but expected in any team codebase

None of these filenames are special to Terraform — it loads and merges every .tf file in a directory regardless of name. The split into main.tf / variables.tf / outputs.tf is a strong community convention, not a language requirement, and following it makes any module immediately navigable to someone who has never seen it before.

# modules/s3-bucket/variables.tf
variable "bucket_name" {
  description = "Globally-unique S3 bucket name."
  type        = string
}

variable "enable_versioning" {
  description = "Whether to enable object versioning on the bucket."
  type        = bool
  default     = false
}
# modules/s3-bucket/main.tf
resource "aws_s3_bucket" "this" {
  bucket = var.bucket_name
}

resource "aws_s3_bucket_versioning" "this" {
  count  = var.enable_versioning ? 1 : 0
  bucket = aws_s3_bucket.this.id

  versioning_configuration {
    status = "Enabled"
  }
}
# modules/s3-bucket/outputs.tf
output "bucket_arn" {
  description = "ARN of the created bucket."
  value       = aws_s3_bucket.this.arn
}

output "bucket_id" {
  value = aws_s3_bucket.this.id
}

Inside a module, var.bucket_name and var.enable_versioning are its inputs — exactly like Level 1's root-level variables, but here they're the parameters a caller sets rather than values a human types at a plan/apply prompt. output blocks are the module's return values — the only things visible outside it.

Calling a module from the root

# root main.tf
module "reports_bucket" {
  source             = "./modules/s3-bucket"
  bucket_name        = "acme-reports-2026"
  enable_versioning  = true
}

module "logs_bucket" {
  source      = "./modules/s3-bucket"
  bucket_name = "acme-logs-2026"
  # enable_versioning omitted -> uses the module's default (false)
}

output "reports_arn" {
  value = module.reports_bucket.bucket_arn
}

Two module blocks calling the same source directory produce two completely independent resources — module.reports_bucket and module.logs_bucket are separate addresses in state, each with its own aws_s3_bucket.this underneath. The module's internal resource name (this) never collides between callers because the full state address is prefixed by the module call's own label (module.reports_bucket.aws_s3_bucket.this vs. module.logs_bucket.aws_s3_bucket.this).

source = "./modules/s3-bucket" is a local path — the simplest kind of source. Module 02 covers versioned sources (registry modules, Git tags) for modules you don't own or want to pin to a specific release.

Worked example: two environments, one module

module "dev_bucket" {
  source      = "./modules/s3-bucket"
  bucket_name = "acme-dev-artifacts"
}

module "prod_bucket" {
  source            = "./modules/s3-bucket"
  bucket_name       = "acme-prod-artifacts"
  enable_versioning = true
}

Eighteen lines define two environments' storage, each independently configurable, with the actual bucket logic (and any future fix to it) living in exactly one place: modules/s3-bucket/main.tf.

How It Actually Works

A module call is a graph subgraph, not a function call. When Terraform Core parses configuration, each module block causes it to load the target directory's .tf files and instantiate every resource inside them as nodes in the same overall dependency graph the root module builds — just namespaced under module.<name>.. There is no separate execution pass per module and no runtime call stack; "calling" a module is a compile-time expansion that produces more graph nodes, all planned and applied by the same single graph walk described in module 09 of Level 1. This is why a module's resources can depend on — and be depended on by — resources anywhere else in the configuration: aws_s3_bucket_versioning inside the module depends on aws_s3_bucket.this in the same module instance via the implicit reference, and the root's module.reports_bucket.bucket_arn output reference creates a graph edge from whatever root-level resource uses it straight into that specific module instance's aws_s3_bucket.this, without either side needing to know the other is "in a module."

Inputs and outputs are the only permitted edges across a module boundary. A resource inside modules/s3-bucket cannot reference var.some_root_variable unless that value is explicitly passed in as one of this module's own variable blocks — modules do not inherit the caller's variable namespace. This isn't a convenience restriction; it's what makes a module portable at all. Terraform enforces it by giving each module instance its own separate variable/local evaluation scope during graph construction, so var.bucket_name inside the module and any same-named var.bucket_name in the root are simply unrelated symbols that happen to share a name.

State addresses encode the call, not the source. terraform state list after applying the example above prints module.dev_bucket.aws_s3_bucket.this and module.prod_bucket.aws_s3_bucket.this — the address is built from the module block's label (dev_bucket), never from the source path or the module's own internal resource name alone. That's precisely how two calls to the identical ./modules/s3-bucket source end up as two distinct, independently-tracked resources instead of colliding: uniqueness comes from the call site, not the module definition.

Exercise

Take the local_file resource from Level 1 module 08's exercise and extract it into a module at ./modules/local-file with two variables (filename and content) and one output (the file's full path via local_file.this.filename). Then call that module twice from a root main.tf with two different filenames, and write out what you'd expect terraform state list to print for both instances — including the full address prefix.