OpenTofu Foundations: Building Composable Infrastructure-as-Code Modules (Part 2)
Welcome to week two of the OpenTofu workshop! This week, we'll dive into the concept of modular infrastructure using OpenTofu. Modules are a powerful way to organize your code, promote reusability, and enforce best practices in Infrastructure as Code (IaC). By the end of this session, you'll be able to create your own modules, use existing ones, and apply best practices for modular infrastructure design.
This is part 2 of a 10 week workshop. Check out part 1 or watch the recorded session here.
Duration: 1 hour
Course Objectives
- Understand what modules are and why they are important.
- Learn how to create reusable modules.
- Refactor the Week 1 code into modules.
- Import and use modules in your configurations.
- Apply best practices for modular infrastructure design.
Key Commands
Commands you'll use to manage modules and your infrastructure:
tofu init
tofu plan
tofu apply
tofu destroy
Prerequisites
- Completion of Week 1 or equivalent knowledge.
- An AWS Account.
- OpenTofu CLI installed.
Walkthrough
1. Understanding Modules
What is a module?
In OpenTofu, a module is a container for multiple resources that are used together. Modules are the basic building blocks of your infrastructure, allowing you to group resources logically and reuse code across different configurations.
Why use modules?
- Reusability: Write code once and use it in multiple places.
- Maintainability: Easier to manage and update code.
- Abstraction: Simplify complex configurations.
- Collaboration: Share modules within teams or the community.
2. Modularizing the EC2 Instance and Database
In Week 1, we created an EC2 instance (aws_instance
) running WordPress and an AWS DB instance (aws_db_instance
) for the MariaDB database. Now, we'll create modules for both the EC2 instance and the database to promote reusability and better organization.
Step 2.1: Create Module Directory Structure
Create directories for your modules:
mkdir -p modules/aws_instance
mkdir -p modules/aws_db_instance
Step 2.2: Create the EC2 Instance Module
Navigate to the aws_instance
module directory:
cd modules/aws_instance
touch {main.tf,variables.tf,outputs.tf}
main.tf
resource "aws_instance" "this" {
ami = var.ami
instance_type = var.instance_type
associate_public_ip_address = true
vpc_security_group_ids = [aws_security_group.this.id]
user_data = var.user_data
tags = merge(
var.tags,
{
Name = var.name_prefix
}
)
}
resource "aws_security_group" "this" {
name = "${var.name_prefix}-web-service"
description = var.description
ingress {
description = "HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "Allow all outbound traffic"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = var.tags
}
variables.tf
variable "name_prefix" {
description = "Name prefix for all the resources"
type = string
}
variable "description" {
description = "Description for the security group"
type = string
default = "OpenTofu Foundations internet access for EC2 instance"
}
variable "ami" {
description = "AMI ID for the EC2 instance"
type = string
default = "ami-08578967e04feedea" # Amazon Linux 2 AMI
validation {
condition = length(regex("^ami-[0-9a-z]{17}$", var.ami)) > 0
error_message = "AMI must start with \"ami-\"."
}
}
variable "instance_type" {
description = "Instance type for the EC2 instance"
type = string
default = "t2.micro"
validation {
condition = contains(["t2.micro", "t3.micro"], var.instance_type)
error_message = "Allowed instance types are \"t2.micro\", \"t3.micro\"."
}
}
variable "user_data" {
description = "User data script to initialize the instance"
type = string
default = ""
}
variable "tags" {
description = "Tags to apply to resources"
type = map(string)
default = {}
}
outputs.tf
output "instance_id" {
description = "ID of the EC2 instance"
value = aws_instance.this.id
}
output "public_ip" {
description = "Public IP of the EC2 instance"
value = aws_instance.this.public_ip
}
output "security_group_id" {
description = "ID of the security group"
value = aws_security_group.this.id
}
Step 2.3: Create the Database Module
Navigate to the aws_db_instance
module directory:
cd ../aws_db_instance
touch {main.tf,variables.tf,outputs.tf}
main.tf
data "aws_vpc" "default" {
filter {
name = "isDefault"
values = ["true"]
}
}
resource "aws_db_instance" "this" {
identifier = var.name_prefix
instance_class = var.instance_class
allocated_storage = var.allocated_storage
engine = var.engine
engine_version = var.engine_version
db_name = var.db_name
username = var.username
password = var.password
vpc_security_group_ids = [aws_security_group.this.id]
skip_final_snapshot = true
tags = var.tags
}
resource "aws_security_group" "this" {
name = "${var.name_prefix}-mariadb"
description = "Allow access to MariaDB"
ingress {
from_port = 3306
to_port = 3306
protocol = "tcp"
cidr_blocks = [data.aws_vpc.default.cidr_block]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = var.tags
}
variables.tf
variable "name_prefix" {
description = "Identifier for the database instance"
type = string
}
variable "instance_class" {
description = "Instance class for the DB instance"
type = string
default = "db.t3.micro"
}
variable "allocated_storage" {
description = "Allocated storage in GB"
type = number
default = 20
}
variable "engine" {
description = "Database engine"
type = string
default = "mariadb"
}
variable "engine_version" {
description = "Database engine version"
type = string
default = "10.6"
}
variable "db_name" {
description = "Name of the database"
type = string
}
variable "username" {
description = "Master username for the database"
type = string
}
variable "password" {
description = "Master password for the database"
type = string
}
variable "tags" {
description = "Tags to apply to the database instance"
type = map(string)
default = {}
}
outputs.tf
output "endpoint" {
description = "Database endpoint"
value = aws_db_instance.this.endpoint
}
output "username" {
description = "Database master username"
value = aws_db_instance.this.username
}
output "password" {
description = "Database master password"
value = aws_db_instance.this.password
}
output "db_name" {
description = "Database name"
value = aws_db_instance.this.db_name
}
output "security_group_id" {
description = "ID of the database security group"
value = aws_security_group.this.id
}
Step 2.4: Return to Root Directory
Navigate back to the root directory:
cd ../../
3. Using the Modules in Your Configuration
Now that we've created modules for the aws_instance
and aws_db_instance
, let's use them in our main configuration.
Step 3.1: Set Up the Root Configuration
Create or modify main.tf
in the root directory:
main.tf
:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.68"
}
}
}
provider "aws" {
region = "us-west-2"
default_tags {
tags = {
environment = "dev"
project = "opentofu-foundations"
}
}
}
# Data source to get the default VPC
data "aws_vpc" "default" {
filter {
name = "isDefault"
values = ["true"]
}
}
# Module for Database Instance
module "aws_db_instance" {
source = "./modules/aws_db_instance"
name_prefix = "week2-db"
db_name = "wordpress"
username = "admin"
password = "yourpassword" # In production, use a secure method for passwords
ingress_cidr_blocks = [data.aws_vpc.default.cidr_block]
tags = {
Owner = "YourName"
}
}
# Module for EC2 Instance
module "aws_instance" {
source = "./modules/aws_instance"
name_prefix = "week2-instance"
ami = "ami-08578967e04feedea" # Amazon Linux 2 AMI
instance_type = "t2.micro"
user_data = <<-EOF
#!/bin/bash
yum update -y
amazon-linux-extras install docker -y
service docker start
usermod -a -G docker ec2-user
docker run -d \
-e WORDPRESS_DB_HOST=${module.aws_db_instance.endpoint} \
-e WORDPRESS_DB_USER=${module.aws_db_instance.username} \
-e WORDPRESS_DB_PASSWORD=${module.aws_db_instance.password} \
-e WORDPRESS_DB_NAME=${module.aws_db_instance.db_name} \
-p 80:80 ${var.image.name}:${var.image.tag}
EOF
tags = {
Owner = "YourName"
}
}
variable "image" {
type = object({
name = string
tag = string
})
default = {
name = "wordpress"
tag = "latest"
}
}
Note: We've aligned the resource names with those in Week 1, using aws_instance
and aws_db_instance
. We are also using data "aws_vpc" "default"
to retrieve the default VPC's CIDR block.
Step 3.2: Create outputs.tf
to Access Module Outputs
outputs.tf
:
output "instance_public_ip" {
description = "Public IP of the EC2 instance"
value = module.aws_instance.public_ip
}
output "db_endpoint" {
description = "Endpoint of the database instance"
value = module.aws_db_instance.endpoint
}
4. Initialize and Apply the Configuration
Step 4.1: Initialize OpenTofu
Run:
tofu init
This will download the AWS provider and initialize the modules.
Step 4.2: Plan the Deployment
Run:
tofu plan
Review the plan to ensure it matches your expectations. You should see resources for the EC2 instance, database instance, and their associated security groups being created.
Step 4.3: Apply the Configuration
Run:
tofu apply --auto-approve
This will create the resources as defined in your configuration. Note that the database instance may take several minutes to become available.
5. Verify the Deployment
Step 5.1: Retrieve Outputs
Run:
tofu output
You should see the public IP address of your EC2 instance and the endpoint of your database instance.
Example output:
instance_public_ip = "54.123.45.67"
db_endpoint = "week2-db.abcdefghij.us-west-2.rds.amazonaws.com"
Step 5.2: Test the Instance
Open a web browser and navigate to https://<instance_public_ip>
. You should see the WordPress setup screen.
6. Best Practices for Module Design
- Version Control: Use versioning for your modules to manage changes and ensure stability.
- Documentation: Provide clear documentation for inputs, outputs, and usage instructions.
- Minimal Exposure: Only expose necessary variables to keep modules simple and user-friendly.
- Default Values: Provide sensible defaults for variables where appropriate.
- Consistent Naming: Use consistent naming conventions for resources and variables.
- Outputs: Expose useful outputs to make the module more flexible.
- Error Handling: Use validations to catch incorrect variable values.
7. Cleaning Up
Don't forget to destroy your infrastructure to avoid unnecessary charges:
tofu destroy -auto-approve
Conclusion
In this module, we expanded on the infrastructure from Week 1 by modularizing the aws_instance
and aws_db_instance
resources. We learned how to create reusable modules, manage dependencies between them, and apply best practices for module design.
Key Takeaways:
- Modules: Created custom modules for the
aws_instance
andaws_db_instance
. - Variables and Outputs: Defined inputs and outputs to make modules flexible and reusable.
- Inter-Module Communication: Used outputs from one module as inputs to another.
- Best Practices: Applied best practices in module design for maintainability and collaboration.
Challenges
Want to keep practicing before Week 3? Here are some challenges:
- Enhance the Database Module: Add variables to configure more database options, such as backup retention, multi-AZ deployments, or storage type. Add validations to some of the variables for the database
variables.tf
(instance_class
,allocated_storage
,engine
,engine_version
). Setpassword
inaws_db_instance/variables.tf
to sensitive. - Parameterize Security Groups: Modify the security group definitions for
aws_instance
andaws_db_instance
to accept lists of ports and protocols as variables. - Use AWS Secrets Manager: Store the database password in AWS Secrets Manager (or SSM) and retrieve it in your configuration.
- Create a VPC Module: Create a module for VPC components like subnets, route tables, and internet gateways.
- Implement Module Versioning: Tag your modules with versions and test upgrading between versions:
module "vpc" {
source = "github.com/my-repo/my-module?ref=sha1234"
}
Happy coding! See you in Week 3, where we'll explore functions and control structures in OpenTofu.
These posts will be updated weekly, subscribe to our newsletter for updates or register for the free live workshop.
Need to learn OpenTofu?
Sign up for the FREE 10 week workshop. Bite-sized one hour per week, instructor lead IaC hands-on labs.