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/lessons/tf-intermediate-01-first-module

lesson ── terraform-intermediate ── ~18 мин ── 7 шагов

Your first module: move S3 into something reusable

The twelve beginner lessons kept a single S3 bucket in main.tf. That works while you have few buckets and they are all different. The moment you need three "identical" buckets with versioning, tags, and a lifecycle, copy and paste starts to bite. The fix is a module.

In this lesson you move the S3 bucket into ./modules/audited-bucket/, describe its contract (variables and outputs), and call the module from the root. This is your first reusable unit, the foundation of the intermediate track.

▶ интерактивный sandbox

Поднимется пара контейнеров: terraform 1.9 и localstack 3.8 в одной сети. В браузере откроется терминал, можно сразу terraform init. Каждый шаг проверяется автоматически. TTL 45 минут, без регистрации.

запустить sandbox →

stack ── terraform · localstack · 1 GB RAM · самоуничтожается через 45 мин простоя

Шаги

  1. 01

    Set up the module skeleton

    Create the file structure:

    bash
    cd /home/student/tf-module
    mkdir -p modules/audited-bucket
    touch modules/audited-bucket/{main.tf,variables.tf,outputs.tf}
    ls -R modules/

    You should get:

    modules/
    └── audited-bucket/
        ├── main.tf
        ├── outputs.tf
        └── variables.tf

    Three files, by convention. Terraform reads every .tf in a directory and joins them, so where you put what does not matter. But the split into main + variables + outputs is the standard, so get used to it.

    подсказка

    You can do it in one command: `mkdir -p modules/audited-bucket && cd $_ && touch main.tf variables.tf outputs.tf`.

    ✓ Skeleton ready. Now the module's contract.

  2. 02

    Describe the input variables

    A module's contract is what it accepts. In modules/audited-bucket/variables.tf:

    hcl
    variable "name" {
      type        = string
      description = "S3 bucket name. Must be globally unique."
      validation {
        condition     = length(var.name) >= 3 && length(var.name) <= 63
        error_message = "Bucket name must be 3-63 characters."
      }
    }
    variable "versioning_enabled" {
      type        = bool
      description = "Enable versioning. Usually true for prod buckets."
      default     = false
    }
    variable "tags" {
      type        = map(string)
      description = "Extra tags. The module adds its own on top."
      default     = {}
    }

    Notice: name has no default, so it is required. The others have a default, so they are optional. That is the "contract": what the user must pass and what they may leave out. See tf-module-inputs-outputs.

    подсказка

    If no editor is available inside the sandbox: `cat > FILE <<'EOF' ... EOF`.

    ✓ Contract described. Now the resources inside the module.

  3. 03

    Describe the resources inside the module

    In modules/audited-bucket/main.tf:

    hcl
    resource "aws_s3_bucket" "this" {
      bucket = var.name
      tags = merge(
        {
          Module = "audited-bucket"
        },
        var.tags,
      )
    }
    resource "aws_s3_bucket_versioning" "this" {
      bucket = aws_s3_bucket.this.id
      versioning_configuration {
        status = var.versioning_enabled ? "Enabled" : "Suspended"
      }
    }

    Pay attention:

    • The resource name is this. This is a module idiom: "the main resource of this module." From outside you still see module.<name>.aws_s3_bucket.this.
    • merge() joins the mandatory Module tag with the user's tags.
    • Versioning is a separate resource as of TF 4.x+, not a subblock.

    ✓ Resources written. What is left: what the module exposes to the outside.

  4. 04

    Describe the outputs

    In modules/audited-bucket/outputs.tf:

    hcl
    output "arn" {
      value       = aws_s3_bucket.this.arn
      description = "Bucket ARN. Used for IAM policies."
    }
    output "bucket" {
      value       = aws_s3_bucket.this.bucket
      description = "Bucket name (as created)."
    }
    output "versioning_status" {
      value = aws_s3_bucket_versioning.this.versioning_configuration[0].status
    }

    This is everything the root module will see. No aws_s3_bucket_versioning.this.bucket, only what is declared as an output.

    ✓ The contract is closed. Now call it from the root.

  5. 05

    Call the module from the root

    Create main.tf in /home/student/tf-module/:

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

    The key points:

    • source = "./modules/audited-bucket" is relative to the .tf file the block is written in. See tf-module-sources.
    • module.logs.arn: from outside, the module is seen through its outputs.
    • The root can still have its own resources: random_id lives in the root, not in the module.

    ✓ The call is ready. Now init and apply.

  6. 06

    init and apply: the module lands in state

    bash
    cd /home/student/tf-module
    terraform init

    In the output:

    Initializing modules...
    - logs in modules/audited-bucket

    This is a symlink in .terraform/modules/logs/ pointing at your modules/audited-bucket/. See tf-init-modules.

    bash
    terraform apply -auto-approve

    Two resources should be created (bucket and versioning) inside the module, plus one random_id in the root.

    ✓ Apply went through. The plan is clean, so state and HCL agree.

    The same on OpenTofu

    OpenTofu keeps the CLI and state compatible with Terraform for the commands in this step: migration usually goes through mv .terraform .terraform.bak; tofu init -upgrade. But on the first switch, back up the state and run on a feature branch, since the differences cluster in the newer features (variables in the backend, state encryption, OCI registry-backed modules). See tf-opentofu-parity for the full matrix.

    • → OpenTofu parity
  7. 07

    Check how the module looks in state

    bash
    terraform state list

    It should print something like:

    module.logs.aws_s3_bucket.this
    module.logs.aws_s3_bucket_versioning.this
    random_id.suffix

    The module.logs. prefix is the module's address. Inside it, aws_s3_bucket.this and aws_s3_bucket_versioning.this. The name "this" from the module is preserved.

    This is the basic way to navigate state with modules. See tf-module-basics.

    ✓ module.logs.aws_s3_bucket.this is visible in state. The contract works.

    Why a module, and not two resource blocks in the root

    Right now you have a single module call. It looks like overkill. But:

    • Add another bucket, module "data" { source = "./modules/audited-bucket", ... }, in one line. Without a module you would have to copy both aws_s3_bucket and aws_s3_bucket_versioning.
    • Change the policy for everyone (for example, add aws_s3_bucket_public_access_block): you edit the module, and the change reaches every call. Without a module you edit N places.
    • Test the module once, the assertions on one input hold for all of them.

    It pays off on the third use. On the first it is overhead, on the second it is debatable, on the third it saves time. Do not build a module prematurely.

    • → Module: a reusable piece of infrastructure
    • → The module's contract

Что ты узнал

A module is a directory of .tf files that you reference through a module block. The contract is the variable and output blocks. Inside it are ordinary resources. In state, a module's resources live under the prefix module.<name>..

команды

  • terraform initpulls module sources into .terraform/modules/
  • terraform state list | grep ^modulewhat in state comes from modules
  • terraform-docs markdown table modules/Xgenerate a README with a variables table

концепции

  • · Module: it is a directory; root vs child is a role, not a different kind of artifact
  • · From outside the module you see only its variables (input) and outputs
  • · In state, a module's resources are module.<name>.<type>.<name>

← предыдущий

Troubleshooting Garden: untangle the Cycle Error

следующий →

Linters: fmt, validate, tflint

Footer
linuxlab-
Copyright © 2026 LinuxLab. All rights reserved.
Tutorials
Pricing
About
Privacy & cookies