Ravindra BagaleCourses & study guides मराठी

Chapter 35: Terraform Modules Writing Using Registry and Versioning

35.2 Write a module — inputs, resources, outputs

Story. A Netflix-style platform team gives product teams a web-server module with only the knobs they should turn. The module hides the security group rules and the AMI lookup. Callers pass a name, a subnet and an instance type, and they get back an id and a public IP. Good modules expose few inputs, validate them, and return what callers actually need.

variables.tf inputs main.tf SG + EC2 outputs.tf caller reads module.web. public_ip
Inputs in, resources inside, outputs out.
# modules/web-server/variables.tf
variable "name"          { type = string }
variable "subnet_id"     { type = string }
variable "vpc_id"        { type = string }
variable "instance_type" {
  type    = string
  default = "t3.micro"
}

# modules/web-server/main.tf
data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]
  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }
}

resource "aws_security_group" "this" {
  name   = "${var.name}-sg"
  vpc_id = var.vpc_id
}

resource "aws_instance" "this" {
  ami                    = data.aws_ami.al2023.id
  instance_type          = var.instance_type
  subnet_id              = var.subnet_id
  vpc_security_group_ids = [aws_security_group.this.id]
  tags                   = { Name = var.name }
}

# modules/web-server/outputs.tf
output "instance_id" { value = aws_instance.this.id }
output "public_ip"   { value = aws_instance.this.public_ip }

What each line does

  • variable "name" { type = string } — A required input used for naming.
  • variable "subnet_id" { type = string } — The subnet the caller wants the server in.
  • variable "vpc_id" { type = string } — The VPC for the security group.
  • default = "t3.micro" — Callers may skip instance_type and get the small default.
  • data "aws_ami" "al2023" { — The module finds the AMI itself, so callers do not need to.
  • resource "aws_security_group" "this" { — Inside a module, this is the common name when there is only one of a type.
  • name = "${var.name}-sg" — Names the group after the caller's name.
  • vpc_security_group_ids = [aws_security_group.this.id] — Attaches the module's own group to the instance.
  • output "instance_id" { value = aws_instance.this.id } — Returns the instance id to the caller.
  • output "public_ip" { value = aws_instance.this.public_ip } — Returns the public IP to the caller.

When it is green.

Success! The configuration is valid.

When it is red.

Error: Unsupported argument
  on main.tf line 4, in module "web":
  4:   ami = "ami-0abc..."
An argument named "ami" is not expected here.

Fix. The caller passed an input the module does not declare. Either remove it or add a matching variable to the module.

What: every term in plain English

  • variable block — Declares an input. It can have a type, a default, a description and validation.
  • required input — A variable with no default. The caller must set it.
  • optional input — A variable with a default. The caller may skip it.
  • validation — A rule inside a variable. Bad values fail at plan time with your own message.
  • sensitive — Marks an output or variable so it is hidden in plan output.
  • this — The common resource name inside a module when there is only one of that type.
  • README.md — A short file that lists inputs, outputs and one example call.

Example 1: A web-server module with validation

A Paytm-style platform team saw people pass t3.2xlarge for test servers. They added a validation rule so the plan fails early.

# modules/web-server/variables.tf
variable "name" {
  type        = string
  description = "Name tag and prefix for all resources"
}

variable "instance_type" {
  type    = string
  default = "t3.micro"
  validation {
    condition     = contains(["t3.micro", "t3.small", "t3.medium"], var.instance_type)
    error_message = "Use t3.micro, t3.small or t3.medium only."
  }
}

variable "subnet_id" {
  type = string
}

variable "vpc_id" {
  type = string
}
terraform plan -var instance_type=t3.2xlarge

Output

Error: Invalid value for variable

  on main.tf line 4, in module "web":
   4:   instance_type = "t3.2xlarge"

Use t3.micro, t3.small or t3.medium only.

What it shows: The plan stops before any AWS call. The message is written by you, so it is clear for the caller.

Example 2: Full module: security group, server and outputs

A Swiggy-style team wants a server with port 80 open and SSH closed. The caller only gives a name and a subnet.

  1. Write main.tf below in modules/web-server.
  2. Write outputs.tf.
  3. Call it from envs/dev with the default VPC.
  4. Apply and open the IP in a browser.
# modules/web-server/main.tf
data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]
  filter {
    name   = "name"
    values = ["al2023-ami-2023.*-x86_64"]
  }
}

resource "aws_security_group" "this" {
  name   = "${var.name}-sg"
  vpc_id = var.vpc_id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_instance" "this" {
  ami                    = data.aws_ami.al2023.id
  instance_type          = var.instance_type
  subnet_id              = var.subnet_id
  vpc_security_group_ids = [aws_security_group.this.id]
  user_data              = <<-EOT
    #!/bin/bash
    dnf install -y nginx
    echo "Hello from ${var.name}" > /usr/share/nginx/html/index.html
    systemctl enable --now nginx
  EOT
  tags = {
    Name = var.name
  }
}

