Agentic AI with Java: Live Cohort
TerraformTaint & Modules

Terraform Module Project Implementation

Build a root module that calls EC2 and S3 child modules, pass an input from the root, fix a real module output error, and create both resources with one apply.

In the theory lesson we drew a plan: one root folder with a provider, a main.tf that calls child modules, an output.tf, and a modules/ folder with one sub-folder per piece of infrastructure. Now we build that plan on the Linux VM and create an EC2 instance and an S3 bucket with a single terraform apply.

This project pulls together almost everything from the course: providers, resources, input variables, outputs, init, validate, plan, apply and destroy.

Root module with provider.tf, main.tf and output.tf calling ec2 and s3 child modules

Step 1: Create the folder structure

Connect to the VM and export your AWS credentials first. Then create the empty structure. touch creates empty files that we fill in later.

mkdir 05-telusko-tf-project
cd 05-telusko-tf-project

mkdir modules
touch provider.tf main.tf output.tf

mkdir modules/ec2 modules/s3
touch modules/ec2/input.tf modules/ec2/main.tf modules/ec2/output.tf
touch modules/s3/main.tf

The S3 module only gets a main.tf to keep things short. It could have its own inputs and outputs exactly like the EC2 module.

Check the result with tree. It is not installed on Amazon Linux by default:

sudo yum install -y tree     # on Ubuntu: sudo apt install -y tree
cd ~
tree 05-telusko-tf-project
05-telusko-tf-project
├── main.tf
├── modules
│   ├── ec2
│   │   ├── input.tf
│   │   ├── main.tf
│   │   └── output.tf
│   └── s3
│       └── main.tf
├── output.tf
└── provider.tf

3 directories, 7 files

It matches the plan.

Step 2: The EC2 child module

modules/ec2/input.tf
variable "ami" {
  description = "Amazon Linux Image info"
  type        = string
}

variable "instance_type" {
  description = "Type of instance"
  default     = "t2.micro"
}

Notice the difference between the two variables. instance_type has a default, so it is optional. ami has only a type and no default, which makes it required: whoever calls this module must pass a value. The lecture does this deliberately to show that inputs can come from the root module.

modules/ec2/main.tf
resource "aws_instance" "linux-VM" {
  ami             = var.ami
  instance_type   = var.instance_type
  key_name        = "terraform_keypair"
  security_groups = ["default"]

  tags = {
    Name = "Telusko_Terraform_Linux_VM"
  }
}

This time the references are written as var.ami instead of "${var.ami}". As explained in Using Variables in Terraform, this is the modern form and it works the same way.

modules/ec2/output.tf
output "ec2_vm_public_ip" {
  value = aws_instance.linux-VM.public_ip
}

There is no provider block in the module. Child modules use the provider configured in the root.

Step 3: The S3 child module

The lecture's version:

modules/s3/main.tf (as in the lecture)
resource "aws_s3_bucket" "teluskos3bucket" {
  bucket = "telusko4354"
  acl    = "private"

  versioning {
    enabled = true
  }
}

It works, but as we saw in Terraform to create s3 bucket on aws, both acl and the inline versioning block are deprecated and print warnings. Here is the same module written for the current AWS provider:

modules/s3/main.tf
resource "aws_s3_bucket" "teluskos3bucket" {
  bucket        = "telusko-tf-project-sm-4354" # must be globally unique
  force_destroy = true                         # practice only
}

resource "aws_s3_bucket_versioning" "teluskos3bucket" {
  bucket = aws_s3_bucket.teluskos3bucket.id

  versioning_configuration {
    status = "Enabled"
  }
}

No acl is needed, because new buckets are private with ACLs disabled.

Step 4: The root files

provider.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = "ap-south-1"
}
main.tf
module "my_ec2" {
  source = "./modules/ec2"

  ami = "ami-062f0cc54dbfd8ef1" # use the Amazon Linux AMI ID from your region
}

module "my_s3" {
  source = "./modules/s3"
}
PartMeaning
module "my_ec2"Calls a module and gives this call the name my_ec2. The same child module could be called twice under different names, for example web and worker
source = "./modules/ec2"Path to the child module, relative to the root folder. Local paths must start with ./ or ../
ami = "..."Sets the child's ami input variable. Because ami has no default in input.tf, leaving this out is an error

