Back to Infrastructure & Cloud Automation Series

Part 9: Terraform Fundamentals

May 14, 2026 Wasil Zafar 55 min read

Master Terraform from zero to production — HCL syntax, providers, resources, data sources, variables, outputs, locals, expressions, functions, and real-world infrastructure deployment patterns that power modern cloud engineering.

Table of Contents

  1. Introduction to Terraform
  2. HCL Syntax Deep Dive
  3. Providers
  4. Resources
  5. Data Sources
  6. Variables & Outputs
  7. Locals & Expressions
  8. The Terraform Workflow
  9. Project Structure
  10. Hands-On Exercises
  11. Conclusion & Next Steps

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.

Terraform is an open-source Infrastructure as Code tool created by HashiCorp. It uses a declarative language called HCL (HashiCorp Configuration Language) to define infrastructure resources across any cloud provider, on-premises environment, or SaaS platform — all from a single, unified workflow.

Why Terraform Won the IaC War

Among the many IaC tools available, Terraform emerged as the dominant choice for infrastructure provisioning. Here's why:

FactorTerraform AdvantageAlternatives
Multi-CloudSingle language for AWS, Azure, GCP, and 3000+ providersCloudFormation (AWS only), Bicep (Azure only)
DeclarativeDescribe desired state; Terraform figures out how to get thereAnsible/scripts require explicit ordering
CommunityLargest IaC community, 3000+ providers, thousands of modulesPulumi growing but smaller ecosystem
Execution Plansterraform plan shows exactly what will change before you applySome tools apply changes immediately
State ManagementTracks real-world resource state, detects driftAnsible is stateless (re-runs everything)
MaturityReleased 2014, battle-tested at massive scaleNewer tools lack production track record
Open SourceCore is open source (BSL license since 2023), OpenTofu fork existsCloudFormation is proprietary

Terraform Architecture

Terraform's architecture consists of four key components that work together to manage infrastructure:

Terraform Architecture Overview
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
Terraform Core reads your configuration, builds a dependency graph, determines what changes are needed by comparing desired state (config) with current state (state file), and executes operations through provider plugins. Providers are the bridge between Terraform and the APIs of cloud platforms.

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 TypePurposeLabelsExample
terraformTerraform settings and required providersNoneterraform { ... }
providerConfigure a provider pluginProvider nameprovider "aws" { ... }
resourceDefine an infrastructure resourceType + Nameresource "aws_vpc" "main" { ... }
dataRead data from existing resourcesType + Namedata "aws_ami" "latest" { ... }
variableDeclare an input variableVariable namevariable "region" { ... }
outputExport a valueOutput nameoutput "ip" { ... }
localsDefine local computed valuesNonelocals { ... }
moduleCall a reusable moduleModule namemodule "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.

Provider Architecture
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"
  }
}
Version Constraints Matter: Always pin provider versions. Using ~> 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-ArgumentPurposeExample Use Case
depends_onExplicit dependency declarationResource depends on IAM policy being created first
countCreate multiple instances by indexCreate 3 identical EC2 instances
for_eachCreate instances from a map/setCreate subnets from a map of CIDR blocks
providerSelect a non-default providerDeploy to a different region
lifecycleCustomize resource behaviorPrevent 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

Resource Dependency Graph
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.

Resources vs Data Sources: Resources create and manage infrastructure (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

TypeDescriptionExample Value
stringSingle text value"us-east-1"
numberNumeric value (int or float)3, 3.14
boolBoolean true/falsetrue
list(type)Ordered collection of same type["us-east-1a", "us-east-1b"]
set(type)Unordered unique collectiontoset(["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

Variable Precedence (highest to lowest): 1) -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.

Terraform Core Workflow
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
Golden Rule: ALWAYS run 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.

Recommended Project File Structure
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
Best Practice: Commit .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

Exercise 1 15 min

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.

local_file terraform init beginner
Exercise 2 30 min

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.

AWS VPC variables count intermediate
Exercise 3 20 min

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.

data sources aws_ami filters
Exercise 4 30 min

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.

project structure multi-file environments advanced

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.