linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
Intro
Lessons
Footer
linuxlab-TutorialsPricingAboutPrivacy & cookies
Copyright © 2026 LinuxLab. All rights reserved.
linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
  • Introduction
  • Lessons
  • How it works
  • Knowledge base
  • Cheat sheet
  • Capstone
  • Interview prep
home/terraform/kb/Modules/tf-module-basics

kb/modules ── Modules ── intermediate

Module: a reusable piece of infrastructure

A module is any directory that contains `.tf` files and can be referenced via a `module` block. The root module is the directory where you run terraform. Child modules are the ones being called. The module contract: input variables (what it accepts), output values (what it exposes). Everything else is an implementation detail.

view as markdownaka: terraform-module, terraform-modules-intro

What a module is

Any directory that contains .tf files is a module. That has been true since day one of Terraform. When you write main.tf in an empty folder and run terraform init, you are already working with a module. It happens to be the only one, it is called the root module, and nothing else exists alongside it.

Modules become useful when you want to reuse a piece of HCL. Instead of copy-pasting ten buckets with identical tags and lifecycle rules, you write one module and call it ten times with different parameters.

Root vs child

TermWhat it is
Root moduleThe directory from which you run terraform init/plan/apply. It owns the state, the lockfile, and .terraform/.
Child moduleA directory that the root (or another child) references via a module block. It has no state of its own. Its resources live in the parent's state under the prefix module.<name>..

These are not two different artifact types. They are roles. Any module becomes the root when you run terraform from it, and a child when something else references it.

Minimal child module

File layout:

modules/s3-bucket/
├── main.tf       # the resources themselves
├── variables.tf  # what the module accepts
└── outputs.tf    # what it exposes

All three files are a convention, not a requirement. Terraform reads every .tf file in the directory and merges them into one configuration. You could put everything in a single main.tf and it would work. Don't do that.

Example (modules/s3-bucket/main.tf):

hcl
resource "aws_s3_bucket" "this" {
  bucket = var.name
  tags = merge(
    { Module = "s3-bucket" },
    var.tags,
  )
}

variables.tf:

hcl
variable "name" {
  type        = string
  description = "Bucket name. Must be globally unique."
}
variable "tags" {
  type    = map(string)
  default = {}
}

outputs.tf:

hcl
output "arn" {
  value = aws_s3_bucket.this.arn
}
output "bucket" {
  value = aws_s3_bucket.this.bucket
}

That is a complete, callable module.

Calling a module from root

In the root module:

hcl
module "logs" {
  source = "./modules/s3-bucket"
  name   = "linuxlab-logs-${random_id.s.hex}"
  tags   = { Owner = "student" }
}
resource "random_id" "s" {
  byte_length = 4
}
output "logs_arn" {
  value = module.logs.arn
}

A few things to notice:

  • source is required. It can be ./local, git::..., a registry address, an archive, or S3. See tf-module-sources.
  • The name "logs" is yours to choose within this root module. A different root can call the same module "backup". That is its business.
  • To reference a module output: module.<name>.<output_name>, not module.<name>.<resource>.<attr>. Only outputs are visible outside a module.

What ends up in state

The root module called module "logs". After apply, the state contains:

bash
$ terraform state list
module.logs.aws_s3_bucket.this
random_id.s

The resource address inside a module follows the pattern module.<name_in_parent>.<type>.<name>. If that module calls its own child, you get module.A.module.B.<type>.<name>. Nesting shows up directly in the address.

When to extract a module

Not at the second repetition. At the third or fourth.

  • Two identical blocks: copy-paste is fine. A shared local or variable will remove the duplication without needing a module.
  • Three identical blocks across different projects: worth thinking about. If they will stay identical, a module makes sense. If they are likely to diverge, hold off.
  • Four or more: unmanageable without a module. Better late than never.

Premature abstraction (one use case so far) is a common mistake. You build a "universal" abstraction for needs that do not yet exist, then the second use case turns out to need entirely different parameters and you have to rewrite the module from scratch. Copy-paste and wait.

init and modules

terraform init downloads module sources into .terraform/modules/:

.terraform/
└── modules/
    ├── modules.json
    └── logs/               # local symlink or reference to ./modules/s3-bucket

For source = "./modules/X": a symlink. For git or a registry: a real clone. If you edit the module HCL locally, changes are visible immediately (they are just files in the repository). If you change source = "git::..."?ref=v1.2.3, you need terraform init -upgrade to download the new version. See tf-init-modules.

Pitfalls

  • Modules have no state of their own. Their resources live in the parent's state. To "extract" a module into a separate state, you need state mv or a new state with terraform_remote_state. See tf-state-manipulation.

  • Providers are not configured inside a module by default. A module inherits providers from the root. If you need a different region or a different alias, pass it via providers = { aws = aws.eu }. See tf-module-composition.

  • source cannot be interpolated. source = "git::...?ref=${var.v}" does not work. Terraform reads source during init, before variables are resolved. The version must be a literal, not an expression.

  • Module outputs are evaluated on every plan. If a module contains a heavy local or a large for expression, it runs on every plan. Do not put expensive computations in outputs. Move them into locals inside the module.

  • Changing source without running init -upgrade first is dangerous. You switch a path from local to git. Terraform sees a "new" module, sees the old one is gone, and tries to destroy all old resources and recreate them. Run terraform init -upgrade first, then plan, and read carefully what it intends to do.

§ команды

bash
terraform init

Downloads module sources into .terraform/modules/ and fetches providers. Without it, no module calls will work.

bash
terraform init -upgrade

Re-fetches modules and providers. Required when you change a source or version constraint.

bash
terraform state list | grep ^module

Lists resources in state that belong to modules. Each one carries the module.<name> prefix.

bash
terraform plan -target=module.logs

Plans only the logs module. Useful for debugging. Do not use as a normal workflow.

§ см. также

  • tf-module-inputs-outputsThe module contract: input variables and outputsFrom the outside, a module is visible only through its input variables (what it accepts) and its output values (what it returns). Everything else is implementation detail. A good module hides its resources behind this contract so you can rewrite the body without touching the calls in the root module.
  • tf-module-sourcesModule sources: local, git, registry, archive, S3`source` tells Terraform where to fetch a module's code. There are five main types: a local path (`./modules/x`), a git repo (`git::https://...`), the Terraform Registry (`hashicorp/consul/aws`), an archive (`https://.../v1.0.zip`), and an S3 object (`s3::https://...`). Use local for code in your own repo, the Registry for public modules, and git for private ones.
  • tf-module-compositionModule composition: a module of modules and passing providersA module can call other modules inside its own main.tf, using the same `module` block. The address in state becomes `module.A.module.B.<type>.<name>`. Providers are inherited by default, but with several aliases (multi-region, multi-account) you pass them explicitly through `providers = { aws = aws.eu }`. `for_each` over a module works with TF 0.13+.
  • tf-init-modulesHow terraform init pulls in modulesWhen your HCL has a module block, terraform init downloads the module source (from a registry, git, or a local folder) into .terraform/modules/. This course does not write modules; this article is an overview of how the mechanics work.
Footer
linuxlab-
Copyright © 2026 LinuxLab. All rights reserved.
Tutorials
Pricing
About
Privacy & cookies