# modules/web-server/outputs.tf
output "instance_id" {
  value = aws_instance.this.id
}
output "public_ip" {
  value = aws_instance.this.public_ip
}
# envs/dev/main.tf
provider "aws" {
  region = "ap-south-1"
}

data "aws_vpc" "default" {
  default = true
}

data "aws_subnets" "default" {
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.default.id]
  }
}

module "web" {
  source    = "../../modules/web-server"
  name      = "dev-web"
  vpc_id    = data.aws_vpc.default.id
  subnet_id = data.aws_subnets.default.ids[0]
}

output "url" {
  value = "http://${module.web.public_ip}"
}
terraform init
terraform apply -auto-approve
curl -s $(terraform output -raw url)

Output

Plan: 2 to add, 0 to change, 0 to destroy.
module.web.aws_security_group.this: Creation complete after 2s [id=sg-0abc1234def567890]
module.web.aws_instance.this: Creation complete after 13s [id=i-0123456789abcdef0]

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Outputs:
url = "http://13.233.10.20"

Hello from dev-web

What it shows: Wait about one minute after apply for nginx to start. The IP shown is a sample.

Example 3: Write a README with inputs and outputs

At Zerodha-style teams, nobody uses a module without a README. Callers must not read main.tf to learn the inputs.

# modules/web-server/README.md
web-server module
Creates one EC2 server with nginx and a security group (port 80 only).

Inputs
| name          | type   | default  | required |
| name          | string | -        | yes      |
| vpc_id        | string | -        | yes      |
| subnet_id     | string | -        | yes      |
| instance_type | string | t3.micro | no       |

Outputs
| instance_id | EC2 instance id |
| public_ip   | public IP       |

Example
module "web" {
  source    = "../../modules/web-server"
  name      = "dev-web"
  vpc_id    = "vpc-..."
  subnet_id = "subnet-..."
}
terraform fmt -recursive
terraform validate

Output

modules/web-server/main.tf
Success! The configuration is valid.

What it shows: fmt prints files it changed. validate checks that the module and its callers fit together.

Example 4: Optional inputs with object and optional()

An Ola-style team wanted tags and a disk size to be optional, inside one settings object.

variable "settings" {
  type = object({
    disk_gb = optional(number, 8)
    tags    = optional(map(string), {})
  })
  default = {}
}

resource "aws_instance" "this" {
  # ... same as before ...
  root_block_device {
    volume_size = var.settings.disk_gb
  }
  tags = merge({ Name = var.name }, var.settings.tags)
}
module "web" {
  source    = "../../modules/web-server"
  name      = "dev-web"
  vpc_id    = data.aws_vpc.default.id
  subnet_id = data.aws_subnets.default.ids[0]
  settings = {
    disk_gb = 20
    tags    = { Team = "payments" }
  }
}
terraform plan

Output

  ~ resource "aws_instance" "this" {
      ~ tags = {
          + "Team" = "payments"
        }
      ~ root_block_device {
          ~ volume_size = 8 -> 20
        }
    }
Plan: 0 to add, 1 to change, 0 to destroy.

What it shows: optional() needs Terraform 1.3 or later. Callers set only what they need.

Command cheat sheet

Command What it does When to use it
variable "x" { type = string } Declare a required input Every value the caller must give
default = "t3.​micro" Make an input optional Safe defaults
validation { condition = ... } Reject bad input at plan time Sizes, names, CIDRs
output "id" { value = ... } Return a value to the caller IDs, IPs, ARNs
sensitive = true Hide a value in plan output Passwords, tokens
terraform validate Check module and caller fit Before every commit

Verify: did it work?

  1. Module is valid

    terraform validate
    

    Output

    Success! The configuration is valid.
    
  2. Outputs come back

    terraform output
    

    Output

    url = "http://13.233.10.20"
    
  3. Server answers

    curl -s $(terraform output -raw url)
    

    Output

    Hello from dev-web
    

When it is red 1

Error: Missing required argument
  on main.tf line 14, in module "web":
The argument "subnet_id" is required, but no definition was found.

Cause. The module has a required input and the caller did not set it.

Fix, step by step

  1. Open the module variables.tf.
  2. Add the input to the module block.

When it is red 2

Error: Reference to undeclared resource
  on main.tf line 5: value = aws_instance.web.id
A managed resource "aws_instance" "web" has not been declared in the root module.

Cause. The caller tried to read a resource inside the module directly.

Fix, step by step

  1. Add an output in the module.
  2. Read it as module.web.instance_id.

When it is red 3

Error: Invalid value for variable
Use t3.micro, t3.small or t3.medium only.

Cause. Your validation rule caught a bad value.

Fix, step by step

  1. Use an allowed value.
  2. Or change the rule in the module through review.

Cleanup

terraform destroy -auto-approve

Ravindra Bagale's Tip

If a caller has to read your main.tf to use the module, the module is not finished. Write the README.

Practice tasks

Easy Add a description

