Blog
April 11, 2018 Marie H.

Writing Reusable Terraform Modules

Writing Reusable Terraform Modules

Photo by <a href="https://unsplash.com/@r_li?utm_source=cloudista&utm_medium=referral" target="_blank" rel="noopener">Ruoyu Li</a> on <a href="https://unsplash.com/?utm_source=cloudista&utm_medium=referral" target="_blank" rel="noopener">Unsplash</a>

Once you've got past the basics of Terraform and your main.tf is starting to look like a CVS receipt, it's time to think about modules. I've seen some real horrors in this space — a single 2,000-line main.tf comes to mind — so let's talk about what makes a good module and how to actually write one.

What makes a good module

A module should do one thing. A VPC module creates a VPC and its associated resources. A database module creates an RDS instance. It doesn't create the VPC and the RDS and the application servers and the Route53 records all in one giant blob. Single responsibility applies here just as much as in software design.

Good modules have sensible defaults. The caller should be able to use a module with minimal required inputs and get something reasonable. Optional configuration should be optional.

Variables should be typed and documented. Terraform 0.11 has basic types — string, list, map — and you should use them. A variable with a description of "" and no type is useless to the next person (or future you) trying to understand what to pass in.

Module directory structure

modules/
  vpc/
    main.tf
    variables.tf
    outputs.tf
    README.md

Keep it simple. main.tf is the resources. variables.tf is the inputs. outputs.tf is what the module exposes to callers. Don't put everything in one file — when someone needs to understand what inputs a module takes, they want to open variables.tf and read it, not grep through 500 lines.

A real example: VPC module

modules/vpc/variables.tf:

variable "name" {
  description = "Name prefix for all VPC resources"
  type        = "string"
}

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

variable "azs" {
  description = "A list of availability zones in the region"
  type        = "list"
}

variable "public_subnets" {
  description = "A list of public subnet CIDR blocks"
  type        = "list"
  default     = []
}

variable "private_subnets" {
  description = "A list of private subnet CIDR blocks"
  type        = "list"
  default     = []
}

variable "enable_nat_gateway" {
  description = "Should be true to provision NAT Gateways for private subnets"
  default     = false
}

variable "tags" {
  description = "A map of tags to add to all resources"
  type        = "map"
  default     = {}
}

modules/vpc/main.tf:

resource "aws_vpc" "this" {
  cidr_block           = "${var.cidr}"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = "${merge(var.tags, map("Name", var.name))}"
}

resource "aws_internet_gateway" "this" {
  vpc_id = "${aws_vpc.this.id}"

  tags = "${merge(var.tags, map("Name", "${var.name}-igw"))}"
}

resource "aws_subnet" "public" {
  count             = "${length(var.public_subnets)}"
  vpc_id            = "${aws_vpc.this.id}"
  cidr_block        = "${element(var.public_subnets, count.index)}"
  availability_zone = "${element(var.azs, count.index)}"

  map_public_ip_on_launch = true

  tags = "${merge(var.tags, map("Name", "${var.name}-public-${count.index + 1}"))}"
}

resource "aws_subnet" "private" {
  count             = "${length(var.private_subnets)}"
  vpc_id            = "${aws_vpc.this.id}"
  cidr_block        = "${element(var.private_subnets, count.index)}"
  availability_zone = "${element(var.azs, count.index)}"

  tags = "${merge(var.tags, map("Name", "${var.name}-private-${count.index + 1}"))}"
}

resource "aws_eip" "nat" {
  count = "${var.enable_nat_gateway ? length(var.azs) : 0}"
  vpc   = true
}

resource "aws_nat_gateway" "this" {
  count         = "${var.enable_nat_gateway ? length(var.azs) : 0}"
  allocation_id = "${element(aws_eip.nat.*.id, count.index)}"
  subnet_id     = "${element(aws_subnet.public.*.id, count.index)}"
}

modules/vpc/outputs.tf:

output "vpc_id" {
  description = "The ID of the VPC"
  value       = "${aws_vpc.this.id}"
}

output "public_subnet_ids" {
  description = "List of IDs of public subnets"
  value       = ["${aws_subnet.public.*.id}"]
}

output "private_subnet_ids" {
  description = "List of IDs of private subnets"
  value       = ["${aws_subnet.private.*.id}"]
}

Calling the module

In your root module (or another module that composes things):

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

  name = "myapp-prod"
  cidr = "10.0.0.0/16"
  azs  = ["us-east-1a", "us-east-1b", "us-east-1c"]

  public_subnets  = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  private_subnets = ["10.0.11.0/24", "10.0.12.0/24", "10.0.13.0/24"]

  enable_nat_gateway = true

  tags = {
    Terraform   = "true"
    Environment = "prod"
  }
}

# Use the outputs in other resources
resource "aws_eks_cluster" "main" {
  name     = "myapp-prod"
  role_arn = "${aws_iam_role.eks_cluster.arn}"

  vpc_config {
    subnet_ids = ["${module.vpc.private_subnet_ids}"]
  }
}

Versioning modules via Git tags

For modules shared across multiple projects, keep them in their own repo and reference them by Git tag. This is the pattern that actually works at scale:

module "vpc" {
  source = "git::https://github.com/myorg/terraform-modules.git//vpc?ref=v1.2.0"

  name = "myapp-prod"
  cidr = "10.0.0.0/16"
  # ...
}

The double slash (//) separates the repo URL from the subdirectory path within the repo. The ?ref= parameter pins to a specific tag, branch, or commit. Always pin to a tag in production — ref=main will bite you when someone pushes a breaking change and your next terraform init pulls it in silently.

Bump the tag, update the ref in your consuming configs, run terraform init to pull the new version. Simple and explicit. Terraform's module registry is also an option, but a private Git repo with tags is zero extra infrastructure.

The mistake I see most often

People write modules that are too big and too opinionated. A module that creates a VPC, three security groups, an ECS cluster, an ALB, and a Route53 record is not a module — it's an entire application stack. That's fine as a root module for a specific project, but it's useless as a reusable component.

Err on the side of smaller, more focused modules. Composition happens at the root module level, not inside a single monolithic module.