instance_type is not passed, so the child uses its default t2.micro. We could override it here with instance_type = "t3.micro" without touching the module.

output.tf (first attempt)
output "ec2_vm_public_ip" {
  value = module.my_ec2.public_ip
}

This looks reasonable. It is wrong, and that is useful.

Step 5: init and validate, and a module error

terraform init

init now does one extra thing: it installs the modules. You will see Initializing modules... with my_ec2 and my_s3 listed before the provider download.

terraform fmt
terraform validate

With the lecture's S3 code, you first see the deprecation warning for versioning (and one more for acl). Then a real error:

Error: Unsupported attribute

  on output.tf line 2, in output "ec2_vm_public_ip":
   2:   value = module.my_ec2.public_ip

    module.my_ec2 is a object

This object does not have an attribute named "public_ip".

The reason is the rule from the theory lesson: from outside, a module is a closed box. The root cannot see the aws_instance inside it, so public_ip (an attribute of the instance) is not available. The only things visible from outside are the module's outputs. Look at the child's output file:

cat modules/ec2/output.tf

The output is named ec2_vm_public_ip. So the root must use that name:

output.tf
output "ec2_vm_public_ip" {
  value = module.my_ec2.ec2_vm_public_ip
}

The pattern is always module.<module call name>.<output name>.

terraform validate
Success! The configuration is valid.

Step 6: plan and apply

terraform plan
Plan: 2 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + ec2_vm_public_ip = (known after apply)

With the current S3 code it says 3 to add, because versioning is its own resource. Resource addresses now include the module path, for example module.my_ec2.aws_instance.linux-VM.

terraform apply -auto-approve
module.my_s3.aws_s3_bucket.teluskos3bucket: Creation complete after 2s [id=telusko4354]
module.my_ec2.aws_instance.linux-VM: Creation complete after 32s [id=i-0abc1234def567890]

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

Outputs:

ec2_vm_public_ip = "203.0.113.25"

The bucket and the instance are created in parallel, because neither depends on the other. Check both in the console: the instance under EC2 > Instances and the bucket under S3.

Run terraform state list to see how module resources are addressed:

module.my_ec2.aws_instance.linux-VM
module.my_s3.aws_s3_bucket.teluskos3bucket
module.my_s3.aws_s3_bucket_versioning.teluskos3bucket

This is also the address you would pass to terraform apply -replace=... from the taint lesson.

Step 7: Destroy

terraform destroy -auto-approve

Run it from the root folder. Terraform destroys everything in every module.

Common errors with modules

ErrorCauseFix
Module not installedYou added or changed a module blockRun terraform init
Missing required argument: "ami"The child variable has no default and the root did not pass itAdd ami = "..." to the module block
Unsupported argument in a module blockYou passed a value the child has no variable forAdd the variable to the child, or fix the spelling
This object does not have an attribute named ...Root tries to read a resource attribute directlyAdd an output in the child and use module.<name>.<output>
Unreadable module directoryWrong source pathPaths are relative to the root and must start with ./

What you have built

The same folder layout scales to real projects. Adding a VPC means a new modules/vpc folder and one more module block in the root. Creating a second environment can mean a second root folder that calls the same modules with different inputs. Combined with a remote S3 backend for state, this is how many teams organise Terraform day to day.

Try It Yourself

  1. Build the full project, reproduce the Unsupported attribute error on purpose, then fix it.
  2. Pass instance_type = "t3.micro" from the root module "my_ec2" block and run terraform plan to see the default being overridden.
  3. Give the S3 module a bucket_name input variable and an output for the bucket ARN, pass the name from the root, and print the ARN from the root output.tf.
  4. Call the EC2 module a second time as module "my_ec2_test" with a different Name tag (you will need a name variable in the module). Apply and check that two instances appear.
  5. Destroy everything from the root folder and confirm in the console.

Official references


Written By: Shramik Masti

How is this guide?

Last updated on