terraform
Why terraform
Choosing Terraform over cloud provider-specific tools like AWS CloudFormation or Azure ARM templates offers several advantages:
- Multi-cloud support: Terraform works across all major cloud providers, allowing you to manage infrastructure in a consistent way regardless of the cloud platform.
- Community and module ecosystem: It has a large community contributing to a public registry of modules, which helps you quickly use and customize infrastructure components.
- Feature parity and updates: Terraform support for cloud features is generally as current as the cloud providers' own tools, sometimes even quicker due to community contributions.
- Flexibility in state management: You can store Terraform's infrastructure state in various secure and version-controlled locations, giving you control over your environment.
IaC and Configuration Management
Terraform focuses on managing and provisioning your base infrastructure—like creating servers, networking, and storage—using code. It sets up the foundational resources but doesn't manage what runs inside those servers.
Configuration management tools like Puppet come into play after Terraform has created the infrastructure; they configure and manage the software and applications on those servers.
NOTE
So, think of Terraform as setting up a blank canvas (the infrastructure), and Puppet as painting the picture (configuring the software). This separation helps keep infrastructure setup and software configuration distinct and manageable.
How terraform works
The Terraform configuration file is structured into three main blocks:
terraformblock: specifies required providers and Terraform version constraintsproviderblock: configures the provider plugin, like choosing AWS and then the properties like AWS region and other connection details.resourceblock: defines the actual infrastructure components, such as an AWS instance.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 4.16"
}
}
required_version = ">= 1.2.0"
}
provider "aws" {
region = "us-east-2"
}
resource "aws_instance" "app_server" {
ami = "ami-0c7c4e3c6b4941f0f"
instance_type = "t2.micro"
tags = {
Name = "Lab-03-AWS-Instance"
}
}
NOTE
Based on this static code, terraform produces a directed acyclic resource graph to create a dependency order in which to create resources.
Then the basic workflow is:
- Write the code
- Run
terraform initto initialize the directory - Validate the changes with
terraform validateandterraform plan - Apply the infra with
terraform apply
Terraform CLI and config
Terraform state file
Terraform tracks the state of the infrastructure with a terraform.tfstate JSON file.
NOTE
A Terraform state file is a JSON-formatted text file that Terraform uses to keep track of the current state of your infrastructure.
- It records details about the resources Terraform manages, like your AWS instances and configurations.
- This file helps Terraform understand what exists in your environment so it can plan and apply only the necessary changes when you update your infrastructure code.
NOTE
The state file represents a source of truth for resource provisioning with Terraform.
The Terraform state file is a critical component that keeps track of the real-world infrastructure Terraform manages. It stores metadata about your AWS resources so Terraform knows what exists and how to manage it.
"Refreshing" the state means Terraform compares the information in the state file with the actual current state of your AWS infrastructure. This ensures Terraform's view is up to date before making any changes.
Here’s how some Terraform CLI commands interact with the state file:
-
terraform plan: Refreshes the state to reflect the current infrastructure, then shows what changes will be made based on your configuration.
-
terraform apply: Also refreshes the state, applies the planned changes to AWS, and updates the state file to reflect the new infrastructure.
-
terraform destroy: Refreshes the state, then removes all resources defined in the state file, updating the state to show that resources are gone.
Keeping the state file accurate through refreshing is essential for Terraform to manage your infrastructure reliably and avoid unexpected changes or errors.
Local state file
All Terraform CLI commands interact with the state file and modify it, and use it as a source of truth to provision or destroy cloud resources.
This means that if you want github actions or a remote server with a CI/CD pipeline to use terraform CLI commands and be up-to-date on the current state of your infra, you must use the terraform.tfstate file as a source of truth for both your local and remote environments.
However, to achieve this, you run into some issues:
- sensitive values are in plain text: because the
terraform.tfstatefile is just a JSON file, sensitive values like access keys and env vars are in plain text and cannot be checked into source control. - remotely storing
terraform.tfstatefile requires extra complexity: You need to figure out stuff like encryption, which backend to host, and how to pull down the state.
Remote storage
Remote storage of the terraform.tfstate file brings key benefits:
- CI/CD capabilities: now you can have CI/CD pipelines that use the
terraformCLI based on the state file to provision your infra and test it. - Collaboration: multiple people on your team can work on terraform and use the same state file.
Here are the three backends you can use for storing your terraform.tfstate file and how to configure them:
- terraform cloud: free storage of the
terraform.tfstatefile and a first class code integration for pulling it down and marking the terraform environment as using remote state configuration.
terraform {
backend "remote" {
organization = "my-org"
workspaces {
name = "my-workspace"
}
}
}
- S3: AWS-managed storage using S3 and DynamoDB of the
terraform.tfstatefile and a first class code integration for pulling it down and marking the terraform environment as using remote state configuration.`
terraform {
backend "s3" {
bucket = "devops-directive-tf-state"
key = "tf-infra/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-locking"
encrypt = true
}
}
Terraform Cloud flow
Just use this:
terraform {
backend "remote" {
organization = "devops-directive"
workspaces {
name = "devops-directive-terraform-course"
}
}
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 3.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
S3 flow
Here is how to make the S3-based remote storage of the terraform.tfstate file work, starting off first using a local terraform.tfstate file:
- Provision the infra with terraform, making sure everything is named exactly:
resource "aws_s3_bucket" "terraform_state" {
bucket = "devops-directive-tf-state"
force_destroy = true
versioning {
enabled = true
}
server_side_encryption_configuration {
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
}
resource "aws_dynamodb_table" "terraform_locks" {
name = "terraform-state-locking"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
- Run
terraform apply - Specify the remote backend to be of the
"s3"type, so from now on you will use the remote state file stored in S3.
terraform {
backend "s3" {
bucket = "devops-directive-tf-state"
key = "tf-infra/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-locking"
encrypt = true
}
}
Here's the full flow:
terraform {
#############################################################
## AFTER RUNNING TERRAFORM APPLY (WITH LOCAL BACKEND)
## YOU WILL UNCOMMENT THIS CODE THEN RERUN TERRAFORM INIT
## TO SWITCH FROM LOCAL BACKEND TO REMOTE AWS BACKEND
#############################################################
# backend "s3" {
# bucket = "devops-directive-tf-state" # REPLACE WITH YOUR BUCKET NAME
# key = "03-basics/import-bootstrap/terraform.tfstate"
# region = "us-east-1"
# dynamodb_table = "terraform-state-locking"
# encrypt = true
# }
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 3.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_s3_bucket" "terraform_state" {
bucket = "devops-directive-tf-state" # REPLACE WITH YOUR BUCKET NAME
force_destroy = true
}
resource "aws_s3_bucket_versioning" "terraform_bucket_versioning" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state_crypto_conf" {
bucket = aws_s3_bucket.terraform_state.bucket
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_dynamodb_table" "terraform_locks" {
name = "terraform-state-locking"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
Basic workflow
All of these commands interact with the state file and modify it, and use it as a source of truth to provision or destroy cloud resources.
terraform init
The terraform init command initializes your working directory for Terraform.
It sets up the backend (usually local at first), downloads and installs the necessary provider plugins like AWS, and creates a lock file (.terraform.lock.hcl) that records the provider versions and selections.
You can safely run this command multiple times—it will recheck for updates and ensure your environment is ready to build infrastructure with Terraform.
terraform validate
The terraform validate command checks your Terraform configuration files for syntax errors and correctness before you proceed to planning or applying infrastructure changes.
- It helps catch issues like misplaced commas or incorrect argument formats by providing clear error messages with file and line details.
- You can also run it with a
-jsonoption to get machine-readable output, useful for automation.
Using terraform validate regularly ensures your code is error-free and ready to be applied, making your infrastructure management smoother and more reliable.
terraform plan
The terraform plan command generates a detailed preview of the changes Terraform will make to your infrastructure based on your current configuration.
It shows what resources will be created, changed, or destroyed without actually applying those changes yet.
- This helps you verify your setup before making any real modifications.
- You can also save the plan to a file to apply it later, ensuring consistency between planning and applying stages.
planning destruction
If you want to see a plan of what will happen and what resources will either get replaced, updated, orphaned, or destroyed upon using the terraform destroy command, then you should use the -destroy flag with the terraform plan command:
terraform plan -destroy
terraform apply
The terraform apply command is the step where Terraform actually builds the infrastructure you've defined in your configuration.
- It first shows you the execution plan again and asks for your confirmation before proceeding.
- Once you confirm by typing "yes," it creates the resources on AWS and generates a state file to track the current infrastructure.
This command is crucial because it turns your code into real cloud infrastructure, but it’s important to review the plan carefully and ensure your AWS credentials are properly configured before applying changes.
update by replacement
If you want to apply changes by replacing cloud resources, use the -replace flag and specify a resource to replace (delete then recreate)
terraform apply -replace="$RESOURCE_TYPE.$LOGICAL_ID"
terraform destroy
The terraform destroy command looks at the state file and destroys all infra provisioned by terraform.
terraform show
The terraform show command goes to the TF state file and outputs the details of all provisioned resources.
The terraform state show <resource_type>.<logical_id> command is used to show details of a specific resource.
If you have multiple terraform.tfstate files since those files are scoped within a directory, you can specify the state file to use and query from for the terraform state show command with the -state option like so:
terraform state show -state="../terraform.tfstate" resource_type.logical_id

Terraform basics
First terraform
// 1. create terraform config
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
}
}
}
// 2. create provider config
provider "aws" {
region = "us-west-2"
}
// 3. define variables
variable "instance_type" {
description = "Type of EC2 instance to provision"
default = "t3.nano"
}
data "aws_ami" "app_ami" {
most_recent = true
filter {
name = "name"
values = ["bitnami-tomcat-*-x86_64-hvm-ebs-nami"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
owners = ["979382823631"] # Bitnami
}
data "aws_vpc" "default" {
default = true
}
// 4. create resources
resource "aws_instance" "blog" {
ami = data.aws_ami.app_ami.id
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.blog.id]
tags = {
Name = "Learning Terraform"
}
}
resource "aws_security_group" "blog" {
name = "blog"
tags = {
Terraform = "true"
}
vpc_id = data.aws_vpc.default.id
}
resource "aws_security_group_rule" "blog_http_in" {
type = "ingress"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
security_group_id = aws_security_group.blog.id
}
resource "aws_security_group_rule" "blog_https_in" {
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
security_group_id = aws_security_group.blog.id
}
resource "aws_security_group_rule" "blog_everything_out" {
type = "egress"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
security_group_id = aws_security_group.blog.id
}
Learning to create resources
The typical Terraform workflow involves three main steps:
- Write your Terraform code to define the infrastructure you want to create.
- Initialize your working directory using the command
terraform init, which sets up the directory and downloads necessary provider plugins. - Apply your infrastructure with
terraform apply, which actually provisions the resources defined in your code.
Here's that workflow in action:
-
Create terraform resource in
.tffile, with the resource type being"local_file"to refer to a local file:
resource "local_file" "hello_world" {
content = "Hello, World!"
filename = "${path.module}/hello_world.txt"
}
-
Run
terraform init -
Run
terraform planwhich is basically likecdk synth -
Run
terraform applyto apply the changes -
Run
terraform destroyto destroy all the resources managed by terraform
First EC2 instance
-
Load AWS access keys into shell session as env vars
aws sso login --profile sandbox
-
Add EC2 instance, give it logical ID of
"web"
resource "aws_instance" "web" {
instance_type = "t2.micro"
ami = "ami-0f8a61b66d1accaee"
tags = {
Name = "HelloWorld"
}
}
-
Create variables that can be used elsewhere, and specify variables with the
variablekeyword and the cloud provider to use with the"aws"keyword:
variable "aws_region" {
description = "The AWS region to deploy resources in"
type = string
default = "us-east-1"
}
provider "aws" {
region = var.aws_region
}
File structure
Once you provision the resources using terraform, all the provisioned resource info will be put into a file called terraform.tfstate, which contains all the details of all cloud assets it created from the most recent terraform apply call.
terraform.tfvars: Holds configuration parameters you can tweak, like the number of servers or instance types.- Main Terraform files: These define the actual infrastructure resources you want to create, such as networks, servers, and load balancers.
terraform.tfstatefile: This file tracks the current state of your infrastructure, recording what Terraform has created or modified. It’s crucial for managing changes accurately.- Modules directory: Contains reusable Terraform code modules that handle specific parts of your infrastructure, like networking or compute resources.
Variables
A variable in Terraform is basically a typed key-value pair .
You have many different ways of creating variables in terraform and there are different types of variables:
- input variables: variables that are declared but don't have a value until runtime, and you inject values in
terraform applyor throughterraform.tfvars.- Use the
variableblock for this.
- Use the
- local variables: standard key-value pairs that act basically as config objects that you can immediately use in your terraform code.
- Use the
localsblock for this.
- Use the
Local variables
Local variables are basically just key-value pairs with values already there, so you can access them through the locals namespace.
locals {
service_name = "My service"
owner = "me"
}
Input variables
You can define input variables in Terraform that you can then use throughout your Terraform files, using the variable block like so, with these meta-arguments:
description: the human-facing description of what the variable does or represents.default: the default value of the variabletype: the data type of the variable, default is string.sensitive: a boolean type, where if you passtrue, then it marks the variable and sensitive and will mask its value when outputted.
variable "instance_type" {
description = "Type of EC2 instance to provision"
default = "t3.nano"
}
For input variables defined with the variable block, you can access variables through the var namespace via dot notation:
var.<variable_name>
Here's an example of defining a variable then using it:
variable "instance_type" {
description = "Type of EC2 instance to provision"
default = "t3.nano"
}
resource "aws_instance" "blog" {
ami = data.aws_ami.app_ami.id
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.blog.id]
tags = {
Name = "Learning Terraform"
}
}
Data types
You have these three primitive data types you can use for variables:
- string: A sequence of characters. Requires double-quotes.
- Ex:
"hello world!".
- Ex:
- number: A numeric value. Does not use double-quotes.
- Ex:
2,20, or17.2014.
- Ex:
- bool: A boolean value.
- Ex:
trueorfalse. - These are used with conditional logic.
- Ex:
- null: A
nullvalue is an omission of value.- Defaults will be used if the variable has one.
- Used often in conditional expressions.
You also have these complex data types:
- list: (also known as tuple) A sequence of values. Each value sits in double-quotes and are comma-separated. Uses square brackets
[]as delimiters.
variable names {
type: list
default = ["Alice", "Bob", "Charlie", "Denise"]
}
- map: (also known as object) a group of values using labels and values - collectively known as key pairs. Uses curly braces
{}as delimiters.- Ex:
{name = "Bob", occupation = "Programmer"} - In this case,
nameis a label, and"Bob"is a value for that label.
- Ex:
variable "ami_filter" {
description = "Name filter and owner for AMI"
type = object ({
name = string
owner = string
})
default = {
name = "bitnami-tomcat-*-x86_64-hvm-ebs-nami"
owner = "979382823631" # Bitnami
}
}
Here is a list of complex data types in action:
# Input variable definitions
variable "vpc_name" {
description = "Name of VPC"
type = string
default = "example-vpc"
}
variable "vpc_cidr" {
description = "CIDR block for VPC"
type = string
default = "10.0.0.0/16"
}
variable "vpc_azs" {
description = "Availability zones for VPC"
type = list(string)
default = ["us-east-2a", "us-east-2b", "us-east-2c"]
}
variable "vpc_private_subnets" {
description = "Private subnets for VPC"
type = list(string)
default = ["10.0.1.0/24", "10.0.2.0/24"]
}
variable "vpc_public_subnets" {
description = "Public subnets for VPC"
type = list(string)
default = ["10.0.101.0/24", "10.0.102.0/24"]
}
variable "vpc_enable_nat_gateway" {
description = "Enable NAT gateway for VPC"
type = bool
default = true
}
variable "vpc_tags" {
description = "Tags to apply to resources created by VPC module"
type = map(string)
default = {
Terraform = "true"
Environment = "testing"
}
}
Object type
In Terraform, use an object type variable when you want to group related configuration values together logically, like an AMI filter with both a name and owner, or an environment with a name and network prefix.
This helps keep your code organized and makes it easier to manage complex settings as a single unit.
First, create variables like so:
variable "instance_type" {
description = "Type of EC2 instance to provision"
default = "t3.nano"
}
// define object type
variable "ami_filter" {
description = "Name filter and owner for AMI"
type = object ({
name = string
owner = string
})
default = {
name = "bitnami-tomcat-*-x86_64-hvm-ebs-nami"
owner = "979382823631" # Bitnami
}
}
// define object variable type
variable "environment" {
description = "Deployment environment"
type = object ({
name = string
network_prefix = string
})
default = {
name = "dev"
network_prefix = "10.0"
}
}
variable "asg_min" {
description = "Minimum instance count for the ASG"
default = 1
}
variable "asg_max" {
description = "Maximum instance count for the ASG"
default = 2
}
Then you can use those advanced variables like this:
data "aws_ami" "app_ami" {
most_recent = true
filter {
name = "name"
values = [var.ami_filter.name]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
owners = [var.ami_filter.owner] # Bitnami
}
Injecting values for input variables
Here is the priority order for the injection precedence of the different ways to inject values for the input variables at runtime, lowest to highest.
- Default value in a declaration block
TF_VAR_<VARNAME>environment variables in the current shell sessionterraform.tfvarsfile*.auto.tfvarsfile- Using
terraform applyorterraform planwith the-varflag.
terraform.tfvars
The terraform.tfvars file is a special file that uses .env syntax where in one file, you define a bunch of key value pairs in the syntax below, and then terraform will automatically inject those key-value pairs in that file as values for the terraform variables that you define with the variable block.
key=value
NOTE
The main purpose of this file is to supply values at runtime for the variables you define with the variable block.
Here is a full example:
- Create
variableblocks for those variables to define them in the terraform.
variable "ami_id" {
description = "The AMI ID for the localstack Amazon Linux image"
type = string
default = "ami-024f768332f0"
}
variable "ec2_instance_type" {
type = string
default = "t2.micro"
}
variable "instance_name" {
type = string
}
- Create a
terraform.tfvarsfile with the variables you want to use:
ec2_instance_type = "t2.micro"
instance_name = "MyInstanceName"
ami_id = "ami-024f768332f0"
- Use the variables, which will be populated on the
varobject:
resource "aws_instance" "web" {
instance_type = var.ec2_instance_type
ami = var.ami_id
}
Variables with CLI
You can run terraform apply and pass in variable values to have those values get injected into runtime:
terraform apply -var="var_name=value"
Variable runtime validation
If you want runtime type safety and validation like zod for terraform variables, then you can use the validation meta-argument on the variable block like so, where the validation.error_message error is thrown when validation.condition is false.
variable "short_variable" {
type = string
// shows error_message when condition == false
validation {
condition = length(var.short_variable) < 4,
error_message = "Short must be less than 4!"
}
}
Outputs
Outputs are like cloudformation outputs, defined by a output top level block.
output "ec2_instance_id" {
value = aws_instance.web.id
description = "The ID of the EC2 instance"
}
output "ec2_instance_public_ip" {
value = aws_instance.web.public_ip
description = "The public IP address of the EC2 instance"
}
output "ec2_instance_private_ip" {
value = aws_instance.web.private_ip
description = "The private IP address of the EC2 instance"
}
output "ec2_instance_public_dns" {
value = aws_instance.web.public_dns
description = "The public DNS name of the EC2 instance"
}
Here are the meta-arguments to supply to an output block:
value: the output valuedescription: the human-facing description for the output
Output CLI
You can also imperatively use the terraform CLI to fetch raw output values from your terraform code after the fact.
This works by looking through the tfstate file to find all previous outputs, because the outputs are stored in that file.
Here is the basic syntax:
terraform output -raw $OUTPUT_NAME
Data blocks
In Terraform, a data block is used to fetch or reference existing information about infrastructure that Terraform doesn't directly manage.
In the example below, here are the values we get access to by creating the data blocks:
data.aws_ami.app_ami: returns a reference to the AMI object filtered by name, virtualization type, and owners.data.aws_vpc.default: returnstrue, meaning that the default value for whether to use a VPC for instances is true.
data "aws_ami" "app_ami" {
most_recent = true
filter {
name = "name"
values = ["bitnami-tomcat-*-x86_64-hvm-ebs-nami"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
owners = ["979382823631"] # Bitnami
}
data "aws_vpc" "default" {
default = true
}
You can then access data block variable values through the data namespace via dot notation.
resource "aws_instance" "blog" {
ami = data.aws_ami.app_ami.id
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.blog.id]
tags = {
Name = "Learning Terraform"
}
}
Expressions and functions

Template interpolation
In Terraform you can use template string interpolation syntax to use a variable's value within a string with the ${} syntax.
module "blog_vpc" {
source = "terraform-aws-modules/vpc/aws"
name = var.environment.name
cidr = "${var.environment.network_prefix}.0.0/16"
azs = ["us-west-2a","us-west-2b","us-west-2c"]
public_subnets = ["${var.environment.network_prefix}.101.0/24", "${var.environment.network_prefix}.102.0/24", "${var.environment.network_prefix}.103.0/24"]
tags = {
Terraform = "true"
Environment = var.environment.name
}
}
Ternary expressions + Conditionals
You have all these operators in terraform:
!, - # (multiplication by -1)
*, /, % # (modulo)
+, - # (subtraction)
>, >=, <, <= # (comparison)
==, != # (equality)
&& # (AND)
|| # (OR)
You can use ternary operators to supply a value conditionally.
condition ? true_val : false_val
# For example
var.a != "" ? var.a : "default-a"
Numeric functions
abs()
ceil()
floor()
log()
max()
parseint() # parse as integer
pow()
signum() # sign of number
String functions
chomp() # remove newlines at end
format() # format number
formatlist()
indent()
join()
lower()
regex()
regexall()
replace()
split()
strrev() # reverse string
substr()
title()
trim()
trimprefix()
trimsuffix()
trimspace()
upper()
Resources
Basics and meta-arguments
The nice thing about using Terraform is that the logical ID of a resource is a combination of the resource type and the actual human-facing logical ID used.
This means that you can scope logical IDs to a resource type and thus reuse logical IDs across your application as long as the combination of resource type and logical ID is unique.
You can also refer to the properties of another resource using dot-notation syntax on the namespace of the resource type, like aws_security_group.
resource_type.logical_id.property
You specify the properties of a resource through meta-arguments, like the AMI ID or instance type of an EC2 instance, but you also have these built-in metaarguments:
count: Number, defines the number of duplicates you want to create of this resource.depends_on: String, defines which resource is a dependency, so doesn't create the current resource until the resource it depends on is createdfor_each: Array, basically populates an array from some value you you inject it with, then you can useeachto refer to the current element in the iteration.- This lets you create multiple resources dynamically through an array with more control than just creating duplicates with
count.
- This lets you create multiple resources dynamically through an array with more control than just creating duplicates with
lifecycle: Object, controls the lifecycle of a resource with properties like being replaced upon update and what property changes to ignore on update.
depends_on
Based on the meta-arguments and the references you have to other resources and variables throughout the entire code, Terraform automatically creates a dependency graph with the order of dependencies to create.
If two resources depend on each other but not on each other's data, then use the depends_on identifier, which manually specifies a resource's dependency on another resource
resource "aws_iam_role" "example" {
name = "example"
assume_role_policy = "..."
}
resource "aws_iam_instance_profile" "example" {
role = aws_iam_role.example.name
}
resource "aws_iam_role_policy" "example" {
name = "example"
role = aws_iam_role.example.name
policy = jsonencode({
"Statement" = [{
"Action" = "s3:*",
"Effect" = "Allow",
}],
})
}
resource "aws_instance" "example" {
ami = "ami-a1b2c3d4"
instance_type = "t2.micro"
iam_instance_profile = aws_iam_instance_profile.example.name
# creating the ec2 instance won't work without
# the policy being created first, so we write this explicit dependcy
depends_on = [
aws_iam_role_policy.example,
]
}
lifecycle
A set of meta arguments to control terraform lifecycle behavior for a resource, especially upon a configuration update.
create_before_destroy: Boolean. Iftrue, creates the new resource before destroying the old oneignore_changes: a list of properties/meta-arguments to ignore for drift detection, meaning that if you manually change one of these properties in the AWS console, it's ok, terraform doesn't automatically fix drift for those irgnored properties being changed.prevent_destroy: reject any plan that would destroy this resource.
resource "aws_instance" "server" {
ami = "ami-a1b2c3d4"
instance_type = "t2.micro"
lifecycle {
create_before_destroy = true
ignore_changes = [
# Some resources have metadata
# modified automatically outside
# of Terraform
tags
]
}
}
count
The count meta-argument defines the number of instances you want to create of this resource.
count = 0: don't create this resource, want none of it.count = 1: create this resource once.count = 2: create two instances of this resource.
Then if you want to dynamically change a property of the resource depending on the current count, you can use the count.index variable, which represents the current iterating number.
resource "aws_instance" "server" {
count = 4 # create four EC2 instances
ami = "ami-a1b2c3d4"
instance_type = "t2.micro"
tags = {
Name = "Server ${count.index}"
}
}
foreach
- Define an array variable with a value
- Within a resource, provide an array value to the
for_eachmeta-argument - Now you can use the
eachvariable, which has theeach.keyrepresenting the value of the current iterating element.
locals {
subnet_ids = toset([
"subnet-abcdef",
"subnet-012345",
])
}
resource "aws_instance" "server" {
for_each = local.subnet_ids
ami = "ami-a1b2c3d4"
instance_type = "t2.micro"
subnet_id = each.key
tags = {
Name = "Server ${each.key}"
}
}
EC2 instances
Instance basics: setup security group
- Create SSH security group: here is how to create a security group that allows all ingress SSH traffic in on port 22:
resource "aws_security_group" "sg_ssh_allow_all" {
name = "sg_ssh_allow_all"
description = "Allow SSH traffic"
// Allow all ingress to port 22 from any SSH process on the internet.
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
- Create HTTP security group: here is how to create a security group that allows all ingress HTTP traffic in on port 80 and any egress traffic from the instance to anywhere.
resource "aws_security_group" "sg_http_allow_all" {
name = "sg_http_allow_all"
description = "Allow HTTP traffic"
// Allow all ingress to port 80 from any HTTP process on the internet.
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
// Allow all egress to any process on the internet.
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
- Then to create an instance with security groups attached, we have to specify three important meta-arguments:
instance_type: the instance type of the EC2 instanceami: the image ID of the AMI to usevpc_security_group_ids: the array of security groups to use and attach to the instance, referenced by their IDs
resource "aws_instance" "web" {
instance_type = "t2.micro"
ami = "ami-61ad6e59d7b0" // ubuntu 26.04
vpc_security_group_ids = [
aws_security_group.sg_ssh_allow_all.id,
aws_security_group.sg_http_allow_all.id
]
tags = {
Name = "HelloWorld"
}
}
resource "aws_security_group" "sg_ssh_allow_all" {
name = "sg_ssh_allow_all"
description = "Allow SSH traffic"
// Allow all ingress to port 22 from any SSH process on the internet.
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_security_group" "sg_http_allow_all" {
name = "sg_http_allow_all"
description = "Allow HTTP traffic"
// Allow all ingress to port 80 from any HTTP process on the internet.
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
// Allow all egress to any process on the internet.
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
Instance basics: add key pair
- Create an SSH key pair locally, in a
keysdirectory, call it something likekeys/ec2_instance_key
ssh-keygen -t ed25519
- Create the keypair resource, specify the path to the public key that was created, which should end in a
.pubextension.
resource "aws_key_pair" "my_key" {
key_name = "my-key"
public_key = file("keys/ec2_instance_key.pub")
}
- specify that you want to use a key pair for the instance and reference the key pair name to use.
resource "aws_instance" "web" {
instance_type = "t2.micro"
ami = var.ec2_instance_config.ami
# specify which key pair to use
key_name = aws_key_pair.my_key.key_name
# need to allow SSH on port 22 from anywhere, else no point.
vpc_security_group_ids = [
aws_security_group.sg_ssh_allow_all.id,
]
}
resource "aws_security_group" "sg_ssh_allow_all" {
name = "sg_ssh_allow_all"
description = "Allow SSH traffic"
// Allow all ingress to port 22 from any SSH process on the internet.
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
- You can then connect via public IP or public DNS via
ssh, using the private key as the connection input file, and connecting asroot.
ssh -i keys/ec2_instance_key root@ec2-172-17-0-3.localhost.localstack.cloud
Instance basics: outputs
output "ec2_instance_id" {
value = aws_instance.web.id
description = "The ID of the EC2 instance"
}
output "ec2_instance_public_ip" {
value = aws_instance.web.public_ip
description = "The public IP address of the EC2 instance"
}
output "ec2_instance_private_ip" {
value = aws_instance.web.private_ip
description = "The private IP address of the EC2 instance"
}
output "ec2_instance_public_dns" {
value = aws_instance.web.public_dns
description = "The public DNS name of the EC2 instance"
}
Instance basics: user data scripts + Cloud init scripts
A cloud-init script is a declarative YAML configuration script that automates the setup and initialization of a cloud server right after it’s created.
- A cloud-init script automates tasks like creating user groups and users with SSH access, updating the system, installing software such as the Apache web server and Python pip, and setting up a static website using MkDocs.
- Essentially, it simplifies and automates the manual steps you’d normally perform on a Linux server, making your infrastructure setup faster, repeatable, and more efficient within your Terraform workflow on AWS.
Here is a cloud init YAML script that does two things:
- Declare NGINX as one of the packages to be installed in the
packagessection - Use
systemctlto enable and start NGINX in the background in theruncmdsection:
#cloud-config
packages:
- nginx
runcmd:
- systemctl start nginx
- systemctl enable nginx
Now once your cloud init script is created, all you have to do to register it as a user data script for an instance is to use the user_data meta argument when creating an instance in Terraform and reference the filepath to the cloud init script:
resource "aws_instance" "web" {
instance_type = "t2.micro"
ami = var.ec2_instance_config.ami
user_data = file("script/cloudinit.yaml")
}
Instances + data blocks
data "aws_ami" "app_ami" {
most_recent = true
filter {
name = "name"
values = ["bitnami-tomcat-*-x86_64-hvm-ebs-nami"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
owners = ["979382823631"] # Bitnami
}
data "aws_vpc" "default" {
default = true
}
resource "aws_instance" "blog" {
ami = data.aws_ami.app_ami.id
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.blog.id]
tags = {
Name = "Learning Terraform"
}
}
resource "aws_eip" "blog" {
instance = aws_instance.blog.id
vpc = true
}
resource "aws_security_group" "blog" {
name = "blog"
tags = {
Terraform = "true"
}
vpc_id = data.aws_vpc.default.id
}
resource "aws_security_group_rule" "blog_http_in" {
type = "ingress"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
security_group_id = aws_security_group.blog.id
}
resource "aws_security_group_rule" "blog_https_in" {
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
security_group_id = aws_security_group.blog.id
}
resource "aws_security_group_rule" "blog_everything_out" {
type = "egress"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
security_group_id = aws_security_group.blog.id
}
ALB
- Define the variables
variable "ami" {
description = "Amazon machine image to use for ec2 instance"
type = string
default = "ami-011899242bb902164" # Ubuntu 20.04 LTS // us-east-1
}
variable "instance_type" {
description = "ec2 instance type"
type = string
default = "t2.micro"
}
variable "domain" {
description = "Domain for website"
type = string
}
- Fetch the default VPC and default subnet via
datablocks
data "aws_vpc" "default_vpc" {
default = true
}
data "aws_subnet_ids" "default_subnet" {
vpc_id = data.aws_vpc.default_vpc.id
}
- Create a security group that allows all ingress on port 8080
resource "aws_security_group" "instances" {
name = "instance-security-group"
}
resource "aws_security_group_rule" "allow_http_inbound" {
type = "ingress"
security_group_id = aws_security_group.instances.id
from_port = 8080
to_port = 8080
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
- Create two EC2 instances that are basically copies of each other, and use the same security group:
resource "aws_instance" "instance_1" {
ami = var.ami
instance_type = var.instance_type
security_groups = [aws_security_group.instances.name]
user_data = <<-EOF
#!/bin/bash
echo "Hello, World 1" > index.html
python3 -m http.server 8080 &
EOF
}
resource "aws_instance" "instance_2" {
ami = var.ami
instance_type = var.instance_type
security_groups = [aws_security_group.instances.name]
user_data = <<-EOF
#!/bin/bash
echo "Hello, World 2" > index.html
python3 -m http.server 8080 &
EOF
}
- Create a target group for the load balancer and attach instances to it.
resource "aws_lb_target_group" "instances" {
name = "example-target-group"
port = 8080
protocol = "HTTP"
vpc_id = data.aws_vpc.default_vpc.id
health_check {
path = "/"
protocol = "HTTP"
matcher = "200"
interval = 15
timeout = 3
healthy_threshold = 2
unhealthy_threshold = 2
}
}
resource "aws_lb_target_group_attachment" "instance_1" {
target_group_arn = aws_lb_target_group.instances.arn
target_id = aws_instance.instance_1.id
port = 8080
}
resource "aws_lb_target_group_attachment" "instance_2" {
target_group_arn = aws_lb_target_group.instances.arn
target_id = aws_instance.instance_2.id
port = 8080
}
- Create a security group for the load balancer that allows all ingress on port 80 and egress traffic to anywhere on the internet:
resource "aws_security_group" "alb" {
name = "alb-security-group"
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
- Create a load balancer and a listener component:
resource "aws_lb" "load_balancer" {
name = "web-app-lb"
load_balancer_type = "application"
subnets = data.aws_subnet_ids.default_subnet.ids
security_groups = [aws_security_group.alb.id]
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.load_balancer.arn
port = 80
protocol = "HTTP"
# By default, return a simple 404 page
default_action {
type = "fixed-response"
fixed_response {
content_type = "text/plain"
message_body = "404: page not found"
status_code = 404
}
}
}
resource "aws_lb_listener_rule" "instances" {
listener_arn = aws_lb_listener.http.arn
priority = 100
condition {
path_pattern {
values = ["*"]
}
}
action {
type = "forward"
target_group_arn = aws_lb_target_group.instances.arn
}
}
- Add a DNS A record for a domain you own via route 53 to alias it to the load balancer DNS.
resource "aws_route53_zone" "primary" {
name = var.domain
}
resource "aws_route53_record" "root" {
zone_id = aws_route53_zone.primary.zone_id
name = var.domain
type = "A"
alias {
name = aws_lb.load_balancer.dns_name
zone_id = aws_lb.load_balancer.zone_id
evaluate_target_health = true
}
}
ALB + ASG
-
Set Up the Load Balancer Module:
-
Use the Terraform AWS ALB module from the Terraform Registry.
-
Configure it with your VPC ID and public subnets from your VPC module.
-
Attach your existing security group by passing its ID as a list.
-
Define listeners to forward HTTP traffic to a target group (no HTTPS for simplicity).
-
Remove inline security group rules and access logs if not needed.
-
-
Create the Target Group:
-
Define an AWS load balancer target group resource.
-
Set the name and link it to your VPC using the VPC ID.
-
-
Connect Target Group to Instances:
-
Initially, create a target group attachment resource to link the target group to your single instance.
-
When using an autoscaling group, remove this attachment.
-
-
Set Up the Auto Scaling Group Module:
- Use the Terraform AWS Auto Scaling module from the Registry.
- Configure parameters like
min_size(e.g., 1),max_size(e.g., 2), andvpc_zone_identifierwith your public subnets. - Use a launch template name (e.g., “blog”) to let the module handle instance provisioning.
- Set security groups and image ID (AMI) for instances.
- Add a traffic source attachment block to connect the ASG to the ALB’s target group by referencing the target group ARN.
Here's the complete code.
data "aws_ami" "app_ami" {
most_recent = true
filter {
name = "name"
values = ["bitnami-tomcat-*-x86_64-hvm-ebs-nami"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
owners = ["979382823631"] # Bitnami
}
module "blog_vpc" {
source = "terraform-aws-modules/vpc/aws"
name = "dev"
cidr = "10.0.0.0/16"
azs = ["us-west-2a","us-west-2b","us-west-2c"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
tags = {
Terraform = "true"
Environment = "dev"
}
}
module "blog_autoscaling" {
source = "terraform-aws-modules/autoscaling/aws"
version = "6.5.2"
name = "blog"
min_size = 1
max_size = 2
vpc_zone_identifier = module.blog_vpc.public_subnets
target_group_arns = module.blog_alb.target_group_arns
security_groups = [module.blog_sg.security_group_id]
instance_type = var.instance_type
image_id = data.aws_ami.app_ami.id
}
module "blog_alb" {
source = "terraform-aws-modules/alb/aws"
version = "~> 6.0"
name = "blog-alb"
load_balancer_type = "application"
vpc_id = module.blog_vpc.vpc_id
subnets = module.blog_vpc.public_subnets
security_groups = [module.blog_sg.security_group_id]
target_groups = [
{
name_prefix = "blog-"
backend_protocol = "HTTP"
backend_port = 80
target_type = "instance"
}
]
http_tcp_listeners = [
{
port = 80
protocol = "HTTP"
target_group_index = 0
}
]
tags = {
Environment = "dev"
}
}
module "blog_sg" {
source = "terraform-aws-modules/security-group/aws"
version = "4.13.0"
vpc_id = module.blog_vpc.vpc_id
name = "blog"
ingress_rules = ["https-443-tcp","http-80-tcp"]
ingress_cidr_blocks = ["0.0.0.0/0"]
egress_rules = ["all-all"]
egress_cidr_blocks = ["0.0.0.0/0"]
}
S3 Buckets
Here is a basic S3 bucket:
resource "aws_s3_bucket" "bucket" {
bucket = "devops-directive-web-app-data"
force_destroy = true
versioning {
enabled = true
}
}
bucket: String. the bucket nameforce_destroy: Boolean. Whether or not to force destroy the bucket when runningterraform destroy.versioningenabled: enables object versioning.
Server-side encryption
resource "aws_s3_bucket" "bucket" {
bucket = "devops-directive-web-app-data"
force_destroy = true
versioning {
enabled = true
}
server_side_encryption_configuration {
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
}
Terraform modules
Basics
Why modules?
A Terraform module is a way to group related Terraform code into a single, logical unit that can be managed together.
Modules help you organize and reuse code, making it easier to manage complex infrastructure.
Using a Terraform module for security groups simplifies your code by bundling complex configurations into reusable, manageable blocks.
Instead of manually defining every rule and detail, the module handles much of that for you, reducing errors and saving time.
Modules also make your infrastructure code cleaner and easier to maintain, and you can use pre-built, tested modules from the Terraform Registry, which helps ensure best practices and consistency in your setups.
WHat is a module
Modules are the main way to package and reuse resource configurations with Terraform. They are containers for multiple resources that are used together.
A module consists of a collection of .tf and .tf.json files kept together within a sub directory.
Module sources can be one of these:
- local paths: paths to custom modules you created
- terraform registry: pointing to a third-party module

Consuming a module
A module has two main purposes:
- import resources, run terraform code: Runs terraform code from a subdirectory, basically provisioning any resources you define in a module.
- provide access to module variables via
modulenamespace: Themodulenamespace is used to access output variables defined on the module.
NOTE
A module can use variable and output blocks, but the content of a module is encapsulated and works like a black box, so the only way you can access variable values from a module using the module namespace is through exposing output blocks on it.
Here is an example of using a third-party source to create a module and then consuming it via the module namespace:
- Use the third-party source to create a module
module "ec2_instances" {
source = "terraform-aws-modules/ec2-instance/aws"
version = "5.0.0"
name = "Cluster-A-${count.index}"
count = 3
ami = "ami-097a2df4ac947655f"
instance_type = "t2.micro"
vpc_security_group_ids = [module.vpc.default_security_group_id]
subnet_id = module.vpc.public_subnets[0]
tags = {
Terraform = "true"
Environment = "testing"
Why = "Because we can"
}
}
- Consume the module outputs via the
module.<module_name>syntax:
output "ec2_instance_private_ips" {
description = "Private IP addresses of the EC2 instances"
value = module.ec2_instances.*.private_ip
}
Creating a module
There are two types of modules you can create
- root module: default module containing all
.tffiles in the project root - child module: A separate external module referred to from a
.tffile
Here is a basic example:
- Create a subdirectory where your main terraform config lives, call it
main - Create a subdirectory where your module lives, call it
module - When defining a module inside the main code, point the
sourcemeta-argument to the path of the module directory, then supply key-value pairs for the input variables declared in the module code.
module "my_module" {
source = "../module"
# Input vars to inject
bucket_name = "mybucketname2022"
// ...
}
Now you've basically imported all the resources, dynamically injecting variables to change the configuration without actually copying and pasting the code.
Here is a more involved example:
- Create a subdirectory called
modules/webserver, declare these input variables:
variable "vpc_id" {
type = string
description = "VPC ID"
}
variable "cidr_block" {
type = string
description = "CIDR BLOCK"
}
variable "ami" {
type = string
description = "AMI for the webserver instance"
# Pick an AMI that exists within your region and is free tier eligible
}
variable "instance_type" {
type = string
description = "Instance type"
# Go with t2.micro for free tier eligible AMIs
}
variable "webserver_name" {
type = string
description = "Name of the webserver"
}
- Create these resources in the module:
terraform {
required_version = ">= 1.3.0"
}
resource "aws_subnet" "web_subnet" {
vpc_id = var.vpc_id
cidr_block = var.cidr_block
}
resource "aws_instance" "webserver" {
ami = var.ami
instance_type = var.instance_type
subnet_id = aws_subnet.web_subnet.id
tags = {
Name = "${var.webserver_name} webserver"
}
}
- Create and edit a
main.tffile. Add the following:- provider block: Define a provider here to declare this as the main module.
- module block: Here you will call the module (with the source argument), and assign values to the variables that are described in the module.
module "webserver-dave" {
source = "../modules/webserver"
vpc_id = aws_vpc.main.id
cidr_block = "10.0.0.0/16"
ami = "ami-0c7c4e3c6b4941f0f"
# Remember, select an AMI that exists in your AWS region.
# If in doubt, use the AMI above and the us-east-2 region.
instance_type = "t2.micro"
webserver_name = "Dave's"
}
Using third-party modules
Basics
# Input variable definitions
variable "vpc_name" {
description = "Name of VPC"
type = string
default = "example-vpc"
}
variable "vpc_cidr" {
description = "CIDR block for VPC"
type = string
default = "10.0.0.0/16"
}
variable "vpc_azs" {
description = "Availability zones for VPC"
type = list(string)
default = ["us-east-2a", "us-east-2b", "us-east-2c"]
}
variable "vpc_private_subnets" {
description = "Private subnets for VPC"
type = list(string)
default = ["10.0.1.0/24", "10.0.2.0/24"]
}
variable "vpc_public_subnets" {
description = "Public subnets for VPC"
type = list(string)
default = ["10.0.101.0/24", "10.0.102.0/24"]
}
variable "vpc_enable_nat_gateway" {
description = "Enable NAT gateway for VPC"
type = bool
default = true
}
variable "vpc_tags" {
description = "Tags to apply to resources created by VPC module"
type = map(string)
default = {
Terraform = "true"
Environment = "testing"
}
}
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "4.66"
}
}
required_version = ">= 1.4.6"
}
provider "aws" {
region = "us-east-2"
}
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
# To download the latest module, simply omit the version argument.
# However, if you wanted a specific module version, you could list it as shown below.
# This version was released in 2023.
version = "4.0.2"
name = var.vpc_name
cidr = var.vpc_cidr
azs = var.vpc_azs
private_subnets = var.vpc_private_subnets
public_subnets = var.vpc_public_subnets
enable_nat_gateway = var.vpc_enable_nat_gateway
tags = var.vpc_tags
}
module "ec2_instances" {
source = "terraform-aws-modules/ec2-instance/aws"
version = "5.0.0"
name = "Cluster-A-${count.index}"
count = 3
ami = "ami-097a2df4ac947655f"
instance_type = "t2.micro"
vpc_security_group_ids = [module.vpc.default_security_group_id]
subnet_id = module.vpc.public_subnets[0]
tags = {
Terraform = "true"
Environment = "testing"
Why = "Because we can"
}
}
Example with security group module
Here are the steps where we use an official terraform module for security groups to make the process of creating a security group simpler:
- in-house way: Create a
security_groupresource and then for each rule you want to add to the security group, create asecurity_group_ruleresource. - module way: Just define meta-arguments for the security group module to create a security group with rules all at once.
So here are the steps to implement the module way:
- Create a module that creates a VPC:
module "blog_vpc" {
source = "terraform-aws-modules/vpc/aws"
name = "dev"
cidr = "10.0.0.0/16"
azs = ["us-west-2a","us-west-2b","us-west-2c"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
tags = {
Terraform = "true"
Environment = "dev"
}
}
- Create a module that creates a security group with these rules:
- ingress: allow ingress from any source IP to HTTPS port 443 and HTTP port 80
- egress: allow all traffic from any process and port combo to all destinations.
module "blog_sg" {
source = "terraform-aws-modules/security-group/aws"
version = "4.13.0"
vpc_id = data.aws_vpc.default.id
name = "blog"
ingress_rules = ["https-443-tcp","http-80-tcp"]
ingress_cidr_blocks = ["0.0.0.0/0"]
egress_rules = ["all-all"]
egress_cidr_blocks = ["0.0.0.0/0"]
}
- Use the module as a security group reference:
resource "aws_instance" "blog" {
ami = "ami-2342343242"
instance_type = t2.micro
vpc_security_group_ids = [module.blog_sg.security_group_id]
tags = {
Name = "Learning Terraform"
}
}
And here it is all complete:
data "aws_ami" "app_ami" {
most_recent = true
filter {
name = "name"
values = ["bitnami-tomcat-*-x86_64-hvm-ebs-nami"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
owners = ["979382823631"] # Bitnami
}
data "aws_vpc" "default" {
default = true
}
resource "aws_instance" "blog" {
ami = data.aws_ami.app_ami.id
instance_type = var.instance_type
vpc_security_group_ids = [module.blog_sg.security_group_id]
tags = {
Name = "Learning Terraform"
}
}
module "blog_sg" {
source = "terraform-aws-modules/security-group/aws"
version = "4.13.0"
vpc_id = data.aws_vpc.default.id
name = "blog"
ingress_rules = ["https-443-tcp","http-80-tcp"]
ingress_cidr_blocks = ["0.0.0.0/0"]
egress_rules = ["all-all"]
egress_cidr_blocks = ["0.0.0.0/0"]
}
module "blog_vpc" {
source = "terraform-aws-modules/vpc/aws"
name = "dev"
cidr = "10.0.0.0/16"
azs = ["us-west-2a","us-west-2b","us-west-2c"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
tags = {
Terraform = "true"
Environment = "dev"
}
}
Organizing code with modules across environments
If you want to organize your infrastructure as code across different environments like local, production, or staging, then you have two main approaches here:
- Workspaces, which is a first-class object in Terraform that you can use to separate different environments.
- Having a special file structure by using modules and then subfolders that use those modules and override them differently with input variable injection.

Workspaces approach
Workspaces are controlled through the terraform workspace CLI and is just syntactic sugar for injecting the current workspace name as the terraform.workspace global variable now available in your code.
NOTE
Having no workspace is just being in the default workspace.
- pro: The workspaces approach is easy to get started with and the
terraform.workspacevariable is easy to use, and also minimizes code duplication. - con: one state file instead of multiple state files for the multiple environments, and is prone to human error, overwrite state file with different workspaces.
Here are the CLI commands:
terraform workspace new <workspace-name>: creates a new workspaceterraform workspace select <workspace-name>: switches to the specified workspaceteraform workspace list: lists all workspaces.
Here are the steps to use workspaces well:
- Create a new workspace:
terraform workspace new $ENVIRONMENT_NAME
- Use the
terraform.workspacevariable to dynamically do different things in the IaC depending on the current workspace.
locals {
environment_name = terraform.workspace
}
module "web_app" {
source = "../../06-organization-and-modules/web-app-module"
# Input Variables
bucket_prefix = "web-app-data-${local.environment_name}"
domain = "devopsdeployed.com"
environment_name = local.environment_name
instance_type = "t2.micro"
create_dns_zone = terraform.workspace == "production" ? true : false
db_name = "${local.environment_name}mydb"
db_user = "foo"
db_pass = var.db_pass
}
- Build out the IaC for this environment/workspace:
terraform plan
terraform apply
File directory approach
When using Terraform modules and multiple environments, a good practice is to organize your code into separate directories within the same repository, which offers the following propertiesL
- Pro - Isolation of backends: decreased potential of human error, and each subfolder/environment has its own
terraform.tfstatefile so you can isolate different environments for the backends. - Con - code duplication: there is more code duplication, but it can be minimized with shared modules.
Here are the two folder types you need for this approach:
-
Modules Directory: Contains reusable Terraform code grouped logically (e.g., a module for your blog infrastructure). This keeps your infrastructure code modular and manageable.
-
Environments Directory: Contains subdirectories for each environment (like
dev,staging,prod). Each environment directory holds configuration files and provider settings specific to that environment.
This structure allows you to keep modules and environment-specific configurations side by side, making it easier to manage infrastructure without juggling multiple repositories or complex pull requests.
It also helps Terraform understand which environment it’s working with by specifying the working directory accordingly.
Here’s a clear, step-by-step guide to modularizing your Terraform code based on the approach taught in the course:
-
Pull Out Configuration Values into Variables:
-
Start by moving hard-coded values from your main Terraform files (like
main.tf) into a separatevariables.tffile. -
Define variables with types, defaults, and descriptions to make your code flexible and reusable.
-
-
Create a Module Directory:
-
In your repo root, create a folder named
modules. -
Inside
modules, create a subfolder for your module, e.g.,blog. -
Move your main Terraform files (
main.tf,variables.tf,outputs.tf, etc.) into this module folder.
-
-
Define Outputs in the Module:
-
Add an
outputs.tffile in your module to expose useful information, like the DNS name of a load balancer. -
Outputs let other parts of your Terraform code access values from the module.
-
-
Create Environment Directories:
-
At the root level, create an
environmentsfolder. -
Inside
environments, create subfolders for each environment, e.g.,dev,staging,prod. -
Each environment folder will contain Terraform configuration files specific to that environment.
-
-
Reference the Module in Environment Configurations:
-
In each environment folder, create a
main.tfthat calls your module using themoduleblock. -
Use the
sourceattribute to point to your module path, e.g.,../../modules/blog. -
Pass any required variables to customize the module per environment.
-
-
Organize Provider and Backend Configurations:
-
Keep provider settings (like AWS credentials) and backend configurations (state storage) in the environment folders.
-
This keeps environment-specific settings isolated.
-
-
Manage Terraform State Carefully:
- When moving resources into modules, use Terraform’s
movedblocks to update the state file without recreating resources.
- When moving resources into modules, use Terraform’s
Testing Terraform
Terraform CI/CD
Testing CI/CD pipeline
This is a Github Action that uses a terraform codebase with a remote state file and then runs terraform plan and tests the output of that command.
name: "Terraform Plan"
on:
pull_request:
env:
TF_CLOUD_ORGANIZATION: "YOUR-ORGANIZATION-HERE"
TF_API_TOKEN: "${{ secrets.TF_API_TOKEN }}"
TF_WORKSPACE: "learn-terraform-github-actions"
CONFIG_DIRECTORY: "./"
jobs:
terraform:
if: github.repository != 'hashicorp-education/learn-terraform-github-actions'
name: "Terraform Plan"
runs-on: ubuntu-latest
permissions:
# so GitHub can check out this repo using the default github.token
contents: read
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Upload Configuration
uses: hashicorp/tfc-workflows-github/actions/upload-configuration@v1.0.0
id: plan-upload
with:
workspace: ${{ env.TF_WORKSPACE }}
directory: ${{ env.CONFIG_DIRECTORY }}
speculative: true
- name: Create Plan Run
uses: hashicorp/tfc-workflows-github/actions/create-run@v1.0.0
id: plan-run
with:
workspace: ${{ env.TF_WORKSPACE }}
configuration_version: ${{ steps.plan-upload.outputs.configuration_version_id }}
plan_only: true
- name: Get Plan Output
uses: hashicorp/tfc-workflows-github/actions/plan-output@v1.0.0
id: plan-output
with:
plan: ${{ fromJSON(steps.plan-run.outputs.payload).data.relationships.plan.data.id }}
- name: Update PR
uses: actions/github-script@v6
id: plan-comment
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
// 1. Retrieve existing bot comments for the PR
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.find(comment => {
return comment.user.type === 'Bot' && comment.body.includes('Terraform Cloud Plan Output')
});
const output = `#### Terraform Cloud Plan Output
\`\`\`
Plan: ${{ steps.plan-output.outputs.add }} to add, ${{ steps.plan-output.outputs.change }} to change, ${{ steps.plan-output.outputs.destroy }} to destroy.
\`\`\`
[Terraform Cloud Plan](${{ steps.plan-run.outputs.run_link }})
`;
// 3. Delete previous comment so PR timeline makes sense
if (botComment) {
github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
});
}
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
});
Provision the resources
name: "Terraform Apply"
on:
push:
branches:
- main
env:
TF_CLOUD_ORGANIZATION: "YOUR-ORGANIZATION-HERE"
TF_API_TOKEN: "${{ secrets.TF_API_TOKEN }}"
TF_WORKSPACE: "learn-terraform-github-actions"
CONFIG_DIRECTORY: "./"
jobs:
terraform:
if: github.repository != 'hashicorp-education/learn-terraform-github-actions'
name: "Terraform Apply"
runs-on: ubuntu-latest
permissions: # granular permissions
# so GitHub can check out this repo using the default github.token
contents: read
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Upload Configuration
uses: hashicorp/tfc-workflows-github/actions/upload-configuration@v1.0.0
id: apply-upload
with:
workspace: ${{ env.TF_WORKSPACE }}
directory: ${{ env.CONFIG_DIRECTORY }}
- name: Create Apply Run
uses: hashicorp/tfc-workflows-github/actions/create-run@v1.0.0
id: apply-run
with:
workspace: ${{ env.TF_WORKSPACE }}
configuration_version: ${{ steps.apply-upload.outputs.configuration_version_id }}
- name: Apply
uses: hashicorp/tfc-workflows-github/actions/apply-run@v1.0.0
if: fromJSON(steps.apply-run.outputs.payload).data.attributes.actions.IsConfirmable
id: apply
with:
run: ${{ steps.apply-run.outputs.run_id }}
comment: "Apply Run from GitHub Actions CI ${{ github.sha }}"