Why: Clear inputs.

What to do: Add description to every variable in your module.

How:
  1. One short line each.

Check: validate passes.

Medium Validation rule

Why: Fail early.

What to do: Allow only names that start with dev-, test- or prod-.

How:
  1. Use can(regex(...)).

Check: A bad name fails at plan.

Medium New output

Why: Give callers more.

What to do: Add a private_ip output.

How:
  1. Add output, apply, run terraform output.

Check: private_ip shows.

Hard Optional settings

Why: Fewer required inputs.

What to do: Add an optional settings object with disk size and tags.

How:
  1. Use optional() with defaults.

Check: Callers that skip it still work.

Easy Say it in two lines

Why: If you can say it simply, you understand it.

What to do: Write two short lines about this lesson for a fresher.

How:
  1. Write the problem it solves.
  2. Write the one command or file you use.
  3. Read it aloud.

Check: A fresher understands it without a question.

Easy Start a safe lab folder

Why: Practice needs a throwaway place.

What to do: Make a new folder with versions.tf and a random_pet resource, so no AWS cost.

How:
  1. mkdir tf-lab and cd into it.
  2. Add the hashicorp/random provider and one random_pet.
  3. Run terraform init and terraform apply.

Check: Apply complete! with 1 added.

Medium Break it on purpose

Why: You must know the red output by face.

What to do: Cause one error from the red part of this lesson and fix it.

How:
  1. Follow one red section.
  2. Copy the exact message to your notes.
  3. Write the fix in one line.

Check: You fixed it without searching the web.

Medium Run it twice

Why: A second run must be quiet.

What to do: Run the same plan two times with no edits.

How:
  1. Run terraform plan.
  2. Run it again.
  3. Explain any change it still shows.

Check: The second plan says No changes, or you can explain each line.

Medium Write a runbook page

Why: At 2 a.m. nobody remembers flags.

What to do: Write a one-page runbook for this lesson.

How:
  1. Title, when to use, exact commands, how to check, how to undo.

Check: A teammate can follow it alone.

Medium Run it from a pipeline

Why: Real teams run Terraform from CI, not from laptops.

What to do: Run the main command of this lesson inside a GitHub Actions or Jenkins job.

How:
  1. Add a job with hashicorp/setup-terraform or a Terraform Docker image.
  2. Give it AWS access through an IAM role, never a key in the file.
  3. Run init, then the command from this lesson.

Check: The job log shows the same output you saw on your laptop.

Medium Check the cost

Why: Labs left running cost real money.

What to do: Find what this lesson would cost per day if you forgot to destroy it.

How:
  1. Open the AWS Pricing Calculator.
  2. Add each resource from your plan.
  3. Write the daily cost in your notes.

Check: You know the daily cost and you ran terraform destroy.

Hard Draw the flow top to bottom

Why: A drawing shows the order of work.

What to do: Draw this lesson as boxes from top to bottom.

How:
  1. Start at your code, go through plan and state, end at AWS.
  2. Mark where each command reads or writes.

Check: Each box matches a real step you ran.

Hard Explain it like an interview

Why: Interviewers want real stories.

What to do: Record a 60 second answer about this lesson.

How:
  1. Say the problem, the command, and one error you hit.

Check: Your answer has a real command and a real error message.

Homework: try at home

Try at home

Write a module for one S3 bucket with versioning. Inputs: name and tags. Outputs: bucket name and ARN. Call it twice.

Recap

  • Inputs go in variables.tf, resources in main.tf, outputs in outputs.tf.
  • Required inputs have no default.
  • Validation stops bad values at plan time.
  • Callers read only outputs, never inner resources.
  • A README lists inputs, outputs and one example.

FAQ

Can a caller read a resource inside the module?

No. Only through an output.

How many inputs is too many?

If callers set more than ten, split the module or use sensible defaults.

Where do I put the AMI lookup?

Inside the module, so callers do not need to know it.

Should a module create its own VPC?

Usually no. Pass vpc_id and subnet_id in, so it fits any network.

Can outputs be secret?

Mark them sensitive = true. They still live in state, so protect state.

Interview questions

  1. How do you design a good module?

    One job, few inputs with safe defaults, validation for risky values, clear outputs and a README.

  2. How does a caller get a value from inside a module?

    The module declares an output and the caller reads module.NAME.OUTPUT.

  3. What is variable validation?

    A condition and an error message in a variable that fails plan when the value is wrong.

  4. Why use optional() in objects?

    So callers can pass only some fields and get defaults for the rest.

  5. Should child modules have provider blocks?

    No. They declare required_providers and inherit the provider from the root.

Quick quiz

Q1. Which file holds inputs?

variables.tf

Q2. How do you make an input optional?

Give it a default.

Q3. Where does a custom error message go?

In a validation block.

Q4. How do you hide an output in plan?

sensitive = true

Q5. Which command checks the module fits the caller?

terraform validate

Samajla ka?

Ravi Sir's Cards for this lesson

मराठी cards