Terraform Modules for Reusable Infrastructure

Learn to create Terraform modules for reusable infrastructure in this hands-on AWS Cloud & DevOps with Python tutorial. Step 27 of the track: understand the core concept, apply it in a practical exercise, and get ready for the next lesson.

Focus: create terraform modules for reusable infrastructure

Sponsored

You've probably copy-pasted the same 50-line Terraform block for an EC2 instance into three different projects, then spent a painful evening fixing a security group change in one place while the others silently drifted. That's the pain this lesson kills: repetitive, inconsistent, hand-maintained infrastructure code. Here's the fix: create Terraform modules for reusable infrastructure — a practice that turns your messy .tf files into a clean, shareable, versioned toolkit. By the end of this lesson, you'll modularize your AWS infrastructure with Python-powered CI, deploy a reusable VPC and EC2 module, and know exactly when to reach for a module versus a plain resource.

The problem this lesson solves

Infrastructure as code (IaC) was supposed to end manual server setup, but if you're still copying and pasting Terraform blocks between environments, you've only automated the copying. The real problem is DRY (Don't Repeat Yourself) violation at the infrastructure layer — and it costs you in three concrete ways:

  • Configuration drift: A security-group rule gets tweaked in prod/main.tf but not in staging/main.tf. Two weeks later, your staging environment is a security hole and you have no idea why.
  • Review fatigue: Every new project needs a 200-line main.tf review, even though 80% of it was written last month. Your senior engineers become rubber stamps, not architects.
  • Versioning chaos: When you fix a bug in your VPC setup, you can't easily push that fix to all consumers. You end up with five slightly different VPCs named main-v2, main-final, main-FINAL2.

Without modules, every environment is a snowflake. With them, you define the standard once and reuse it everywhere, so your infrastructure becomes a product, not a one-off script.

Core concept / mental model

Think of a Terraform module like a Python function — you've already internalized this pattern in the earlier Python lessons of this track. A function takes inputs, does work, returns outputs, and hides internal complexity. A Terraform module does exactly the same, but for infrastructure:

  • Input variables = function parameters
  • Resources (EC2, VPC, S3) = the function body
  • Outputs (IP addresses, ARNs) = the return value
  • module block = the function call

Every Terraform configuration is a module — the root module is your main main.tf. When you create a child module, you're creating a reusable package that can be called from any root configuration, just like a pip install-ed library. The mental model boils down to:

A module encapsulates a set of resources and exposes a clean interface (variables in, outputs out). It hides the how and exposes the what.

For example, instead of writing raw aws_instance and aws_security_group blocks in every project, you create a module called web-server that accepts an instance_type, an environment, and a vpc_id, then returns the instance's public IP. Your production and staging configs become 20-line files that call the module with different inputs.

How it works step by step

Creating a reusable Terraform module follows a predictable sequence — you'll build it iteratively in a local directory, then call it from a root module.

  1. Design the module's interface. Ask: what inputs will callers provide (e.g., instance_type), what outputs do they need (e.g., public_ip), and what internal resources are always needed? Keep the interface minimal — expose only what's necessary.

  2. Create the module directory. A module is a folder containing .tf files. Conventional structure: modules/<module-name>/main.tf, variables.tf, outputs.tf. The folder name is what you'll reference in your root config.

  3. Define input variables in variables.tf. Each variable needs a type and (ideally) a description. Use validation blocks for critical constraints (like allowed instance types).

  4. Write the resources in main.tf using those variables. This is the cause — the resources you declare — and the effect is what gets provisioned when terraform applies. Use var.<name> to reference inputs.

  5. Expose outputs in outputs.tf. These become the module's return values, consumable by the caller via module.<name>.<output_name>.

  6. Call the module from your root config with a module block. Set input values, then reference outputs downstream.

  7. Version and share. Store your module in a Git repo (or Terraform Registry) and reference it by version — this is where Python CI/CD (from earlier track lessons) comes in: a simple pipeline can lint and test your module on every commit.

Hands-on walkthrough

Let's build a reusable VPC module — the most common infrastructure building block — and then use it from a root configuration. You'll see the full lifecycle from folder creation to terraform apply.

Step 1: Scaffold the module

Create this directory structure:

mkdir -p terraform-demo/modules/vpc
touch terraform-demo/modules/vpc/{main.tf,variables.tf,outputs.tf}

Step 2: Define variables (modules/vpc/variables.tf)

variable "name" {
  description = "Prefix for all resource names"
  type        = string
}

variable "cidr_block" {
  description = "CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "public_subnet_cidrs" {
  description = "CIDR blocks for public subnets"
  type        = list(string)
  default     = ["10.0.1.0/24", "10.0.2.0/24"]
}

variable "azs" {
  description = "Availability zones for subnets"
  type        = list(string)
  default     = ["us-east-1a", "us-east-1b"]
}

Step 3: Write the resources (modules/vpc/main.tf)

resource "aws_vpc" "this" {
  cidr_block           = var.cidr_block
  enable_dns_hostnames = true

  tags = {
    Name = "${var.name}-vpc"
  }
}

resource "aws_subnet" "public" {
  count             = length(var.public_subnet_cidrs)
  vpc_id            = aws_vpc.this.id
  cidr_block        = var.public_subnet_cidrs[count.index]
  availability_zone = var.azs[count.index]

  map_public_ip_on_launch = true

  tags = {
    Name = "${var.name}-public-${count.index}"
  }
}

resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id
  tags = {
    Name = "${var.name}-igw"
  }
}

Step 4: Define outputs (modules/vpc/outputs.tf)

output "vpc_id" {
  value = aws_vpc.this.id
}

output "public_subnet_ids" {
  value = aws_subnet.public[*].id
}

Step 5: Call the module from your root config (main.tf)

provider "aws" {
  region = "us-east-1"
}

module "my_vpc" {
  source = "./modules/vpc"

  name               = "dev"
  cidr_block         = "10.100.0.0/16"
  public_subnet_cidrs = ["10.100.1.0/24", "10.100.2.0/24"]
}

# You can now use the outputs
output "dev_vpc_id" {
  value = module.my_vpc.vpc_id
}

output "dev_subnet_ids" {
  value = module.my_vpc.public_subnet_ids
}

Step 6: Run it

cd terraform-demo   erraform init   erraform plan   erraform apply -auto-approve

Expected output (abridged):

module.my_vpc.aws_vpc.this: Creating...
module.my_vpc.aws_subnet.public[0]: Creating...
module.my_vpc.aws_subnet.public[1]: Creating...
...
Apply complete! Resources: 4 added, 0 changed, 0 destroyed.

Outputs:

dev_subnet_ids = [
  "subnet-0a1b2c3d4e5f6a7b8",
  "subnet-0b2c3d4e5f6a7b8c9",
]
dev_vpc_id = "vpc-0c3d4e5f6a7b8c9d0"

The magic: any other project can now add module "staging_vpc" { source = "./modules/vpc" name = "staging" } and get a complete VPC with zero duplicated resource blocks. Because the module is local, you can evolve it and every consumer benefits instantly.

Compare options / when to choose what

You now have three ways to manage reusable infrastructure in Terraform. Each has trade-offs:

Approach Pros Cons Best for
Copy-paste blocks Quick, no learning curve Drift, review fatigue, security risks One-off experiments
Terraform modules DRY, testable, versionable Requires design effort, abstraction overhead Reusing infrastructure across environments/projects
Terraform Registry / public modules Battle-tested, community-vetted Less control, potential lock-in Standard, widely-used patterns (VPC, EC2, S3)

When to choose what:

  • Choose copy-paste only for a throwaway demo you'll destroy in an hour.
  • Choose your own module when you have a repeated, opinionated pattern — like your company's standard web tier with specific tags and security rules.
  • Choose a public module (e.g., terraform-aws-modules/vpc/aws) when you need a generic, battle-tested version and you're okay with its opinionated defaults.

Pro tip: Start with a public module, then fork it as your own when you need customization. This gives you learning guidance and a safe baseline.

Troubleshooting & edge cases

You will hit these three gotchas. Know them before they hit you.

1. Module source path is relative to the calling file

Wrong: In your root main.tf, you write source = "modules/vpc" and run terraform plan from a subdirectory.

Error: Error: Unreadable module directory ...

Fix: Always make the source path relative to the root module's directory. Better: absolute path or a Git URL:

module "vpc" {
  source = "./modules/vpc"  # relative to the root module file
}

