AWS with Terraform Tutorial: Terraform Modules (18)

· 9 min read · Terraform & OpenTofu Tutorials

How to create Terraform modules for AWS infrastructure #

Using Terraform and OpenTofu modules to turn the VPC, subnets, gateways and routing tables of the tutorial into a reusable building block.

Welcome to our tutorial series about Terraform or OpenTofu on AWS. Every section so far added resources to a single file, terraform-aws-tutorial.tf. That is the best way to learn, but it does not scale: the file grows, the same patterns are copied between projects and one mistake can affect everything. A module groups resources behind a small interface of inputs (variables) and outputs, so they can be reused, tested and versioned.

Terraform is an Infrastructure as Code (IaC) tool used to provision and manage infrastructure. It helps define and deploy resources across various cloud providers using code, making it easier to maintain and scale infrastructure.Terraform is an Infrastructure as Code (IaC) tool used to provision and manage infrastructure. It helps define and deploy resources across various cloud providers using code, making it easier to maintain and scale infrastructure.
Terraform
Basics
Terraform...
AWS is the world’s leading cloud platform, it is used by a wide range of organizations, from startups to large enterprises, to power their online businesses. AWS offers a wide range of services, including computing, storage, database, networking, analytics, machine learning, and artificial intelligence.AWS is the world’s leading cloud platform, it is used by a wide range of organizations, from startups to large enterprises, to power their online businesses. AWS offers a wide range of services, including computing, storage, database, networking, analytics, machine learning, and artificial intelligence.
AWS
Basics
AWS...
The Terraform official AWS provider acts as an abstraction layer that lets Terraform configurations written in HCL define AWS services and infrastructure using code (IaC). Internally Terraform and the AWS provider handle authentication, and make the necessary AWS API calls to query, create, modify, and destroy the resources.The Terraform official AWS provider acts as an abstraction layer that lets Terraform configurations written in HCL define AWS services and infrastructure using code (IaC). Internally Terraform and the AWS provider handle authentication, and make the necessary AWS API calls to query, create, modify, and destroy the resources.
Terraform
AWS Provider
Terraform...
How to Create, and Manage AWS VPCs with TerraformHow to Create, and Manage AWS VPCs with Terraform
AWS VPC
AWS VPC
How to configure and use the Terraform aws_subnet resource block to create and manage AWS Subnets inside a VPC.How to configure and use the Terraform aws_subnet resource block to create and manage AWS Subnets inside a VPC.
AWS Subnets
AWS Subnets
How to configure and use the Terraform aws_internet_gateway resource block to create and manage AWS Internet Gateway inside a VPC to enable Internet access to and from instances. How to configure and use the Terraform aws_internet_gateway resource block to create and manage AWS Internet Gateway inside a VPC to enable Internet access to and from instances.
AWS Internet
Gateway
AWS Internet...
How to configure and use the Terraform aws_nat_gateway and aws_eip resource blocks to create and manage AWS NAT Gateway and its corresponding Public IPs inside each availability zone to enable Internet access from instances in private subnets.How to configure and use the Terraform aws_nat_gateway and aws_eip resource blocks to create and manage AWS NAT Gateway and its corresponding Public IPs inside each availability zone to enable Internet access from instances in private subnets.
AWS NAT
Gateway
AWS NAT...
How to configure and use the Terraform aws_route_table, aws_route, and aws_main_route_table_association resource blocks to create and manage AWS Routing Tables.How to configure and use the Terraform aws_route_table, aws_route, and aws_main_route_table_association resource blocks to create and manage AWS Routing Tables.
AWS Routing
Tables
AWS Routing...
How to configure and use the Terraform aws_security_group and aws_security_group_rule resource blocks to create and manage AWS Security Groups and secure the infrastructure.How to configure and use the Terraform aws_security_group and aws_security_group_rule resource blocks to create and manage AWS Security Groups and secure the infrastructure.
AWS Security
Groups
AWS Security...
How to configure and use the Terraform aws_key_pair resource block to create and manage AWS Key Pairs for performing SSH Public Key Authentication into EC2 instances.How to configure and use the Terraform aws_key_pair resource block to create and manage AWS Key Pairs for performing SSH Public Key Authentication into EC2 instances.
AWS Key
Pairs
AWS Key...
How to configure and use the Terraform aws_ami data source block to find and use AWS AMIs as templates (root volume snapshot with operating system and applications) for EC2 instances.How to configure and use the Terraform aws_ami data source block to find and use AWS AMIs as templates (root volume snapshot with operating system and applications) for EC2 instances.
AWS AMIs
AWS AMIs
Using the Terraform aws_instance resource block to configure, launch, and secure EC2 instances.Using the Terraform aws_instance resource block to configure, launch, and secure EC2 instances.
AWS EC2
Instances
AWS EC2...
AWS RDS
AWS RDS
Using the Terraform aws_route53_delegation_set, aws_route53_zone, and aws_route53_record resource blocks to configure DNS in AWS. Using the Terraform aws_route53_delegation_set, aws_route53_zone, and aws_route53_record resource blocks to configure DNS in AWS.
AWS Route 53
(DNS)
AWS Route 53...
Amazon EC2 Auto Scaling groups keep the right number of instances running, replace failed ones and scale with the load.Amazon EC2 Auto Scaling groups keep the right number of instances running, replace failed ones and scale with the load.
AWS Auto
Scaling
AWS Auto...
Elastic Load Balancing distributes the traffic between healthy instances. The tutorial creates an Application Load Balancer with HTTPS.Elastic Load Balancing distributes the traffic between healthy instances. The tutorial creates an Application Load Balancer with HTTPS.
AWS Load
Balancers
AWS Load...
This tutorial shows how to create infrastructure in AWS using Terraform and configure the operating system and applications using Ansible.This tutorial shows how to create infrastructure in AWS using Terraform and configure the operating system and applications using Ansible.
Terraform,
AWS & Ansible
Terraform,...
Modules package resources behind variables and outputs so they can be reused. The tutorial refactors the network into a module.Modules package resources behind variables and outputs so they can be reused. The tutorial refactors the network into a module.
Terraform
Modules
Terraform...
A backend stores the Terraform state. The tutorial uses an encrypted, versioned S3 bucket with state locking.A backend stores the Terraform state. The tutorial uses an encrypted, versioned S3 bucket with state locking.
Terraform
Backends
Terraform...
fmt, validate, TFLint, Trivy, Checkov, terraform-docs, Infracost and pre-commit: check the code before it reaches AWS.fmt, validate, TFLint, Trivy, Checkov, terraform-docs, Infracost and pre-commit: check the code before it reaches AWS.
Terraform
Tools
Terraform...
Run checks, plans and approved applies automatically with GitHub Actions and OIDC, without access keys.Run checks, plans and approved applies automatically with GitHub Actions and OIDC, without access keys.
Terraform
CI/CD
Terraform...
Select a tutorial section 
Select a tutorial sectio...

