Introduction to Terraform
In Part 8, we explored the world of Infrastructure as Code and surveyed the landscape of tools available. Now it's time to go deep on the tool that has become the de facto standard for multi-cloud infrastructure provisioning: HashiCorp Terraform.
Terraform lets you define infrastructure in human-readable configuration files that you can version, reuse, and share. It manages resources across hundreds of cloud providers and services through a consistent workflow: write code, preview changes, apply changes.
Why Terraform Won the IaC War
Among the many IaC tools available, Terraform emerged as the dominant choice for infrastructure provisioning. Here's why:
| Factor | Terraform Advantage | Alternatives |
|---|---|---|
| Multi-Cloud | Single language for AWS, Azure, GCP, and 3000+ providers | CloudFormation (AWS only), Bicep (Azure only) |
| Declarative | Describe desired state; Terraform figures out how to get there | Ansible/scripts require explicit ordering |
| Community | Largest IaC community, 3000+ providers, thousands of modules | Pulumi growing but smaller ecosystem |
| Execution Plans | terraform plan shows exactly what will change before you apply | Some tools apply changes immediately |
| State Management | Tracks real-world resource state, detects drift | Ansible is stateless (re-runs everything) |
| Maturity | Released 2014, battle-tested at massive scale | Newer tools lack production track record |
| Open Source | Core is open source (BSL license since 2023), OpenTofu fork exists | CloudFormation is proprietary |
Terraform Architecture
Terraform's architecture consists of four key components that work together to manage infrastructure:
flowchart TB
subgraph User["Developer Workflow"]
A[HCL Configuration Files
.tf files] --> B[Terraform CLI]
end
subgraph Core["Terraform Core"]
B --> C[Configuration Parser]
C --> D[Dependency Graph]
D --> E[Execution Engine]
end
subgraph State["State Management"]
E --> F[State File
terraform.tfstate]
F --> G[Remote Backend
S3 / Azure Blob / GCS]
end
subgraph Providers["Provider Plugins"]
E --> H[AWS Provider]
E --> I[Azure Provider]
E --> J[GCP Provider]
E --> K[Other Providers
3000+]
end
subgraph Infra["Real Infrastructure"]
H --> L[AWS Resources]
I --> M[Azure Resources]
J --> N[GCP Resources]
K --> O[Other Resources]
end
style A fill:#3B9797,color:#fff
style B fill:#132440,color:#fff
style E fill:#132440,color:#fff
style F fill:#BF092F,color:#fff
Installation and Setup
Terraform is distributed as a single binary. Install it on any platform:
# macOS (Homebrew)
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
# Windows (Chocolatey)
choco install terraform
# Linux (Ubuntu/Debian)
wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
# Verify installation
terraform version
# Terraform v1.9.x on linux_amd64
# Enable tab completion (bash)
terraform -install-autocomplete
# Initialize a new project directory
mkdir my-first-terraform && cd my-first-terraform
terraform init
HCL Syntax Deep Dive
HashiCorp Configuration Language (HCL) is a structured configuration language designed to be both human-readable and machine-friendly. It's the native language of Terraform.
Blocks, Arguments, and Expressions
HCL is built around blocks — containers for configuration. Each block has a type, optional labels, and a body containing arguments and nested blocks:
# Block syntax: BLOCK_TYPE "LABEL_1" "LABEL_2" { ... }
# Resource block with two labels (type + name)
resource "aws_instance" "web_server" {
# Arguments: key = value
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
# Nested block
tags = {
Name = "WebServer"
Environment = "production"
}
}
# Variable block with one label (name)
variable "region" {
description = "AWS region for resources"
type = string
default = "us-east-1"
}
# Output block with one label (name)
output "instance_ip" {
description = "Public IP of the web server"
value = aws_instance.web_server.public_ip
}
HCL supports two comment styles and string interpolation:
# Single-line comment (hash style)
// Single-line comment (slash style — also valid)
/*
Multi-line comment block.
Use for longer explanations or
temporarily disabling blocks.
*/
# String interpolation with ${}
resource "aws_instance" "example" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
tags = {
# Interpolation expressions
Name = "server-${var.environment}-${count.index + 1}"
# Heredoc syntax for multi-line strings
Description = <<-EOT
This is a web server running in
the ${var.environment} environment.
Managed by Terraform.
EOT
}
}
| Block Type | Purpose | Labels | Example |
|---|---|---|---|
terraform | Terraform settings and required providers | None | terraform { ... } |
provider | Configure a provider plugin | Provider name | provider "aws" { ... } |
resource | Define an infrastructure resource | Type + Name | resource "aws_vpc" "main" { ... } |
data | Read data from existing resources | Type + Name | data "aws_ami" "latest" { ... } |
variable | Declare an input variable | Variable name | variable "region" { ... } |
output | Export a value | Output name | output "ip" { ... } |
locals | Define local computed values | None | locals { ... } |
module | Call a reusable module | Module name | module "vpc" { ... } |
JSON Alternative Syntax
Terraform also accepts JSON format (with .tf.json extension), useful for machine-generated configurations:
{
"resource": {
"aws_instance": {
"web_server": {
"ami": "ami-0c55b159cbfafe1f0",
"instance_type": "t3.micro",
"tags": {
"Name": "WebServer",
"Environment": "production"
}
}
}
}
}
Providers
Providers are Terraform plugins that interface with external APIs. They are the bridge between your HCL configuration and real cloud infrastructure. Each provider offers a set of resource types and data sources you can use.
flowchart LR
subgraph Config["Terraform Config"]
A[provider block]
B[required_providers]
end
subgraph Registry["Terraform Registry"]
C[registry.terraform.io]
end
subgraph Plugins["Downloaded Plugins"]
D[hashicorp/aws v5.x]
E[hashicorp/azurerm v4.x]
F[hashicorp/google v6.x]
end
subgraph APIs["Cloud APIs"]
G[AWS API]
H[Azure ARM API]
I[GCP API]
end
B --> C
C --> D
C --> E
C --> F
A --> D
A --> E
A --> F
D --> G
E --> H
F --> I
style C fill:#3B9797,color:#fff
style D fill:#132440,color:#fff
style E fill:#132440,color:#fff
style F fill:#132440,color:#fff
AWS Provider Setup
# terraform block defines required providers and versions
terraform {
required_version = ">= 1.9.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0" # Allow 5.x but not 6.0
}
}
}
# Provider configuration
provider "aws" {
region = "us-east-1"
# Authentication methods (in order of precedence):
# 1. Environment variables: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
# 2. Shared credentials file (~/.aws/credentials)
# 3. IAM instance profile (for EC2/ECS)
# 4. Explicit credentials (NOT recommended for production)
default_tags {
tags = {
ManagedBy = "Terraform"
Project = "my-project"
Environment = "production"
}
}
}
Azure Provider (azurerm) Setup
terraform {
required_version = ">= 1.9.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
# Azure provider requires features block
provider "azurerm" {
features {
resource_group {
prevent_deletion_if_contains_resources = false
}
key_vault {
purge_soft_delete_on_destroy = true
}
}
# Authentication via Azure CLI, Service Principal, or Managed Identity
# az login sets up credentials automatically for local development
subscription_id = "00000000-0000-0000-0000-000000000000"
}
GCP Provider Setup
terraform {
required_version = ">= 1.9.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 6.0"
}
}
}
provider "google" {
project = "my-gcp-project-id"
region = "us-central1"
zone = "us-central1-a"
# Authentication: GOOGLE_APPLICATION_CREDENTIALS env var
# or gcloud auth application-default login
}
Multiple Provider Instances with Aliases
# Default provider (no alias)
provider "aws" {
region = "us-east-1"
}
# Additional provider instance with alias
provider "aws" {
alias = "west"
region = "us-west-2"
}
# Use aliased provider for specific resources
resource "aws_instance" "west_server" {
provider = aws.west # Uses us-west-2 region
ami = "ami-0abcdef1234567890"
instance_type = "t3.micro"
tags = {
Name = "West Region Server"
}
}
resource "aws_instance" "east_server" {
# Uses default provider (us-east-1)
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
tags = {
Name = "East Region Server"
}
}
~> 5.0 allows 5.x updates but prevents breaking 6.0 changes. Without version constraints, terraform init downloads the latest version which might break your configuration.
Resources
Resources are the most fundamental element in Terraform. Each resource block describes one or more real infrastructure objects — a VPC, an EC2 instance, a DNS record, or a Kubernetes pod.
Resource Meta-Arguments
Every resource supports special meta-arguments that control Terraform's behavior:
| Meta-Argument | Purpose | Example Use Case |
|---|---|---|
depends_on | Explicit dependency declaration | Resource depends on IAM policy being created first |
count | Create multiple instances by index | Create 3 identical EC2 instances |
for_each | Create instances from a map/set | Create subnets from a map of CIDR blocks |
provider | Select a non-default provider | Deploy to a different region |
lifecycle | Customize resource behavior | Prevent accidental deletion |
# count: Create multiple similar resources
resource "aws_instance" "web" {
count = 3
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
tags = {
Name = "web-server-${count.index + 1}"
}
}
# for_each: Create resources from a map
variable "subnets" {
default = {
"public-1" = "10.0.1.0/24"
"public-2" = "10.0.2.0/24"
"private-1" = "10.0.3.0/24"
}
}
resource "aws_subnet" "this" {
for_each = var.subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value
availability_zone = "us-east-1a"
tags = {
Name = each.key
}
}
# lifecycle: Prevent accidental destruction
resource "aws_db_instance" "production" {
identifier = "prod-database"
engine = "postgres"
engine_version = "16.1"
instance_class = "db.r6g.large"
lifecycle {
prevent_destroy = true # Terraform will error if you try to destroy
ignore_changes = [password] # Don't detect password changes as drift
}
}
Implicit and Explicit Dependencies
flowchart TD
A[aws_vpc.main] --> B[aws_subnet.public]
A --> C[aws_subnet.private]
A --> D[aws_internet_gateway.gw]
B --> E[aws_security_group.web]
E --> F[aws_instance.web]
C --> G[aws_db_subnet_group.db]
G --> H[aws_db_instance.postgres]
D --> I[aws_route_table.public]
I --> J[aws_route_table_association.public]
B --> J
style A fill:#3B9797,color:#fff
style F fill:#132440,color:#fff
style H fill:#BF092F,color:#fff
# Implicit dependency: Terraform detects from attribute references
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
tags = { Name = "main-vpc" }
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id # Implicit dependency on VPC
cidr_block = "10.0.1.0/24"
map_public_ip_on_launch = true
tags = { Name = "public-subnet" }
}
# Explicit dependency: Use when there's no attribute reference
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
subnet_id = aws_subnet.public.id
depends_on = [aws_iam_role_policy.s3_access] # Must exist first
tags = { Name = "web-server" }
}
Practical Example: AWS VPC with EC2
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
# VPC
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = { Name = "terraform-demo-vpc" }
}
# Internet Gateway
resource "aws_internet_gateway" "gw" {
vpc_id = aws_vpc.main.id
tags = { Name = "terraform-demo-igw" }
}
# Public Subnet
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a"
map_public_ip_on_launch = true
tags = { Name = "terraform-demo-public" }
}
# Route Table
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.gw.id
}
tags = { Name = "terraform-demo-rt" }
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
# Security Group
resource "aws_security_group" "web" {
name = "web-sg"
description = "Allow HTTP and SSH"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 22
to_port = 22
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"]
}
tags = { Name = "terraform-demo-sg" }
}
# EC2 Instance
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.web.id]
user_data = <<-EOF
#!/bin/bash
yum update -y
yum install -y httpd
systemctl start httpd
systemctl enable httpd
echo "Hello from Terraform!" > /var/www/html/index.html
EOF
tags = { Name = "terraform-demo-web" }
}
output "web_public_ip" {
value = aws_instance.web.public_ip
}
Practical Example: Azure Resource Group with VNet
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
subscription_id = "00000000-0000-0000-0000-000000000000"
}
# Resource Group
resource "azurerm_resource_group" "main" {
name = "terraform-demo-rg"
location = "East US"
tags = {
Environment = "Development"
ManagedBy = "Terraform"
}
}
# Virtual Network
resource "azurerm_virtual_network" "main" {
name = "terraform-demo-vnet"
address_space = ["10.0.0.0/16"]
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
tags = azurerm_resource_group.main.tags
}
# Subnet
resource "azurerm_subnet" "web" {
name = "web-subnet"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.0.1.0/24"]
}
# Network Security Group
resource "azurerm_network_security_group" "web" {
name = "web-nsg"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
security_rule {
name = "HTTP"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefix = "*"
destination_address_prefix = "*"
}
}
output "vnet_id" {
value = azurerm_virtual_network.main.id
}
Data Sources
Data sources allow Terraform to read information from existing infrastructure or external systems. Unlike resources (which Terraform creates and manages), data sources query existing objects and make their attributes available for use in your configuration.
resource "aws_vpc"). Data sources read existing infrastructure (data "aws_vpc"). Use data sources when you need to reference resources created outside Terraform or in a different state file.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
# Find the latest Amazon Linux 2023 AMI
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
# Reference an existing VPC by tag
data "aws_vpc" "existing" {
filter {
name = "tag:Name"
values = ["production-vpc"]
}
}
# Get current AWS account info
data "aws_caller_identity" "current" {}
# Get available AZs in current region
data "aws_availability_zones" "available" {
state = "available"
}
# Use data sources in resources
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
availability_zone = data.aws_availability_zones.available.names[0]
tags = {
Name = "web-server"
Account = data.aws_caller_identity.current.account_id
}
}
output "ami_id" {
value = data.aws_ami.amazon_linux.id
}
output "account_id" {
value = data.aws_caller_identity.current.account_id
}
output "available_azs" {
value = data.aws_availability_zones.available.names
}
Azure Data Source Example
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
provider "azurerm" {
features {}
subscription_id = "00000000-0000-0000-0000-000000000000"
}
# Reference existing resource group
data "azurerm_resource_group" "existing" {
name = "production-rg"
}
# Reference existing Key Vault
data "azurerm_key_vault" "secrets" {
name = "prod-keyvault"
resource_group_name = data.azurerm_resource_group.existing.name
}
# Read a secret from Key Vault
data "azurerm_key_vault_secret" "db_password" {
name = "database-password"
key_vault_id = data.azurerm_key_vault.secrets.id
}
output "resource_group_location" {
value = data.azurerm_resource_group.existing.location
}
output "secret_value" {
value = data.azurerm_key_vault_secret.db_password.value
sensitive = true
}
Variables and Outputs
Variables make your Terraform configurations flexible and reusable. Outputs expose values for other configurations or for humans to read.
Input Variable Types
| Type | Description | Example Value |
|---|---|---|
string | Single text value | "us-east-1" |
number | Numeric value (int or float) | 3, 3.14 |
bool | Boolean true/false | true |
list(type) | Ordered collection of same type | ["us-east-1a", "us-east-1b"] |
set(type) | Unordered unique collection | toset(["web", "app", "db"]) |
map(type) | Key-value pairs (same value type) | { dev = "t3.micro", prod = "t3.large" } |
object({...}) | Structured type with named attributes | { name = "web", port = 80 } |
tuple([...]) | Fixed-length sequence with typed elements | ["hello", 42, true] |
# variables.tf — Complete variable examples
# Simple string variable
variable "environment" {
description = "Deployment environment"
type = string
default = "development"
validation {
condition = contains(["development", "staging", "production"], var.environment)
error_message = "Environment must be development, staging, or production."
}
}
# Number with validation
variable "instance_count" {
description = "Number of instances to create"
type = number
default = 2
validation {
condition = var.instance_count >= 1 && var.instance_count <= 10
error_message = "Instance count must be between 1 and 10."
}
}
# Boolean
variable "enable_monitoring" {
description = "Enable CloudWatch detailed monitoring"
type = bool
default = false
}
# List
variable "availability_zones" {
description = "AZs for resource deployment"
type = list(string)
default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
# Map
variable "instance_types" {
description = "Instance type per environment"
type = map(string)
default = {
development = "t3.micro"
staging = "t3.small"
production = "t3.large"
}
}
# Complex object
variable "database_config" {
description = "Database configuration"
type = object({
engine = string
engine_version = string
instance_class = string
storage_gb = number
multi_az = bool
})
default = {
engine = "postgres"
engine_version = "16.1"
instance_class = "db.t3.medium"
storage_gb = 100
multi_az = false
}
}
# Sensitive variable (value hidden in output)
variable "db_password" {
description = "Database master password"
type = string
sensitive = true
}
Variable Precedence
-var CLI flag, 2) -var-file flag, 3) *.auto.tfvars files (alphabetical), 4) terraform.tfvars, 5) TF_VAR_* environment variables, 6) Variable default value. If no value is found and there's no default, Terraform prompts interactively.
Output Values
# outputs.tf — Export values for other configurations
output "vpc_id" {
description = "The ID of the VPC"
value = aws_vpc.main.id
}
output "public_subnet_ids" {
description = "IDs of all public subnets"
value = aws_subnet.public[*].id
}
output "web_server_ips" {
description = "Public IPs of web servers"
value = [for instance in aws_instance.web : instance.public_ip]
}
output "database_endpoint" {
description = "RDS endpoint"
value = aws_db_instance.main.endpoint
sensitive = true # Hidden in CLI output
}
output "connection_string" {
description = "Full connection string"
value = "postgresql://${var.db_username}:${var.db_password}@${aws_db_instance.main.endpoint}/mydb"
sensitive = true
}
terraform.tfvars and Auto-Loading
# terraform.tfvars — Automatically loaded by Terraform
environment = "production"
instance_count = 3
enable_monitoring = true
availability_zones = [
"us-east-1a",
"us-east-1b",
]
instance_types = {
web = "t3.medium"
api = "t3.large"
db = "r6g.xlarge"
}
database_config = {
engine = "postgres"
engine_version = "16.1"
instance_class = "db.r6g.large"
storage_gb = 500
multi_az = true
}
# Using variable files on the command line
terraform plan -var-file="environments/production.tfvars"
terraform apply -var="environment=staging" -var="instance_count=1"
# Environment variables (prefix with TF_VAR_)
export TF_VAR_db_password="super-secret-password"
export TF_VAR_environment="production"
terraform apply
Locals and Expressions
Local values are computed values that simplify your configuration by reducing repetition and giving meaningful names to complex expressions.
Conditionals, Loops, and Dynamic Blocks
# locals block — computed, reusable values
locals {
# Common tags applied to all resources
common_tags = {
Project = "ecommerce"
Environment = var.environment
ManagedBy = "Terraform"
Team = "platform"
CostCenter = "engineering-${var.environment}"
}
# Conditional expression
instance_type = var.environment == "production" ? "t3.large" : "t3.micro"
# Computed name prefix
name_prefix = "${var.project}-${var.environment}"
# Transform a list into a map
subnet_map = { for idx, cidr in var.subnet_cidrs :
"subnet-${idx}" => cidr
}
}
# Using locals in resources
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = local.instance_type
tags = merge(local.common_tags, {
Name = "${local.name_prefix}-web"
Role = "webserver"
})
}
# for expression — transform lists and maps
locals {
# List comprehension: extract instance IDs
instance_ids = [for i in aws_instance.web : i.id]
# Map comprehension: name -> IP mapping
instance_ips = { for i in aws_instance.web : i.tags.Name => i.public_ip }
# Filtered list: only production instances
prod_instances = [for i in aws_instance.web : i.id if i.tags.Environment == "production"]
# Uppercase transformation
upper_azs = [for az in var.availability_zones : upper(az)]
}
# Splat expression — shorthand for simple attribute extraction
output "all_instance_ids" {
value = aws_instance.web[*].id
# Equivalent to: [for i in aws_instance.web : i.id]
}
# Dynamic blocks — generate repeated nested blocks
resource "aws_security_group" "web" {
name = "${local.name_prefix}-web-sg"
description = "Security group for web servers"
vpc_id = aws_vpc.main.id
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.from_port
to_port = ingress.value.to_port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
description = ingress.value.description
}
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
Built-in Functions
Terraform includes dozens of built-in functions for transforming data:
locals {
# String functions
lower_env = lower(var.environment) # "PRODUCTION" -> "production"
upper_env = upper(var.environment) # "production" -> "PRODUCTION"
name_parts = split("-", "web-server-01") # ["web", "server", "01"]
joined = join(", ", var.availability_zones) # "us-east-1a, us-east-1b"
formatted = format("server-%03d", 5) # "server-005"
trimmed = trimspace(" hello ") # "hello"
replaced = replace("hello-world", "-", "_") # "hello_world"
# Numeric functions
max_val = max(5, 12, 9) # 12
min_val = min(5, 12, 9) # 5
ceiled = ceil(4.2) # 5
floored = floor(4.8) # 4
# Collection functions
list_len = length(var.availability_zones) # 3
flat_list = flatten([["a", "b"], ["c"]]) # ["a", "b", "c"]
merged_maps = merge(local.common_tags, { Extra = "tag" })
keys_list = keys({ a = 1, b = 2 }) # ["a", "b"]
values_list = values({ a = 1, b = 2 }) # [1, 2]
lookup_val = lookup(var.instance_types, var.environment, "t3.micro")
element_val = element(var.availability_zones, 0) # "us-east-1a"
contains_it = contains(["dev", "prod"], "dev") # true
distinct_az = distinct(["a", "b", "a", "c"]) # ["a", "b", "c"]
# Network functions
subnet_cidr = cidrsubnet("10.0.0.0/16", 8, 1) # "10.0.1.0/24"
host_addr = cidrhost("10.0.1.0/24", 5) # "10.0.1.5"
# Filesystem functions
user_data = file("${path.module}/scripts/init.sh")
rendered = templatefile("${path.module}/templates/config.tpl", {
server_name = "web-01"
port = 8080
})
# Encoding functions
encoded = base64encode("hello world") # "aGVsbG8gd29ybGQ="
json_str = jsonencode({ name = "web", port = 80 })
# Type conversion
to_num = tonumber("42") # 42
to_str = tostring(42) # "42"
to_list = tolist(toset(["b", "a", "c"])) # ["a", "b", "c"]
}
The Terraform Workflow
Terraform follows a predictable workflow: Write → Init → Plan → Apply. Understanding each step is critical for safe infrastructure management.
flowchart LR
A[Write
.tf files] --> B[terraform init
Download providers]
B --> C[terraform validate
Check syntax]
C --> D[terraform plan
Preview changes]
D --> E{Review
plan output}
E -->|Approve| F[terraform apply
Execute changes]
E -->|Reject| A
F --> G[Infrastructure
Updated]
G --> H[terraform state
Stored]
H -.->|Next change| A
style B fill:#3B9797,color:#fff
style D fill:#16476A,color:#fff
style F fill:#BF092F,color:#fff
style G fill:#132440,color:#fff
Core Commands in Detail
# 1. INIT: Initialize the working directory
# - Downloads provider plugins
# - Initializes backend (state storage)
# - Downloads modules
terraform init
# Upgrade providers to latest allowed version
terraform init -upgrade
# 2. VALIDATE: Check configuration syntax
terraform validate
# Success! The configuration is valid.
# 3. FORMAT: Auto-format code to canonical style
terraform fmt
# Lists files that were reformatted
# Recursively format all .tf files
terraform fmt -recursive
# Check formatting without modifying (useful in CI)
terraform fmt -check
# 4. PLAN: Preview what Terraform will do
terraform plan
# Save plan to file (for review and exact apply)
terraform plan -out=tfplan
# Plan with specific variable values
terraform plan -var="environment=production"
# Plan for destruction
terraform plan -destroy
# 5. APPLY: Execute the planned changes
terraform apply
# Apply a saved plan file (skips confirmation)
terraform apply tfplan
# Auto-approve (use in CI/CD only)
terraform apply -auto-approve
# Apply targeting a specific resource
terraform apply -target=aws_instance.web
# 6. DESTROY: Remove all managed resources
terraform destroy
# Destroy specific resource only
terraform destroy -target=aws_instance.web
# 7. STATE: Inspect and manage state
terraform state list # List all managed resources
terraform state show aws_instance.web # Show details of one resource
terraform state mv aws_instance.web aws_instance.app # Rename in state
terraform state rm aws_instance.web # Remove from state (doesn't destroy)
terraform state pull # Download remote state to stdout
terraform plan before terraform apply. Review the plan output carefully. A plan showing unexpected destroys or replacements should be investigated before applying. In production, save plans with -out and apply the exact saved plan.
Understanding Plan Output
# Plan output symbols:
# + create — new resource will be created
# - destroy — existing resource will be destroyed
# ~ update — resource will be modified in-place
# -/+ replace — resource will be destroyed and recreated
# <= read — data source will be read
# Example plan output:
# Terraform will perform the following actions:
#
# # aws_instance.web will be created
# + resource "aws_instance" "web" {
# + ami = "ami-0c55b159cbfafe1f0"
# + instance_type = "t3.micro"
# + id = (known after apply)
# + public_ip = (known after apply)
# }
#
# Plan: 1 to add, 0 to change, 0 to destroy.
Real-World Project Structure
As your infrastructure grows, organizing Terraform files properly becomes critical for maintainability.
flowchart TB
subgraph Root["Project Root"]
A[main.tf
Primary resources]
B[variables.tf
Input variables]
C[outputs.tf
Output values]
D[providers.tf
Provider config]
E[terraform.tf
Backend + versions]
F[locals.tf
Local values]
end
subgraph EnvFiles["Environment Files"]
G[terraform.tfvars
Default values]
H[production.tfvars
Prod overrides]
I[staging.tfvars
Stage overrides]
end
subgraph Generated[".terraform/ (generated)"]
J[providers/]
K[modules/]
end
subgraph Ignore["Git Ignored"]
L[.terraform/]
M[*.tfstate]
N[*.tfstate.backup]
O[.terraform.lock.hcl]
end
style A fill:#3B9797,color:#fff
style B fill:#16476A,color:#fff
style C fill:#16476A,color:#fff
style E fill:#132440,color:#fff
style L fill:#BF092F,color:#fff
style M fill:#BF092F,color:#fff
# Recommended multi-file project layout
my-terraform-project/
├── main.tf # Primary resource definitions
├── variables.tf # All input variable declarations
├── outputs.tf # All output declarations
├── providers.tf # Provider configurations
├── terraform.tf # Terraform and backend settings
├── locals.tf # Local value definitions
├── data.tf # Data source definitions
├── terraform.tfvars # Default variable values
├── environments/
│ ├── dev.tfvars # Development overrides
│ ├── staging.tfvars # Staging overrides
│ └── prod.tfvars # Production overrides
├── modules/
│ ├── networking/ # Reusable VPC module
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── compute/ # Reusable compute module
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── scripts/
│ └── user-data.sh # EC2 bootstrap scripts
├── templates/
│ └── config.tpl # Template files
├── .gitignore # Git ignore rules
└── README.md # Project documentation
Environment Separation
# terraform.tf — Backend configuration with workspace support
terraform {
required_version = ">= 1.9.0"
backend "s3" {
bucket = "my-terraform-state"
key = "infrastructure/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# Strategy 1: Terraform Workspaces (simple projects)
terraform workspace new development
terraform workspace new staging
terraform workspace new production
terraform workspace select production
terraform apply -var-file="environments/prod.tfvars"
# List workspaces
terraform workspace list
# default
# development
# staging
# * production
# Strategy 2: Directory-based separation (complex projects)
# infrastructure/
# ├── modules/ # Shared modules
# ├── environments/
# │ ├── dev/ # terraform init/apply here
# │ │ ├── main.tf
# │ │ ├── backend.tf
# │ │ └── terraform.tfvars
# │ ├── staging/
# │ └── production/
.gitignore for Terraform Projects
# .gitignore for Terraform
# Local .terraform directories
**/.terraform/*
# .tfstate files (state should be stored remotely)
*.tfstate
*.tfstate.*
# Crash log files
crash.log
crash.*.log
# Exclude override files (used for local testing)
override.tf
override.tf.json
*_override.tf
*_override.tf.json
# Ignore CLI configuration files
.terraformrc
terraform.rc
# Ignore sensitive variable files
*.tfvars
!example.tfvars
# Lock file should be committed for reproducibility
# !.terraform.lock.hcl
.terraform.lock.hcl to version control. This file records the exact provider versions and hashes used, ensuring reproducible builds across team members and CI/CD systems. Think of it like package-lock.json for Node.js.
Hands-On Exercises
Your First Terraform Resource
Install Terraform and deploy a local_file resource that creates a file on your machine. This exercise requires no cloud credentials.
# main.tf — Create a local file (no cloud account needed!)
terraform {
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
resource "local_file" "hello" {
content = "Hello, Terraform! Deployed at ${timestamp()}"
filename = "${path.module}/hello.txt"
}
output "file_path" {
value = local_file.hello.filename
}
output "file_content" {
value = local_file.hello.content
}
Steps: 1) Create a directory and save as main.tf, 2) Run terraform init, 3) Run terraform plan and review output, 4) Run terraform apply, 5) Verify the file was created, 6) Run terraform destroy to clean up.
AWS VPC with Variables
Create an AWS VPC with public and private subnets using input variables for customization.
# variables.tf
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "public_subnets" {
description = "Public subnet CIDR blocks"
type = list(string)
default = ["10.0.1.0/24", "10.0.2.0/24"]
}
variable "private_subnets" {
description = "Private subnet CIDR blocks"
type = list(string)
default = ["10.0.10.0/24", "10.0.11.0/24"]
}
variable "environment" {
description = "Environment name"
type = string
default = "dev"
}
# main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
locals {
common_tags = {
Environment = var.environment
ManagedBy = "Terraform"
}
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
tags = merge(local.common_tags, { Name = "${var.environment}-vpc" })
}
resource "aws_subnet" "public" {
count = length(var.public_subnets)
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnets[count.index]
map_public_ip_on_launch = true
tags = merge(local.common_tags, { Name = "${var.environment}-public-${count.index + 1}" })
}
resource "aws_subnet" "private" {
count = length(var.private_subnets)
vpc_id = aws_vpc.main.id
cidr_block = var.private_subnets[count.index]
tags = merge(local.common_tags, { Name = "${var.environment}-private-${count.index + 1}" })
}
# outputs.tf
output "vpc_id" {
value = aws_vpc.main.id
}
output "public_subnet_ids" {
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id
}
Challenge: Add an Internet Gateway and Route Table to make the public subnets actually public. Create a dev.tfvars file with custom CIDR blocks.
Data Sources in Practice
Use data sources to look up existing infrastructure and deploy an EC2 instance using the latest AMI.
# main.tf — Use data sources to reference existing infrastructure
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
# Look up the latest Amazon Linux 2023 AMI
data "aws_ami" "al2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
filter {
name = "state"
values = ["available"]
}
}
# Look up the default VPC
data "aws_vpc" "default" {
default = true
}
# Look up subnets in the default VPC
data "aws_subnets" "default" {
filter {
name = "vpc-id"
values = [data.aws_vpc.default.id]
}
}
resource "aws_instance" "demo" {
ami = data.aws_ami.al2023.id
instance_type = "t3.micro"
subnet_id = data.aws_subnets.default.ids[0]
tags = {
Name = "data-source-demo"
}
}
output "ami_used" {
value = data.aws_ami.al2023.id
}
output "ami_name" {
value = data.aws_ami.al2023.name
}
output "vpc_cidr" {
value = data.aws_vpc.default.cidr_block
}
Challenge: Add a data source for aws_caller_identity and output your AWS account ID. Add filters to find a specific AMI by tag.
Multi-File Project Structure
Reorganize a single-file Terraform config into proper multi-file structure with separate concerns.
# Create proper project structure
mkdir -p terraform-structured/{environments,modules/networking}
cd terraform-structured
# Create each file
cat > terraform.tf <<'EOF'
terraform {
required_version = ">= 1.9.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
EOF
cat > providers.tf <<'EOF'
provider "aws" {
region = var.aws_region
}
EOF
cat > variables.tf <<'EOF'
variable "aws_region" {
description = "AWS region"
type = string
default = "us-east-1"
}
variable "environment" {
description = "Environment name"
type = string
default = "dev"
}
variable "vpc_cidr" {
description = "VPC CIDR block"
type = string
default = "10.0.0.0/16"
}
EOF
cat > locals.tf <<'EOF'
locals {
name_prefix = "${var.environment}-demo"
common_tags = {
Environment = var.environment
ManagedBy = "Terraform"
Project = "structured-demo"
}
}
EOF
cat > main.tf <<'EOF'
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
tags = merge(local.common_tags, { Name = "${local.name_prefix}-vpc" })
}
EOF
cat > outputs.tf <<'EOF'
output "vpc_id" {
description = "VPC ID"
value = aws_vpc.main.id
}
EOF
# Create environment-specific tfvars
cat > environments/dev.tfvars <<'EOF'
environment = "dev"
vpc_cidr = "10.0.0.0/16"
EOF
cat > environments/prod.tfvars <<'EOF'
environment = "prod"
vpc_cidr = "10.1.0.0/16"
EOF
# Initialize and validate
terraform init
terraform validate
terraform fmt -check
Challenge: Add a .gitignore, create a reusable module in the modules/networking/ directory, and call it from main.tf.
Conclusion & Next Steps
You've now built a solid foundation in Terraform fundamentals. You understand HCL syntax, know how to configure providers for major clouds, can define resources with meta-arguments, query existing infrastructure with data sources, parameterize configurations with variables, compute values with locals and expressions, and follow the standard Terraform workflow.
The key principles to carry forward:
- Always plan before apply — review execution plans carefully
- Pin provider versions — prevent unexpected breaking changes
- Use variables for flexibility — never hardcode values that change between environments
- Structure projects properly — separate concerns into multiple files from the start
- Treat state as sacred — use remote backends, enable encryption and locking
- Commit lock files — ensure reproducible builds across your team
Next in the Series
In Part 10: Infrastructure Security, we'll explore IAM policies and least-privilege access, network security with security groups and NACLs, secrets management with Vault and cloud-native solutions, compliance as code, and zero-trust architecture patterns for hardening your infrastructure.