2. Variable count mismatch between public and private subnets

If your module has two azs but only one public_subnet_cidr, Terraform will error with Insufficient items when you try to map subnets to AZs via count or zipmap. Fix: validate input lengths inside the module (e.g., check {} block) or simply define a separate variable for private subnet CIDRs with a matching count.

3. Module outputs are not available until after apply

Referencing module.vpc.vpc_id inside the same module (e.g., as an input to another resource in the root config) is fine, but you cannot use it inside that same module's main.tf (you'd reference aws_vpc.this.id directly). In the root, the reference is lazy — it works after apply, but not during plan if you try to use it in a depends_on that doesn't exist yet.

What you learned & what's next

You've learned the core idea of create Terraform modules for reusable infrastructure: encapsulate resources, expose a clean interface, and reuse them across environments. You completed a practical exercise by building a VPC module from scratch, calling it from a root configuration, and seeing the apply output — this meets both learning objectives. Your key mental shift: every Terraform config is a module, and child modules are your single source of truth for infrastructure patterns.

What's next: In the next lesson, you'll learn to use remote state and locking to manage Terraform state across teams — because with reusable modules, the next problem is shared state and concurrent applies. You'll use the same VPC module in a multi-environment setup with S3 backend and DynamoDB locking.

Key takeaways:

  • Modules are the DRY principle for infrastructure — encapsulate repeated resources and expose variables/outputs.
  • Interface design is critical: limit inputs to what's meaningful, output only what callers need.
  • Local modules are great for private patterns; public modules are great for generic patterns — choose based on control vs. speed.
  • Version your modules with Git tags so consumers stay reproducible.
  • Module paths are relative to the root config — get this wrong and you'll be debugging path errors.
  • Module outputs are lazy — they're resolved after apply, not at plan time.

Now go modularize your infrastructure — and remember, the next step is making that state safe for a team.

Practice recap

Create a second module named ec2-instance in the same terraform-demo directory. It should take instance_type, ami, subnet_id, and name as inputs, and output the public IP. Call it from your root config, referencing the VPC module's public_subnet_ids[0] as the subnet. Run terraform init, plan, and apply — you now have a reusable VPC and an EC2 module working together!

Practice recap

Now build a second module for an EC2 instance in the same demo directory. It should accept instance_type, ami, subnet_id, and name as inputs, and output the public IP. Then call it from your root config, feeding module.my_vpc.public_subnet_ids[0] as the subnet. Run terraform init, plan, and apply — you'll have two reusable modules working together.

Common mistakes

  • Hardcoding resource names inside a module instead of using variables — this makes the module impossible to reuse across environments.
  • Omitting count or for_each when you need multiple resources (like subnets) — you'll get a single resource and unexpected behavior.
  • Using a relative module source path that breaks when called from a different root directory — use absolute paths or Git URLs.
  • Not defining output values for attributes you'll need later — you'll end up querying the state file manually instead of using clean module outputs.

Variations

  1. Use a public module from the Terraform Registry (e.g., terraform-aws-modules/vpc/aws) instead of writing your own — faster but less customizable.
  2. Store modules in a private Git repository and reference them by git::https://...//modules/vpc?ref=1.0.0 for versioning and reuse across teams.
  3. Use for_each instead of count when creating resources from a map variable — better for keyed instances like multiple environments.

Real-world use cases

  • A platform team creates an internal web-app module used by all product squads — each squad passes its own app_name and instance_size, and gets a consistent security-hardened environment.
  • A consultancy manages multiple client AWS accounts — they store a landing-zone module in Git and apply it with different inputs per client, ensuring standardized networking and IAM baselines.
  • A startup uses a public VPC module to bootstrap a new environment for every feature branch within a CI pipeline, using Python scripts to generate Terraform variable files.

Key takeaways

  • Terraform modules are like Python functions: variables in, resources inside, outputs out.
  • Every Terraform configuration is a module — the root is just the top-level module.
  • Design the module interface first: keep inputs minimal and outputs purposeful.
  • Use modules to eliminate copy-paste infrastructure and prevent configuration drift.
  • Local modules are great for internal patterns; public modules are for generic, battle-tested needs.
  • Always version your modules (Git tags, registry, or Git URL refs) so consumers stay consistent.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.