Prerequisites #

Read the previous sections of the tutorial, listed in the series index at the end of this page. This section refactors the network created in AWS VPC, AWS Subnets, AWS Internet Gateway, AWS NAT Gateway and AWS Routing Tables.

Terraform modules #

A module is a directory with Terraform files. Every Terraform project is already a module, the root module, and it can call other modules (child modules) with a module block:

  • Inputs: variable blocks, set as arguments of the module block.
  • Outputs: output blocks, read as module.<name>.<output>.
  • Sources: a local path (./modules/network), the public registry (terraform-aws-modules/vpc/aws), a Git repository (git::https://github.com/org/repo.git//modules/network?ref=v1.2.0) or an S3 bucket.

Good modules do one thing, expose few and well-documented variables, hide implementation details and do not configure providers (the provider is inherited from the root module).

The new structure of the project #

.
├── providers.tf        # terraform {} and provider "aws" {}
├── main.tf             # calls the module and the rest of the resources
├── moved.tf            # tells Terraform that the resources changed address
└── modules
    └── network
        ├── main.tf
        ├── variables.tf
        └── outputs.tf

The goal is the same infrastructure: after the refactor, tofu plan must not create, change or destroy anything.

The network module #

Input variables #

modules/network/variables.tf
variable "name_prefix" {
  type        = string
  description = "Prefix used in the names of the resources, for example ditwl"
}

variable "vpc_name" {
  type        = string
  description = "Name tag of the VPC"
}

variable "vpc_cidr_block" {
  type        = string
  description = "IPv4 CIDR block of the VPC, for example 172.21.0.0/19"

  validation {
    condition     = can(cidrnetmask(var.vpc_cidr_block))
    error_message = "vpc_cidr_block must be a valid IPv4 CIDR block, for example 172.21.0.0/19."
  }
}

variable "subnets" {
  type = map(object({
    cidr_block        = string
    availability_zone = string
    public            = bool
  }))
  description = "Subnets by name. Private subnets need a public subnet in the same Availability Zone for the NAT Gateway."

  validation {
    condition = alltrue([
      for s in var.subnets : s.public || anytrue([for p in var.subnets : p.public && p.availability_zone == s.availability_zone])
    ])
    error_message = "Every Availability Zone with a private subnet needs a public subnet."
  }
}

The subnets variable is a map: each key is the name of a subnet. A for_each over it creates all the subnets, so adding a subnet means adding one entry, not copying a resource block.

Resources #

modules/network/main.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0"
    }
  }
}

