We created this one module for us that creates the infrastructure for use. We can create as many resources as we like in this module and maybe have the whole infrastructure deployment done using a single main.tf. That’s not always ideal. You would ideally like to categorize the resources you are creating and sometimes would like to control if a set of resources should be created or not. For example, we can split the current resources into two submodules: network and storage. Maybe in some situation we would just want to create the networking part but skip storage part. With just one variable we will be able to control if we want to create resources related to storage.

Split into modules

In our previous example we just two files in module folder but if we want to split the code modules namely network and storage, we will have structure like below:

├── main.tf
├── network
│   ├── main.tf
│   ├── outputs.tf
│   └── variables.tf
├── storage
│   ├── main.tf
│   └── variables.tf
└── variables.tf

Notice how the network and storage folder look like original module folder that has just files as main.tf and variables.tf. This is because the original module could also be used as a sub-module for any other module. If it’s not clear then just read through the doc and it should clear by the end of it.

The outermost main.tf is the file that will be responsible to call the submodules, i.e. network and storage. But it is possible to use these two modules without using outer main.tf. Any terraform code an reference the network module, pass the variables the network module needs and the module will create resources. Our outer main.tf will also do the same. Let’s see how.

Restructuring code to create submodules

The main.tf within the network module will look like this:

resource "aws_vpc" "tf_vpc" {
  cidr_block = var.vpc_cidr
}

resource "aws_subnet" "private_1" {
  vpc_id     = aws_vpc.tf_vpc.id
  cidr_block = var.subnet_private_1
  availability_zone = "us-east-1a"
  tags = {
    Name = "${var.unique_id}-private-1"
  }
}

resource "aws_subnet" "private_2" {
  vpc_id     = aws_vpc.tf_vpc.id
  cidr_block = var.subnet_private_2
  availability_zone = "us-east-1b"
  tags = {
    Name = "${var.unique_id}-private-2"
  }
}

The variables.tf file within the network module will look like this:

variable "unique_id" {
  
}

variable "vpc_cidr" {
  
}

variable "subnet_private_1" {
  
}

variable "subnet_private_2" {
  
}

Module outputs

Along with the main.tf and variables.tf in the network submodule, we also have another file called outputs.tf:

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

output "subnet_ids" {
  value = [aws_subnet.private_1.id,aws_subnet.private_2.id]
}

These outputs are available to the caller of the module so that the caller can it to create downstream resources. In our case, the outer main.tf is calling the network module like this:

module "network" {
  source = "./network"
  unique_id = var.unique_id
  vpc_cidr = var.vpc_cidr
  subnet_private_1 = var.subnet_private_1
  subnet_private_2 = var.subnet_private_2
}

module "storage" {
  source = "./storage"
  unique_id = var.unique_id
  database_name = var.database_name
  database_password = var.database_password
  database_username = var.database_username
  vpc_id = module.network.vpc_id
  sg_allowed_cidr = [var.vpc_cidr]
  subnet_group_ids = module.network.subnet_ids
}

terraform {
  backend "s3" {

  }
}

Notice how the storage module is fetching the value for subnet_group_ids by referring to module.network.subnet_ids. The module word is a static word that tell terraform that we are going to refer to a module next. The network word is the name of the ‘module’ resource. And then subnet_ids is an output generated by the module which is present in the outputs.tf of the network module. Basically, the module reference becomes like a resource that is accepting some variables and generating some outputs. That’s what a ‘module’ is supposed to do, right?

Even though it is not compulsory for a module to have outputs, but you will always find that whoever is calling your module will need some kind of outputs so that they can pass them further down for more resource creation. The storage module does not have any outputs because there is no resource that is using the database. Imagine deploying the application via terraform. Then we will have to pass the database hostname to the application, right? That’s when you can add outputs to the terraform module.

Here is how the storage module looks like: The main.tf file:

resource "aws_security_group" "allow_db" {
  name        = "${var.unique_id}-allow_db"
  description = "Allow DB access only within VPC"
  vpc_id      = var.vpc_id

  ingress {
    description      = "TLS from VPC"
    from_port        = 5432
    to_port          = 5432
    protocol         = "tcp"
    cidr_blocks      = var.sg_allowed_cidr
  }

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

  tags = {
    Name = "allow_this_vpc"
  }
}

resource "aws_db_instance" "tf_db" {
  allocated_storage    = 10
  db_name              = var.database_name
  engine               = "postgres"
  engine_version       = "13"
  instance_class       = "db.t3.micro"
  username             = var.database_username
  password             = var.database_password
  parameter_group_name = "default.postgres13"
  skip_final_snapshot  = true
  
  db_subnet_group_name = aws_db_subnet_group.subnet_group_1.id
  vpc_security_group_ids = [ aws_security_group.allow_db.id ]
}

resource "aws_db_subnet_group" "subnet_group_1" {
  name       = "${var.unique_id}-main"
  subnet_ids = var.subnet_group_ids

  tags = {
    Name = "${var.unique_id}-subnet-group-1"
  }
}

The variables.tf file:

variable "unique_id" {
  
}

variable "vpc_id" {
  
}

variable "sg_allowed_cidr" {
  type = list(string)
  default = ["0.0.0.0/0"]
}

variable "database_name" {
  
}

variable "database_password" {
  
}

variable "database_username" {
  
}

variable "subnet_group_ids" {
  type = list(string)
  default = []
}

Control submodule installation

You can decide if you want to deploy the storage submodule or not. You can simply add a variable to the outer variables.tf and then add one line in the outer main.tf for the user to control if the module should be installed. variables.tf:

.
.
variable "storage_enabled" {
  type = bool
  default = true
}

and in main.tf

module "storage" {
  count = var.storage_enabled ? 1 : 0
  source = "./storage"
  unique_id = var.unique_id
  .
  .
  .
}

This way the user calling the module can control if a set of resources should be created or not.

There is another benefit of using submodules which is creating a set of resources multiple times. In the above example we added the count variable but its value can either be 0 or 1. But it can have any value and the same module will be called multiple times to create multiple set of resources present in the storage module.

Note that having submodules is different than having resources in separate files within the same module. As mentioned in earlier chapters, for terraform it does not matter if your resources, outputs, module calls, are present in the same file or multiple files. Multiple files are created only for humans to have better readability and management.

Conclusion

Use terraform modules when you have a set of resources that the user can choose if they want or not. Controlling each resource creating is difficult in terraform. But if you put them all in one module then enabling and disabling that one module is easier.

Updated: