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
}