locals {
  public_subnets  = { for name, s in var.subnets : name => s if s.public }
  private_subnets = { for name, s in var.subnets : name => s if !s.public }

  # One NAT Gateway per Availability Zone, in the public subnet of that zone
  nat_subnet_by_az = { for name, s in local.public_subnets : s.availability_zone => name }
  private_azs      = toset([for s in local.private_subnets : s.availability_zone])

  # us-east-1a -> za
  zone = { for az in keys(local.nat_subnet_by_az) : az => "z${substr(az, -1, 1)}" }
}

resource "aws_vpc" "this" {
  cidr_block = var.vpc_cidr_block
  tags = {
    Name = var.vpc_name
    tool = "Terraform"
  }
}

resource "aws_subnet" "this" {
  for_each = var.subnets

  vpc_id                  = aws_vpc.this.id
  cidr_block              = each.value.cidr_block
  availability_zone       = each.value.availability_zone
  map_public_ip_on_launch = each.value.public
  tags = {
    Name = each.key
  }
}

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

resource "aws_eip" "nat" {
  for_each = local.nat_subnet_by_az

  domain = "vpc"
  tags = {
    Name = "${var.name_prefix}-eip-ngw-${local.zone[each.key]}"
  }
}

resource "aws_nat_gateway" "this" {
  for_each = local.nat_subnet_by_az

  subnet_id     = aws_subnet.this[each.value].id
  allocation_id = aws_eip.nat[each.key].id
  tags = {
    Name = "${var.name_prefix}-ngw-${local.zone[each.key]}-pub"
  }

  depends_on = [aws_internet_gateway.this]
}

# Public routing table, the main one of the VPC: access to the Internet through the Internet Gateway
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.this.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.this.id
  }

  tags = {
    Name = "${var.name_prefix}-rt-pub-main"
  }
}

resource "aws_main_route_table_association" "public" {
  vpc_id         = aws_vpc.this.id
  route_table_id = aws_route_table.public.id
}

# One private routing table per Availability Zone: access to the Internet through the NAT Gateway of the zone
resource "aws_route_table" "private" {
  for_each = local.private_azs

  vpc_id = aws_vpc.this.id
  tags = {
    Name = "${var.name_prefix}-rt-priv-${local.zone[each.key]}"
  }
}

resource "aws_route" "private_nat" {
  for_each = local.private_azs

  route_table_id         = aws_route_table.private[each.key].id
  destination_cidr_block = "0.0.0.0/0"
  nat_gateway_id         = aws_nat_gateway.this[each.key].id
}

