- Introduction
- What is a Terraform Module?
- Module Analogy: Functions in Programming
- Input Variables:
- Local Values:
- Output Values:
- Deploying Nginx on an Ubuntu EC2 Instance Using Terraform Modules
- Initialize our infrastructure:
- Conclusion
Introduction
Variables are fundamental building blocks in programming, acting as placeholders for dynamic data. They allow us to store and manipulate temporary values, enabling flexibility and adaptability in program logic—from simple to complex applications.
In this post, we'll explore the basics of Terraform values and Input variables. This is the foundation for working with Terraform, and in future posts, we’ll dive deeper into each type of Terraform value and Input variable.
At the end of each section, I’ll provide a hyperlink to a more detailed blog post on that specific topic, so you can easily explore it further. This approach allows us to gradually build our knowledge of Terraform concepts without being overwhelmed by their complexity.
Let’s get started!
What is a Terraform Module?
In Terraform, modules are essential building blocks that group multiple resources together for reuse and organization. Every Terraform configuration is part of a module, even if it's just a single .tf
file. Modules help manage complexity by allowing you to break down large infrastructure into smaller, manageable components.
Since values and input variables are often used inside modules, it’s important to understand the basics of modules to get the full picture of how Terraform values and variables work.
Simple Example of Terraform Modules
Let’s look at a very basic example of a Terraform setup with a root module (the main configuration) and a child module (a reusable component).
NOTE:
- The root module is your main entry point where you define and call other modules.
- The child module contains reusable infrastructure code.
Don't worry about below file structures and codes, we will deep dive into modules in an upcoming blog.
Directory Structure
project/
├── main.tf
├── modules/
│ └── child_module/
│ ├── variables.tf
│ ├── outputs.tf
│ └── child.tf
Root Module (main.tf
)
This is the root module, where you call other modules. In this case, we are using a simple child module:
# main.tf in the root module
module "example" {
source = "./modules/child_module" # Pointing to the child module
name = "my-instance" # Passing input variable to the child module
}
Child Module (child.tf
, variables.tf
, outputs.tf
)
Don’t worry about the code for now;, By the end of this post, you’ll be able to understand them with ease. Stay patient!
The child module contains the actual infrastructure resources, input variables, and outputs. It’s like a reusable block of code.
child.tf
# child.tf in the child module
resource "aws_instance" "example" {
ami = "ami-123456"
instance_type = "t2.micro"
tags = {
Name = var.name
}
}
variables.tf
Here, we define the input variables the child module expects:
# variables.tf in the child module
variable "name" {
type = string
description = "Name tag for the instance"
}
outputs.tf
And finally, we can define outputs to pass back information from the child module to the root module:
# outputs.tf in the child module
output "instance_id" {
value = aws_instance.example.id
}
Summary
- The root module is your main entry point where you define and call other modules.
- The child module contains reusable infrastructure code and can accept input variables and return outputs.
By organizing your code into modules, you can create reusable, modular infrastructure, which becomes crucial when dealing with Terraform values and input variables.
Module Analogy: Functions in Programming
If you’re familiar with traditional programming languages, you can think of Terraform modules as being similar to functions:
- Input variables are like function arguments — they allow you to pass data into the module.
- Output values are like function return values — they allow the module to return data back to the calling module.
- Local values are like a function’s temporary local variables — used within the module for intermediate calculations or storage.
Let’s break these down and understand each of them in turn.
Input Variables:
Input variables let you customize aspects of Terraform modules without altering the module's own source code. This functionality allows you to share modules across different Terraform configurations, making your module composable and reusable.
When you declare variables in the root module of your configuration, you can set their values using CLI options and environment variables. When you declare them in child modules, the calling module should pass values in the module block.
Feel free to deep dive into this blog for more understanding: How to Use Terraform Variables: Examples & Best Practices
Note: For brevity, input variables are often referred to as just "variables" or "Terraform variables" when it is clear from context what sort of variable is being discussed. Other kinds of variables in Terraform include environment variables (set by the shell where Terraform runs) and expression variables (used to indirectly represent a value in an expression).
Declaring an Input Variable
Each input variable accepted by a module must be declared using a variable
block.
here are some examples of input variables:
variable "instance_type" {
type = string
description = "Type of EC2 instance to be used"
}
variable "region" {
type = string
default = "us-east-1"
description = "AWS region to deploy resources in"
}
variable "db_password" {
type = string
description = "Password for the database"
sensitive = true
}
variable "availability_zone" {
type = string
description = "The availability zone for the EC2 instance"
nullable = true
}
variable "ami_id" {
type = string
description = "The AMI ID for the EC2 instance"
validation {
condition = length(var.ami_id) > 0
error_message = "The AMI ID must not be empty"
}
}
Arguments for Input Variables:
-
default
- A default value which then makes the variable optional. -
type
- This argument specifies what value types are accepted for the variable. -
description
- This specifies the input variable's documentation. -
validation
- A block to define validation rules, usually in addition to type constraints. -
sensitive
- Limits Terraform UI output when the variable is used in configuration. -
nullable
- Specify if the variable can be null within the module.
Types of Input Variables
Terraform input variables are categorized into two main types: simple and complex.
- Simple data types: String, Number, Bool
- Complex data types: List, Map, Tuple, Object, Set
The following snippets provide examples for each type:
String Type:
- Used to represent text values.
variable "instance_name" {
type = string
default = "my-instance"
}
Number Type:
- Represents numeric values, useful for counts or sizes.
variable "instance_count" {
type = number
default = 2
}
Boolean Type:
- Represents true or false values, ideal for feature flags.
variable "enable_monitoring" {
type = bool
default = true
}
List Type:
- An ordered collection of values of the same type.
variable "allowed_ips" {
type = list(string)
default = ["192.168.1.1", "192.168.1.2"]
}
Set Type:
- A unique, unordered collection of values.
variable "availability_zones" {
type = lt = ["us-east-1a", "us-east-1b"]
}
Tuple Type:
- A fixed-length collection of values with different types.
variable "example_tuple" {
type = tuple([string, number, bool])
default = ["ami-123456", 2, true]
}
Object Type:
- A collection of named attributes, each with a specific type.
variable "instance_config" {
type = object({
instance_type = string
disk_size = number
})
default = {
instance_type = "t2.micro"
disk_size = 30
}
}
Map Type:
- A collection of key-value pairs, where all keys are the same type.
variable "tags" {
type = map(string)
default = {
Name = "my-instance"
Project = "Terraform"
}
}
Note: At the later part of this post, we will learn how to pass values to input variables at run time through a real-world project.
Now move to local values.
Local Values:
Terraform Locals are named values which can be assigned and used in your code. It mainly serves the purpose of reducing duplication within the Terraform code. When you use Locals in the code, since you are reducing duplication of the same value, you also increase the readability of the code.
Feel free to deep dive into this blog for more understanding: Terraform Locals: What Are They, How to Use Them
Local Values vs Input Variables
Input Variables: These are used to pass dynamic values into a Terraform module from outside, allowing you to customize your configuration without hardcoding values. Input variables are like function arguments, enabling flexibility and reuse of your modules across different environments.
Local Values: These are used within a module to define temporary, reusable values that simplify complex expressions or calculations. Unlike input variables, local values cannot be overridden from outside the module—they are only available within the module where they are defined.
How to Use Local Values:
When you use a Terraform local in the code, there are two parts to it:
- First, declare the local along and assign a value
- Then, use the local name anywhere in the code where that value is needed
Local values in Terraform can be categorized into several types based on their data structures: simple (like strings, numbers, and booleans) and complex (such as lists, maps, tuples, and objects), allowing for flexibility and organization within your configuration.
here is an example of local block:
locals {
# String: Type of EC2 instance
instance_type = "t2.micro"
# Number: Number of EC2 instances to launch
instance_count = 3
# List: Allowed IP addresses
allowed_ips = [
"192.168.1.1",
"192.168.1.2"
]
# Map: Key-value pairs for tagging resources
tags = {
Name = "my-instance"
Project = "Terraform"
}
# Object: Configurations for the EC2 instance
instance_config = {
instance_type = local.instance_type
disk_size = 30
}
}
Note: At the later part of this post, we will learn how to use Local Values inside a module through a real-world project.
Output Values:
Terraform outputs serve two main purposes:
- Printing details: They display information about a resource, data source, local value, or variable after deployment.
- Exporting details: When using modules, they allow you to export specific details about resources, data sources, locals, or variables for use outside the module.
Feel free to deep dive into this blog for more understanding: Terraform Output Values : Complete Guide & Examples
How to Use Output Values
In Terraform, the output
block is used to define output values. These values let you display important details from your configuration or share them with other modules.
Syntax for Declaring Output Values
output "output_name" {
value = value_expression # The value you want to output, like a resource attribute
description = "Description of the output value" # Optional, but helps understand what the output represents
sensitive = true/false # Optional; hides the output if set to true (useful for sensitive data like passwords)
}
Let’s look at examples to understand how to use output values in different scenarios.
Examples of Output Values
1. Output from a Resource
When working with resources, you might want to extract specific details. For example, after creating an AWS EC2 instance, you might want to get its public IP to connect to it.
# Defining an AWS EC2 instance resource
resource "aws_instance" "example" {
ami = "ami-0c55b159cbfafe1f0" # Amazon Machine Image ID
instance_type = "t2.micro" # Instance type
}
# Output block to fetch the public IP of the created instance
output "instance_public_ip" {
value = aws_instance.example.public_ip # Fetching the public IP from the resource
description = "The public IP address of the EC2 instance"
}
In this example, aws_instance.example.public_ip
retrieves the public IP address from the created instance, and the output
block makes this value available for you to see when you run terraform apply
.
2. Output from a Data Source
Data sources fetch information from existing resources, and you can use outputs to display that data. For instance, you might want to know which AWS region your configuration is using.
# Defining a data source to get the current AWS region
data "aws_region" "current" {}
# Output block to display the AWS region
output "aws_current_region" {
value = data.aws_region.current.name # Getting the region name from the data source
description = "The current AWS region being used"
}
Here, data.aws_region.current.name
fetches the region name from the data source, and the output block makes this value visible.
3. Output from Local Values
Local values help define reusable expressions, and you can output them if needed. For example, let’s say you generated a unique S3 bucket name and want to display it.
# Defining local values
locals {
bucket_name = "my-app-bucket-${random_id.bucket.hex}" # Creating a unique bucket name
}
# Output block to display the local bucket name
output "local_bucket_name" {
value = local.bucket_name # Using the local value as the output
description = "The dynamically generated S3 bucket name"
}
In this case, local.bucket_name
is a local value, and the output block allows you to see the generated bucket name when you run Terraform commands.
4. Output from Child Module to Root Module
If you're using modules, you might want to pass information from a child module back to the root module. This allows the parent configuration to access values from the child.
In the Child Module:
# Output block in the child module to expose the VPC ID
output "vpc_id" {
value = aws_vpc.main.id # The VPC ID from the resource in the child module
description = "The VPC ID from the child module"
}
In the Root Module:
# Defining the module in the root configuration
module "network" {
source = "./modules/network" # Path to the child module
}
# Output block to use the child module's output
output "child_vpc_id" {
value = module.network.vpc_id # Accessing the output from the child module
description = "The VPC ID obtained from the child module"
}
Here, the child module exposes its vpc_id
using an output block, and the root module accesses this value through module.network.vpc_id
.
Congratulation!!!, you have learned fundamental concepts of the Terraform module, Input Variable, local values, and output values. Now time to use these concepts, in a hands-on project.
Deploying Nginx on an Ubuntu EC2 Instance Using Terraform Modules
The purpose of this guide is to demonstrate how to create an EC2 instance and deploy Nginx on it using Terraform modules. Through this process, we will explore the use of Terraform input variables and values.
If you get stuck at any point, you can refer to the code examples and configurations in my GitHub repo for this blog: Infrastructure-Nginx
Project Directory Structure:
Infrastructure-Nginx/
├── README.md
├── main.tf
├── outputs.tf
└── nginx-module/
├── ec2.tf
├── key-pair.tf
├── networking.tf
├── outputs.tf
├── scripts/
│ └── nginx-script.sh
├── variables.tf
└── version.tf
The Infrastructure-Nginx
directory serves as the root module, where we will invoke the child module named nginx-module
.
So, Let's first set up our nginx-module
.
Specify the Terraform Version:
Paste the following code snippet inside nginx-module/version.tf
.
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.56"
}
}
}
This configuration specifies that Terraform should use the AWS provider and ensures compatibility with Terraform version 1.0 or higher. The provider version is locked to ensure stability and prevent unexpected updates.
Define Networking for EC2 Instance:
In the nginx-module/networking.tf
file, add the following code:
# Create VPC
resource "aws_vpc" "main_vpc" {
cidr_block = var.vpc["cidr_block"]
tags = {
Name = "main-vpc"
}
}
# Create Internet Gateway
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main_vpc.id
tags = {
Name = "main-igw"
}
}
# Create a Public Subnet
resource "aws_subnet" "public_subnet" {
vpc_id = aws_vpc.main_vpc.id
cidr_block = var.subnet.cidr_block
availability_zone = var.subnet.availability_zone
map_public_ip_on_launch = var.subnet.map_public_ip_on_launch
tags = {
Name = "public-subnet"
}
}
# Create Route Table
resource "aws_route_table" "public_rt" {
vpc_id = aws_vpc.main_vpc.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
}
# Associate Route Table with Subnet
resource "aws_route_table_association" "public_rt_association" {
subnet_id = aws_subnet.public_subnet.id
route_table_id = aws_route_table.public_rt.id
}
# Create Security Group
resource "aws_security_group" "allow_ssh_http_https" {
vpc_id = aws_vpc.main_vpc.id
ingress {
from_port = var.ingress["ssh_port"]
to_port = var.ingress["ssh_port"]
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = var.ingress["http_port"]
to_port = var.ingress["http_port"]
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = var.ingress["https_port"]
to_port = var.ingress["https_port"]
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = var.egress_port
to_port = var.egress_port
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "allow-ssh-http-https"
}
}
This code creates a VPC with an internet gateway, a public subnet with a specified CIDR block and availability zone, and sets up a route table. It also defines a security group that allows only specific ingress and egress traffic.
Create AWS kay_pair for EC2:
Add the following code in key-pair.tf
:
resource "tls_private_key" "ssh_key" {
algorithm = var.tls_keys.algorithm
rsa_bits = var.tls_keys.rsa_bits
}
resource "local_file" "private_key" {
content = tls_private_key.ssh_key.private_key_pem
filename = var.tls_keys.private_key_filename
}
resource "local_file" "public_key" {
content = tls_private_key.ssh_key.public_key_openssh
filename = var.tls_keys.public_key_filename
}
resource "aws_key_pair" "deployer" {
key_name = var.aws_key_pair_name
public_key = tls_private_key.ssh_key.public_key_openssh
}
This configuration generates TLS keys and stores them in specified locations. The public key is used to create the AWS key pair.
Create EC2 Instance:
In ec2.tf
, add the following code:
# Create Ubuntu EC2 Instance
resource "aws_instance" "ubuntu_instance" {
ami = var.ec2_instance.ami
instance_type = var.ec2_instance.instance_type
associate_public_ip_address = var.ec2_instance.associate_public_ip_address
subnet_id = aws_subnet.public_subnet.id
vpc_security_group_ids = [aws_security_group.allow_ssh_http_https.id]
key_name = aws_key_pair.deployer.key_name
depends_on = [
aws_security_group.allow_ssh_http_https,
aws_internet_gateway.igw
]
user_data = file("${path.module}/scripts/nginx-script.sh")
tags = {
Name = "ubuntu-instance"
}
}
this will create an ubuntu instance and then use the nginx-script.sh
as user_data
to install and run nginx onto it.
Create the nginx-script.sh
:
In the scripts
folder, create a file named nginx-script.sh
:
#!/bin/bash
sudo apt update -y
sudo apt install -y nginx
# Create index.html with H1 tag in the default NGINX web directory
echo "<h1>Hello From Ubuntu EC2 Instance!!!</h1>" | sudo tee /var/www/html/index.html
# Restart NGINX to apply the changes
sudo systemctl restart nginx
this will greet Hello From Ubuntu EC2 Instance!!!
on port 80.
Create the variables:
In variables.tf
, add:
variable "tls_keys" {
description = "TLS key configuration: includes the algorithm used, RSA bit size, and filenames for the private and public keys."
type = object({
algorithm = string
rsa_bits = number
private_key_filename = string
public_key_filename = string
})
default = {
algorithm = "your_algorithm"
rsa_bits = 2048
private_key_filename = "your_private_key_path"
public_key_filename = "your_public_key_path"
}
}
variable "aws_key_pair_name" {
description = "The name of the AWS key pair to be used for SSH access."
type = string
default = "your_key_pair_name"
}
variable "vpc" {
description = "VPC configuration with CIDR block for defining the network range."
type = map(string)
default = {
cidr_block = "your_cidr_block"
}
}
variable "subnet" {
description = "Subnet configuration: includes CIDR block, availability zone, and whether to assign public IPs."
type = object({
cidr_block = string
availability_zone = string
map_public_ip_on_launch = bool
})
default = {
cidr_block = "your_subnet_cidr_block"
availability_zone = "your_availability_zone"
map_public_ip_on_launch = true
}
}
variable "ingress" {
description = "Ingress rules for allowed inbound traffic: HTTP, HTTPS, and SSH port numbers."
type = map(number)
default = {
http_port = 80
https_port = 443
ssh_port = 22
}
}
variable "egress_port" {
description = "Egress port configuration, representing the allowed outbound port range."
type = number
default = 0
}
variable "ec2_instance" {
description = "EC2 instance configuration: includes AMI ID, instance type, and whether to associate a public IP address."
type = object({
ami = string
instance_type = string
associate_public_ip_address = bool
})
default = {
ami = "your_ami_id"
instance_type = "your_instance_type"
associate_public_ip_address = true
}
}
Define all required variables with descriptions.
Create output values:
In outputs.tf
, add:
# Output the Public IPs
output "ubuntu_instance_public_ip" {
value = aws_instance.ubuntu_instance.public_ip
}
This output displays the public IP of the EC2 instance.
looks good, now time to use this module in the root module.
Define main.tf
in root module:
here we will define provider block
and give source to our nginx-module
then pass the values to our nginx-module input variables
. we will local values to pass the values to module.
locals {
tls_keys = {
algorithm = "RSA"
rsa_bits = 4096
private_key_filename = "./.ssh/terraform_rsa"
public_key_filename = "./.ssh/terraform_rsa.pub"
}
aws_key_pair_name = "ubuntu_ssh_key"
vpc = {
cidr_block = "10.0.0.0/16"
}
subnet = {
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a"
map_public_ip_on_launch = true
}
ingress = {
http_port = 80
https_port = 443
ssh_port = 22
}
egress_port = 0
ec2_instance = {
ami = "ami-0a0e5d9c7acc336f1"
instance_type = "t2.micro"
associate_public_ip_address = true
}
}
provider "aws" {
region = "us-east-1"
}
module "nginx-module" {
source = "./nginx-module"
tls_keys = local.tls_keys
aws_key_pair_name = local.aws_key_pair_name
vpc = local.vpc
subnet = local.subnet
ingress = local.ingress
egress_port = local.egress_port
ec2_instance = local.ec2_instance
}
output value for ubuntu public_ip:
create one output value to access the Ubuntu ip provided by the module.
output "ubuntu_instance_public_ip" {
value = module.nginx-module.ubuntu_instance_public_ip
}
Congratulations you just created the infrastructure for nginx on Ubuntu.
let's apply the infrastructure.
Initialize our infrastructure:
move to Infrastructure-Nginx/main.tf
and run the below command:
terraform init
Plan our infrastructure:
now run the below command to plan our infrastructure, so that we will know what will be deployed:
terraform plan
Apply our infrastructure:
run terraform apply
Confirm the changes by typing “yes”.
Awesome! You just created your Ubuntu EC2 instance via Terraform.
Check through AWS UI:
Navigate to the AWS Management Console to verify your instance and other resources. You can now view the public IP address and other details directly in the console.
Access Through Port 80 in Your Browser:
Open your web browser and enter http://your-public-ip:80 in the address bar, replacing your-public-ip with the EC2 instance's public IP. You should see your "Hello From Ubuntu EC2 Instance!!!" message.
Destroy the Infrastructure:
If you want to tear down the infrastructure you created, use the terraform destroy command
Confirm the changes by typing “yes”.
Delete all the resources defined in your configuration, ensuring a clean removal of everything Terraform created.
Conclusion
Terraform Modules offer a powerful way to organize and reuse infrastructure code, while input variables provide flexibility in customizing deployments. By leveraging these features, you can efficiently build and manage complex cloud infrastructures like deploying Nginx on an EC2 instance. So, grab your Terraform Lego set and start building your digital empire!
Top comments (0)