resource "aws_route_table_association" "private" {
  for_each = local.private_subnets

  subnet_id      = aws_subnet.this[each.key].id
  route_table_id = aws_route_table.private[each.value.availability_zone].id
}

Outputs #

modules/network/outputs.tf
output "vpc_id" {
  description = "ID of the VPC"
  value       = aws_vpc.this.id
}

output "subnet_ids" {
  description = "IDs of all the subnets by name"
  value       = { for name, s in aws_subnet.this : name => s.id }
}

output "public_subnet_ids" {
  description = "IDs of the public subnets"
  value       = [for name, s in local.public_subnets : aws_subnet.this[name].id]
}

output "private_subnet_ids" {
  description = "IDs of the private subnets"
  value       = [for name, s in local.private_subnets : aws_subnet.this[name].id]
}

Using the module #

Replace the VPC, subnet, gateway and routing table resources in terraform-aws-tutorial.tf with one module block:

main.tf
module "network" {
  source = "./modules/network"

  name_prefix    = "ditwl"
  vpc_name       = "ditlw-vpc"
  vpc_cidr_block = "172.21.0.0/19"

  subnets = {
    "ditwl-sn-za-pro-pub-00" = { cidr_block = "172.21.0.0/23", availability_zone = "us-east-1a", public = true }
    "ditwl-sn-za-pro-pri-02" = { cidr_block = "172.21.2.0/23", availability_zone = "us-east-1a", public = false }
    "ditwl-sn-zb-pro-pub-04" = { cidr_block = "172.21.4.0/23", availability_zone = "us-east-1b", public = true }
    "ditwl-sn-zb-pro-pri-06" = { cidr_block = "172.21.6.0/23", availability_zone = "us-east-1b", public = false }
  }
}

The rest of the infrastructure used to reference the resources directly. Now it uses the outputs of the module:

Before After
aws_vpc.ditlw-vpc.id module.network.vpc_id
aws_subnet.ditwl-sn-za-pro-pub-00.id module.network.subnet_ids["ditwl-sn-za-pro-pub-00"]
aws_subnet.ditwl-sn-zb-pro-pri-06.id module.network.subnet_ids["ditwl-sn-zb-pro-pri-06"]

For example, an EC2 instance now uses subnet_id = module.network.subnet_ids["ditwl-sn-za-pro-pub-00"] and a security group vpc_id = module.network.vpc_id.

Moving the existing resources: the moved block #

If you run tofu plan now, Terraform sees that aws_vpc.ditlw-vpc disappeared from the code and that module.network.aws_vpc.this is new: it would destroy the whole network and create it again. A moved block tells Terraform that the resource did not change, only its address did, and the state is updated without touching the real infrastructure.

moved.tf
moved {
  from = aws_vpc.ditlw-vpc
  to   = module.network.aws_vpc.this
}

moved {
  from = aws_subnet.ditwl-sn-za-pro-pub-00
  to   = module.network.aws_subnet.this["ditwl-sn-za-pro-pub-00"]
}
moved {
  from = aws_subnet.ditwl-sn-za-pro-pri-02
  to   = module.network.aws_subnet.this["ditwl-sn-za-pro-pri-02"]
}
moved {
  from = aws_subnet.ditwl-sn-zb-pro-pub-04
  to   = module.network.aws_subnet.this["ditwl-sn-zb-pro-pub-04"]
}
moved {
  from = aws_subnet.ditwl-sn-zb-pro-pri-06
  to   = module.network.aws_subnet.this["ditwl-sn-zb-pro-pri-06"]
}

moved {
  from = aws_internet_gateway.ditwl-ig
  to   = module.network.aws_internet_gateway.this
}

moved {
  from = aws_eip.ditwl-eip-ngw-za
  to   = module.network.aws_eip.nat["us-east-1a"]
}
moved {
  from = aws_eip.ditwl-eip-ngw-zb
  to   = module.network.aws_eip.nat["us-east-1b"]
}
moved {
  from = aws_nat_gateway.ditwl-ngw-za-pub
  to   = module.network.aws_nat_gateway.this["us-east-1a"]
}
moved {
  from = aws_nat_gateway.ditwl-ngw-zb-pub
  to   = module.network.aws_nat_gateway.this["us-east-1b"]
}

moved {
  from = aws_route_table.ditwl-rt-pub-main
  to   = module.network.aws_route_table.public
}
moved {
  from = aws_main_route_table_association.ditwl-rta-default
  to   = module.network.aws_main_route_table_association.public
}
moved {
  from = aws_route_table.ditwl-rt-priv-za
  to   = module.network.aws_route_table.private["us-east-1a"]
}
moved {
  from = aws_route_table.ditwl-rt-priv-zb
  to   = module.network.aws_route_table.private["us-east-1b"]
}
moved {
  from = aws_route.ditwl-r-rt-priv-za-ngw-za
  to   = module.network.aws_route.private_nat["us-east-1a"]
}
moved {
  from = aws_route.ditwl-r-rt-priv-zb-ngw-zb
  to   = module.network.aws_route.private_nat["us-east-1b"]
}
moved {
  from = aws_route_table_association.ditwl-rta-za-pro-pri-02
  to   = module.network.aws_route_table_association.private["ditwl-sn-za-pro-pri-02"]
}
moved {
  from = aws_route_table_association.ditwl-rta-zb-pro-pri-06
  to   = module.network.aws_route_table_association.private["ditwl-sn-zb-pro-pri-06"]
}

Run the Terraform Plan #

A new module has to be installed before it can be used:

$ tofu init
Initializing modules...
- network in modules/network

Then check the plan. Every moved resource is listed, and nothing is created or destroyed:

$ tofu plan
...
  # aws_vpc.ditlw-vpc has moved to module.network.aws_vpc.this
    resource "aws_vpc" "this" {
        id = "vpc-0361cf67e9e74acf6"
        # (14 unchanged attributes hidden)
    }
...
Plan: 0 to add, 0 to change, 0 to destroy.

Apply the plan to save the new addresses in the state:

$ tofu apply

Keep moved.tf until everyone who shares the state has applied the change, then delete it.

Public registry modules #

You do not always have to write the module. The Terraform Registry and the OpenTofu Registry have thousands of them. The most popular one for this job, terraform-aws-modules/vpc/aws, builds the same network:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0" # always pin the version

  name = "ditlw-vpc"
  cidr = "172.21.0.0/19"

  azs             = ["us-east-1a", "us-east-1b"]
  public_subnets  = ["172.21.0.0/23", "172.21.4.0/23"]
  private_subnets = ["172.21.2.0/23", "172.21.6.0/23"]

  enable_nat_gateway     = true
  one_nat_gateway_per_az = true
}

Read the code of a registry module before using it and pin its version, as it will create resources in your AWS account.

Module best practices #

  • One purpose per module, with a short list of variables. If a module needs 40 variables, it is probably several modules.
  • Describe and validate every variable (description, type, validation) and every output.
  • No provider blocks inside modules, only required_providers. The root module configures the provider.
  • Use for_each over a map instead of count: adding or removing an element does not renumber the others.
  • Version the modules that are shared (Git tags or a registry) and update them in a controlled way.
  • Document them with a README. The Terraform Tools section shows how to generate it automatically.

Common Questions About Terraform Modules #

What is the difference between a module and a workspace? #

A module is a way to reuse code. A workspace is a way to keep several states for the same code (for example, one per environment). They solve different problems and can be combined.

Can a module use resources created outside of it? #

Yes, but pass them as variables (for example vpc_id) instead of reading them inside the module. The module stays independent and easier to test.

How do I use the same module for several environments? #

Call it several times with different inputs, from different root modules (one directory and one state per environment) or in the same one with different names.

What happens if I rename a resource inside a module? #

Terraform considers it a different resource and recreates it. Add a moved block inside the module with the old and the new address.

Next Steps #

The infrastructure is now organized in modules. Before sharing it with a team, the state must stop living on your computer: continue with Terraform Backends.

#AWS #AWS VPC #Terraform #OpenTofu #IaC #AWS Terraform Tutorial