Terraform Basics: Getting Started with Infrastructure as Code

Learning to provision and manage infrastructure with HashiCorp Terraform

Introduction to Terraform

Terraform is an open-source Infrastructure as Code (IaC) tool created by HashiCorp that enables you to safely and predictably provision and manage infrastructure across various cloud providers and services. Using a declarative configuration language called HashiCorp Configuration Language (HCL), Terraform allows you to define infrastructure components in human-readable configuration files that can be versioned, shared, and reused.

As one of the most widely adopted IaC tools in the industry, Terraform has become the de facto standard for multi-cloud infrastructure provisioning. Its provider-based architecture supports hundreds of infrastructure platforms, including major cloud providers like AWS, Azure, and Google Cloud, as well as SaaS platforms, database services, and more.

graph TD A[Terraform Workflow] --> B[Write Configuration] B --> C[Initialize Environment] C --> D[Plan Changes] D --> E[Apply Changes] E --> F[Infrastructure Created/Updated] F --> G[State Management] G --> B

The Shopping List Analogy

Working with Terraform can be compared to grocery shopping with a list:

  • Configuration Files are like your shopping list – they declare what you want, not how to get it.
  • Terraform Plan is like reviewing your shopping list before shopping, checking what you already have at home and what you still need to buy.
  • Terraform Apply is the actual shopping trip, where you acquire the items on your list.
  • Terraform State is like your pantry inventory – it tracks what you currently have.
  • Terraform Providers are like different stores (grocery store, hardware store, etc.) where you can acquire different types of items.
  • Terraform Modules are like pre-made shopping lists for specific occasions (dinner party, camping trip) that you can reuse and customize.

Just as a shopping list lets you efficiently buy exactly what you need without forgetting items, Terraform lets you consistently provision exactly the infrastructure you need without manual errors or omissions.

Terraform vs. Other IaC Tools

To understand Terraform's positioning in the IaC ecosystem, it's helpful to compare it with other common tools used for infrastructure management.

Tool Primary Focus Approach Language Multi-Cloud
Terraform Infrastructure provisioning Declarative HCL (HashiCorp Configuration Language) Yes (strong)
AWS CloudFormation AWS-specific provisioning Declarative JSON/YAML No (AWS only)
Azure Resource Manager Azure-specific provisioning Declarative JSON/Bicep No (Azure only)
Google Deployment Manager GCP-specific provisioning Declarative YAML/Python No (GCP only)
Ansible Configuration management Procedural YAML Yes
Pulumi Infrastructure provisioning Declarative General programming languages (Python, TypeScript, etc.) Yes
AWS CDK AWS-specific provisioning Imperative to Declarative TypeScript, Python, Java, etc. No (AWS only)

Key Terraform Differentiators

When to Choose Terraform

Terraform is particularly well-suited for:

  • Multi-cloud or hybrid-cloud environments
  • Infrastructure that spans multiple service providers
  • Teams looking for a cloud-agnostic approach to IaC
  • Projects requiring a balance of readability and expressiveness
  • Scenarios needing detailed dependency management
  • When you want to leverage a vast ecosystem of community modules

Consider alternatives when:

  • You're working exclusively within a single cloud provider and prefer native tooling
  • You need more focus on configuration management than provisioning
  • Your team strongly prefers using general-purpose programming languages

Installing and Setting Up Terraform

Installation Options

Terraform can be installed in several ways depending on your operating system and preferences:

Manual Installation

For macOS (using Homebrew):


brew tap hashicorp/tap
brew install hashicorp/tap/terraform

For Windows (using Chocolatey):


choco install terraform

For Linux (using apt on Ubuntu/Debian):


# Add HashiCorp GPG key
curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo apt-key add -

# Add HashiCorp repository
sudo apt-add-repository "deb [arch=amd64] https://apt.releases.hashicorp.com $(lsb_release -cs) main"

# Update and install Terraform
sudo apt-get update && sudo apt-get install terraform

Manual Download and Installation

For any platform:

  1. Download the appropriate package from the Terraform downloads page
  2. Extract the downloaded zip file
  3. Move the terraform binary to a directory included in your system's PATH

# Example for Linux/macOS after downloading
unzip terraform_*.zip
sudo mv terraform /usr/local/bin/

Verifying Installation

After installation, verify that Terraform is correctly installed by checking the version:


terraform version

You should see output similar to:


Terraform v1.5.4
on darwin_amd64

Editor Setup

Setting up your editor for Terraform development improves productivity:

Visual Studio Code

Install the official HashiCorp Terraform extension for syntax highlighting, validation, and autocompletion:

  1. Open VS Code
  2. Go to Extensions (Ctrl+Shift+X or Cmd+Shift+X)
  3. Search for "HashiCorp Terraform"
  4. Click Install

Other Editors

Editor Recommendations

  • Enable format on save for automatic HCL formatting
  • Set up linting for Terraform best practices
  • Configure tab spacing to 2 spaces (Terraform convention)
  • If using VS Code, enable the Terraform language server for enhanced features

Terraform Fundamentals

Core Terraform Concepts

graph TD A[Core Terraform Concepts] --> B[Providers] A --> C[Resources] A --> D[Data Sources] A --> E[Variables] A --> F[Outputs] A --> G[State] A --> H[Modules] B --> B1[AWS, Azure, GCP, etc.] C --> C1[Infrastructure Components] D --> D1[Read-Only External Info] E --> E1[Configuration Inputs] F --> F1[Exposed Information] G --> G1[Current Infrastructure State] H --> H1[Reusable Components]

Providers

Providers are plugins that enable Terraform to interact with specific platforms and services.


# AWS provider configuration
provider "aws" {
  region = "us-west-2"
  profile = "production"
}

# Using multiple providers
provider "azurerm" {
  features {}
  subscription_id = "your-subscription-id"
}

Resources

Resources are the infrastructure components you want to create and manage, such as virtual machines, networks, or DNS records.


# Example AWS EC2 instance resource
resource "aws_instance" "web_server" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"
  
  tags = {
    Name = "WebServer"
    Environment = "Production"
  }
}

# Example S3 bucket resource
resource "aws_s3_bucket" "data_bucket" {
  bucket = "my-company-data-bucket"
  acl    = "private"
  
  versioning {
    enabled = true
  }
}

Data Sources

Data sources allow Terraform to fetch information from existing infrastructure or external sources.


# Fetch information about an existing VPC
data "aws_vpc" "existing_vpc" {
  id = "vpc-0123456789abcdef0"
}

# Use data from the existing VPC
resource "aws_subnet" "new_subnet" {
  vpc_id     = data.aws_vpc.existing_vpc.id
  cidr_block = "10.0.1.0/24"
  
  tags = {
    Name = "NewSubnet"
  }
}

Variables

Variables provide a way to parameterize your Terraform configurations.


# Defining variables in variables.tf
variable "region" {
  description = "AWS region to deploy resources"
  type        = string
  default     = "us-west-2"
}

variable "instance_count" {
  description = "Number of EC2 instances to create"
  type        = number
  default     = 2
}

variable "environment" {
  description = "Deployment environment"
  type        = string
  default     = "development"
  
  validation {
    condition     = contains(["development", "staging", "production"], var.environment)
    error_message = "Environment must be one of: development, staging, production."
  }
}

# Using variables in main.tf
provider "aws" {
  region = var.region
}

resource "aws_instance" "server" {
  count         = var.instance_count
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = var.environment == "production" ? "t2.medium" : "t2.micro"
  
  tags = {
    Name        = "server-${count.index + 1}"
    Environment = var.environment
  }
}

Outputs

Outputs expose specific values from your infrastructure that can be used by other Terraform configurations or external systems.


# Define outputs
output "instance_ip_addresses" {
  description = "Public IP addresses of created instances"
  value       = aws_instance.server[*].public_ip
}

output "s3_bucket_arn" {
  description = "ARN of the created S3 bucket"
  value       = aws_s3_bucket.data_bucket.arn
  sensitive   = true  # Marks as sensitive to hide in console output
}

State

Terraform state is a snapshot of your infrastructure that tracks resource metadata and dependencies.


# Configure remote state storage (typically in a backend.tf file)
terraform {
  backend "s3" {
    bucket         = "terraform-state-bucket"
    key            = "project/environment/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-locks"
  }
}

Modules

Modules are reusable components that encapsulate groups of resources, making your Terraform code more organized and maintainable.


# Using a module
module "vpc" {
  source = "./modules/vpc"
  
  name               = "main-vpc"
  cidr_block         = "10.0.0.0/16"
  availability_zones = ["us-west-2a", "us-west-2b", "us-west-2c"]
  
  tags = {
    Environment = var.environment
    Project     = "MyProject"
  }
}

# Reference module outputs
resource "aws_instance" "app_server" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"
  subnet_id     = module.vpc.private_subnet_ids[0]
  
  tags = {
    Name = "AppServer"
  }
}

The Terraform Workflow

Terraform follows a consistent workflow regardless of which cloud provider or services you're using.

sequenceDiagram participant D as Developer participant T as Terraform participant C as Cloud Provider D->>T: Write/Update Configuration Note over D,T: Create .tf files D->>T: terraform init T->>T: Download Providers & Modules T->>D: Initialization Complete D->>T: terraform validate T->>D: Configuration Validation Result D->>T: terraform plan T->>C: Read Current State C->>T: Return Current Resources T->>T: Create Execution Plan T->>D: Show Planned Changes D->>T: terraform apply T->>D: Confirm Application? D->>T: Yes T->>C: Create/Update/Delete Resources C->>T: Confirm Resource Changes T->>T: Update State File T->>D: Show Results D->>T: terraform destroy (when needed) T->>D: Confirm Destruction? D->>T: Yes T->>C: Delete Resources C->>T: Confirm Deletion T->>T: Update State File T->>D: Show Results

Key Workflow Commands

terraform init

Initializes a Terraform working directory, downloading providers and modules.


# Basic initialization
terraform init

# Upgrade modules and plugins
terraform init -upgrade

# Initialize with a specific backend configuration
terraform init -backend-config=prod.backend.hcl

terraform plan

Creates an execution plan showing what Terraform will do when you apply the configuration.


# Create a plan
terraform plan

# Save the plan to a file
terraform plan -out=tfplan

# Create a plan for specific resources
terraform plan -target=aws_instance.web_server

# Create a plan with variable values
terraform plan -var="instance_count=5" -var="environment=staging"

terraform apply

Applies the changes required to reach the desired state of the configuration.


# Apply changes with confirmation
terraform apply

# Apply changes without confirmation
terraform apply -auto-approve

# Apply a specific saved plan
terraform apply tfplan

# Apply changes for specific resources
terraform apply -target=aws_instance.web_server

terraform destroy

Destroys all resources managed by the current Terraform configuration.


# Destroy with confirmation
terraform destroy

# Destroy without confirmation
terraform destroy -auto-approve

# Destroy specific resources
terraform destroy -target=aws_instance.web_server

Additional Useful Commands


# Validate configuration syntax
terraform validate

# Format configuration according to conventions
terraform fmt

# Show current state
terraform show

# List all resources in state
terraform state list

# Import existing infrastructure into Terraform
terraform import aws_instance.imported i-1234567890abcdef0

# Get plugin or module updates
terraform get -update

# View outputs
terraform output

# View specific output
terraform output instance_ip_addresses

Best Practices for Terraform Workflow

  • Always Run Plan First: Review planned changes before applying them
  • Use Version Control: Commit Terraform configurations to track changes over time
  • Use Remote State: Store state in a shared, secure backend for team collaboration
  • State Locking: Ensure only one person can modify state at a time
  • Use Workspaces: Separate state for different environments
  • Output Plans: Save and review plans for complex changes
  • Target Carefully: Be cautious with -target as it can break dependencies

Your First Terraform Project

Let's create a simple Terraform project to provision basic AWS infrastructure.

Project Structure


first-terraform-project/
├── main.tf         # Main configuration file
├── variables.tf    # Variable declarations
├── outputs.tf      # Output declarations
├── providers.tf    # Provider configurations
└── .gitignore      # Git ignore file

Step 1: Set Up Provider Configuration

Create a providers.tf file:


# providers.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.16"
    }
  }
  
  required_version = ">= 1.2.0"
}

provider "aws" {
  region = var.aws_region
  
  # Uncomment if not using AWS CLI credentials
  # access_key = var.aws_access_key
  # secret_key = var.aws_secret_key
}

Step 2: Define Variables

Create a variables.tf file:


# variables.tf
variable "aws_region" {
  description = "AWS region to deploy resources"
  type        = string
  default     = "us-west-2"
}

variable "vpc_cidr_block" {
  description = "CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "subnet_cidr_block" {
  description = "CIDR block for the subnet"
  type        = string
  default     = "10.0.1.0/24"
}

variable "availability_zone" {
  description = "Availability zone for the subnet"
  type        = string
  default     = "us-west-2a"
}

variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t2.micro"
}

variable "ami_id" {
  description = "AMI ID for the EC2 instance"
  type        = string
  default     = "ami-0c55b159cbfafe1f0"  # Amazon Linux 2 in us-west-2
}

variable "environment" {
  description = "Deployment environment"
  type        = string
  default     = "dev"
}

Step 3: Create Main Configuration

Create a main.tf file:


# main.tf
# Create a VPC
resource "aws_vpc" "main" {
  cidr_block = var.vpc_cidr_block
  
  tags = {
    Name        = "main-vpc"
    Environment = var.environment
  }
}

# Create a subnet in the VPC
resource "aws_subnet" "public" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = var.subnet_cidr_block
  availability_zone = var.availability_zone
  
  map_public_ip_on_launch = true
  
  tags = {
    Name        = "public-subnet"
    Environment = var.environment
  }
}

# Create an internet gateway
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  
  tags = {
    Name        = "main-igw"
    Environment = var.environment
  }
}

# Create a 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.main.id
  }
  
  tags = {
    Name        = "public-route-table"
    Environment = var.environment
  }
}

# Associate the route table with the subnet
resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}

# Create a security group
resource "aws_security_group" "web" {
  name        = "web-sg"
  description = "Allow inbound HTTP and SSH traffic"
  vpc_id      = aws_vpc.main.id
  
  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    description = "Allow HTTP"
  }
  
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    description = "Allow SSH"
  }
  
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
    description = "Allow all outbound traffic"
  }
  
  tags = {
    Name        = "web-sg"
    Environment = var.environment
  }
}

# Create an EC2 instance
resource "aws_instance" "web" {
  ami                    = var.ami_id
  instance_type          = var.instance_type
  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 = "web-server" Environment = var.environment } }

Step 4: Define Outputs

Create an outputs.tf file:


# outputs.tf
output "vpc_id" {
  description = "ID of the created VPC"
  value       = aws_vpc.main.id
}

output "subnet_id" {
  description = "ID of the created subnet"
  value       = aws_subnet.public.id
}

output "instance_id" {
  description = "ID of the created EC2 instance"
  value       = aws_instance.web.id
}

output "public_ip" {
  description = "Public IP address of the EC2 instance"
  value       = aws_instance.web.public_ip
}

output "website_url" {
  description = "URL to access the web server"
  value       = "http://${aws_instance.web.public_ip}"
}

Step 5: Create .gitignore

Create a .gitignore file:


# .gitignore
# Local .terraform directories
**/.terraform/*

# .tfstate files
*.tfstate
*.tfstate.*

# Crash log files
crash.log

# Exclude all .tfvars files, which are likely to contain sensitive data
*.tfvars

# Ignore override files as they are usually used for local settings
override.tf
override.tf.json
*_override.tf
*_override.tf.json

# Ignore CLI configuration files
.terraformrc
terraform.rc

Step 6: Initialize and Apply

Now let's execute the Terraform workflow:


# Initialize Terraform
terraform init

# Validate the configuration
terraform validate

# Format the configuration
terraform fmt

# Create an execution plan
terraform plan

# Apply the configuration
terraform apply

What's Happening Behind the Scenes

When you run these commands, Terraform:

  1. During Init:
    • Creates a .terraform directory to store plugins and modules
    • Downloads the AWS provider plugin
    • Sets up the backend for state storage
  2. During Plan:
    • Reads the current state or initializes a new one
    • Refreshes state to match real-world resources
    • Determines what actions are necessary to achieve desired state
    • Creates a dependency graph of resources
    • Shows what will be created, modified, or destroyed
  3. During Apply:
    • Executes the plan in the correct order based on dependencies
    • Makes API calls to AWS to create the VPC, subnet, etc.
    • Updates the state file with the IDs and properties of created resources
    • Displays output values specified in outputs.tf

The result is a complete network setup with a web server running Apache, accessible via its public IP.

Step 7: Verify and Clean Up


# Check outputs
terraform output

# Access the web server
echo "Open http://$(terraform output -raw public_ip) in your browser"

# When finished, destroy the resources
terraform destroy

Terraform Language Features

HCL Syntax Essentials

HashiCorp Configuration Language (HCL) is the syntax used in Terraform files.

Blocks and Arguments


# Block structure
resource "aws_instance" "example" {  # Block type, resource type, and name label
  ami           = "ami-0c55b159cbfafe1f0"  # Argument
  instance_type = "t2.micro"                # Another argument
  
  # Nested block
  ebs_block_device {
    device_name = "/dev/sdh"
    volume_size = 100
  }
}

Data Types


# String
variable "region" {
  type    = string
  default = "us-west-2"
}

# Number
variable "port" {
  type    = number
  default = 80
}

# Boolean
variable "enable_logging" {
  type    = bool
  default = true
}

# List
variable "availability_zones" {
  type    = list(string)
  default = ["us-west-2a", "us-west-2b", "us-west-2c"]
}

# Map
variable "tags" {
  type    = map(string)
  default = {
    Environment = "Development"
    Project     = "Example"
  }
}

# Complex types
variable "subnet_config" {
  type = object({
    cidr_block        = string
    availability_zone = string
    public            = bool
    tags              = map(string)
  })
  
  default = {
    cidr_block        = "10.0.1.0/24"
    availability_zone = "us-west-2a"
    public            = true
    tags              = {
      Name = "Public Subnet"
    }
  }
}

Expressions and Functions


# String interpolation
resource "aws_instance" "example" {
  tags = {
    Name = "web-server-${var.environment}"  # String interpolation
  }
}

# Conditional expression
resource "aws_instance" "example" {
  instance_type = var.environment == "production" ? "t2.medium" : "t2.micro"
}

# For expressions
resource "aws_subnet" "example" {
  count             = length(var.availability_zones)
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.${count.index + 1}.0/24"
  availability_zone = var.availability_zones[count.index]
}

# Built-in functions
locals {
  upper_name = upper(var.name)
  timestamp  = formatdate("YYYY-MM-DD", timestamp())
  subnets    = cidrsubnets(var.vpc_cidr, 8, 8, 8, 8)
}

Advanced Terraform Features

Count and For Each


# Using count
resource "aws_instance" "server" {
  count         = 3
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"
  
  tags = {
    Name = "server-${count.index + 1}"
  }
}

# Using for_each with a map
resource "aws_instance" "server" {
  for_each      = {
    web  = "t2.micro"
    api  = "t2.small"
    db   = "t2.medium"
  }
  
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = each.value
  
  tags = {
    Name = "${each.key}-server"
  }
}

# Using for_each with a set
resource "aws_security_group_rule" "ingress" {
  for_each          = toset(["80", "443", "22"])
  
  type              = "ingress"
  security_group_id = aws_security_group.example.id
  from_port         = each.value
  to_port           = each.value
  protocol          = "tcp"
  cidr_blocks       = ["0.0.0.0/0"]
}

Dynamic Blocks


# Without dynamic blocks (repetitive)
resource "aws_security_group" "example" {
  name        = "example"
  description = "Example security group"
  
  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  
  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["10.0.0.0/8"]
  }
}

# With dynamic blocks (more concise)
variable "ingress_rules" {
  default = [
    {
      port        = 80
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
    },
    {
      port        = 443
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
    },
    {
      port        = 22
      protocol    = "tcp"
      cidr_blocks = ["10.0.0.0/8"]
    }
  ]
}

resource "aws_security_group" "example" {
  name        = "example"
  description = "Example security group"
  
  dynamic "ingress" {
    for_each = var.ingress_rules
    content {
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = ingress.value.protocol
      cidr_blocks = ingress.value.cidr_blocks
    }
  }
}

Locals


# Using locals for calculated values
locals {
  common_tags = {
    Environment = var.environment
    Project     = var.project_name
    Owner       = "Terraform"
    ManagedBy   = "Terraform"
  }
  
  is_production    = var.environment == "production"
  instance_type    = local.is_production ? "t2.medium" : "t2.micro"
  availability_zones = slice(data.aws_availability_zones.available.names, 0, var.az_count)
}

resource "aws_instance" "example" {
  ami           = var.ami_id
  instance_type = local.instance_type
  
  tags = merge(
    local.common_tags,
    {
      Name = "example-instance"
    }
  )
}

Lifecycle Rules


# Lifecycle rules modify how Terraform handles resource changes
resource "aws_instance" "example" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"
  
  lifecycle {
    create_before_destroy = true  # Create replacement before destroying original
    prevent_destroy = true         # Prevent accidental destruction
    ignore_changes = [
      tags,                        # Ignore changes to tags
      user_data                    # Ignore changes to user_data
    ]
  }
}

HCL Style Guidelines

  • Indentation: Use 2 spaces for indentation
  • Block Spacing: Add a blank line between blocks
  • Naming: Use snake_case for resource names and variables
  • String Values: Double quotes are preferred over single quotes
  • Alignment: Align equal signs for readability
  • Comments: Use # for comments (not // or /*...*/)
  • Ordering: Place required arguments first, followed by optional ones
  • Autoformatting: Run terraform fmt before committing code

Terraform Modules

Modules are containers for multiple resources that are used together, allowing you to create reusable components.

Module Structure


modules/vpc/
├── main.tf         # Main configuration
├── variables.tf    # Input variables
├── outputs.tf      # Output values
└── README.md       # Documentation

Creating a Basic Module

Module Definition (modules/vpc/main.tf)


# main.tf in modules/vpc
resource "aws_vpc" "this" {
  cidr_block           = var.cidr_block
  enable_dns_support   = var.enable_dns_support
  enable_dns_hostnames = var.enable_dns_hostnames
  
  tags = merge(
    var.tags,
    {
      Name = var.name
    }
  )
}

resource "aws_subnet" "public" {
  count = length(var.public_subnet_cidrs)
  
  vpc_id                  = aws_vpc.this.id
  cidr_block              = var.public_subnet_cidrs[count.index]
  availability_zone       = var.availability_zones[count.index % length(var.availability_zones)]
  map_public_ip_on_launch = true
  
  tags = merge(
    var.tags,
    {
      Name = "${var.name}-public-${count.index + 1}"
      Type = "Public"
    }
  )
}

resource "aws_subnet" "private" {
  count = length(var.private_subnet_cidrs)
  
  vpc_id            = aws_vpc.this.id
  cidr_block        = var.private_subnet_cidrs[count.index]
  availability_zone = var.availability_zones[count.index % length(var.availability_zones)]
  
  tags = merge(
    var.tags,
    {
      Name = "${var.name}-private-${count.index + 1}"
      Type = "Private"
    }
  )
}

resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id
  
  tags = merge(
    var.tags,
    {
      Name = "${var.name}-igw"
    }
  )
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.this.id
  
  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.this.id
  }
  
  tags = merge(
    var.tags,
    {
      Name = "${var.name}-public-rt"
    }
  )
}

resource "aws_route_table_association" "public" {
  count = length(aws_subnet.public)
  
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table" "private" {
  vpc_id = aws_vpc.this.id
  
  tags = merge(
    var.tags,
    {
      Name = "${var.name}-private-rt"
    }
  )
}

resource "aws_route_table_association" "private" {
  count = length(aws_subnet.private)
  
  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private.id
}

Module Variables (modules/vpc/variables.tf)


# variables.tf in modules/vpc
variable "name" {
  description = "Name prefix for the VPC resources"
  type        = string
}

variable "cidr_block" {
  description = "CIDR block for the VPC"
  type        = string
}

variable "enable_dns_support" {
  description = "Enable DNS support in the VPC"
  type        = bool
  default     = true
}

variable "enable_dns_hostnames" {
  description = "Enable DNS hostnames in the VPC"
  type        = bool
  default     = true
}

variable "availability_zones" {
  description = "List of availability zones to use"
  type        = list(string)
}

variable "public_subnet_cidrs" {
  description = "List of CIDR blocks for public subnets"
  type        = list(string)
  default     = []
}

variable "private_subnet_cidrs" {
  description = "List of CIDR blocks for private subnets"
  type        = list(string)
  default     = []
}

variable "tags" {
  description = "Tags to apply to all resources"
  type        = map(string)
  default     = {}
}

Module Outputs (modules/vpc/outputs.tf)


# outputs.tf in modules/vpc
output "vpc_id" {
  description = "ID of the created VPC"
  value       = aws_vpc.this.id
}

output "public_subnet_ids" {
  description = "List of public subnet IDs"
  value       = aws_subnet.public[*].id
}

output "private_subnet_ids" {
  description = "List of private subnet IDs"
  value       = aws_subnet.private[*].id
}

output "internet_gateway_id" {
  description = "ID of the created internet gateway"
  value       = aws_internet_gateway.this.id
}

output "public_route_table_id" {
  description = "ID of the public route table"
  value       = aws_route_table.public.id
}

output "private_route_table_id" {
  description = "ID of the private route table"
  value       = aws_route_table.private.id
}

Using the Module


# Using the VPC module in main.tf
module "vpc" {
  source = "./modules/vpc"
  
  name               = "example-vpc"
  cidr_block         = "10.0.0.0/16"
  availability_zones = ["us-west-2a", "us-west-2b", "us-west-2c"]
  
  public_subnet_cidrs  = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  private_subnet_cidrs = ["10.0.11.0/24", "10.0.12.0/24", "10.0.13.0/24"]
  
  tags = {
    Environment = "Development"
    Project     = "Example"
  }
}

# Using module outputs
resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"
  subnet_id     = module.vpc.public_subnet_ids[0]
  
  tags = {
    Name = "web-server"
  }
}

Module Sources

Terraform modules can be sourced from various locations:


# Local path
module "vpc" {
  source = "./modules/vpc"
}

# Git repository
module "vpc" {
  source = "git::https://github.com/example/terraform-aws-vpc.git?ref=v1.2.0"
}

# Terraform Registry
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "3.14.0"
}

# S3 bucket
module "vpc" {
  source = "s3::https://s3-eu-west-1.amazonaws.com/examplebucket/modules/vpc/v1.2.0.zip"
}

# HTTP URL
module "vpc" {
  source = "https://example.com/modules/vpc.zip"
}

Module Best Practices

  • Pin Versions: Always specify module versions for stability
  • Flat Structure: Avoid deeply nested modules
  • Composability: Design small, focused modules that can be combined
  • Documentation: Include README.md with usage examples and input/output descriptions
  • Input Validation: Use variable validation to prevent misuse
  • Default Values: Provide sensible defaults where appropriate
  • Avoid Hardcoding: Parameterize values that might change
  • Module Testing: Include examples or tests to verify functionality

State Management in Terraform

Understanding Terraform state is crucial for effectively working with infrastructure at scale.

What is Terraform State?

Local vs. Remote State

Local State (Default)

  • Stored in terraform.tfstate file in workspace directory
  • Simple to set up - no additional configuration needed
  • Works well for personal projects or experimentation
  • Problematic for team collaboration
  • No built-in locking mechanism
  • Risk of loss or accidental commitment to version control

Remote State

  • Stored in a shared backend (S3, Azure Blob, GCS, etc.)
  • Enables team collaboration
  • Provides state locking to prevent concurrent modifications
  • Increased security (encryption, access controls)
  • Better disaster recovery
  • Remote operations (plan/apply in a consistent environment)

Configuring Remote State

AWS S3 and DynamoDB Backend


# backend.tf
terraform {
  backend "s3" {
    bucket         = "terraform-state-bucket"
    key            = "myproject/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-locks"
  }
}

# Create these resources first (in a separate bootstrap configuration)
resource "aws_s3_bucket" "terraform_state" {
  bucket = "terraform-state-bucket"
  
  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-locks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"
  
  attribute {
    name = "LockID"
    type = "S"
  }
}

Azure Storage Backend


# backend.tf
terraform {
  backend "azurerm" {
    resource_group_name  = "terraform-state-rg"
    storage_account_name = "terraformstate12345"
    container_name       = "tfstate"
    key                  = "myproject.terraform.tfstate"
  }
}

Google Cloud Storage Backend


# backend.tf
terraform {
  backend "gcs" {
    bucket  = "terraform-state-bucket"
    prefix  = "myproject"
  }
}

State Management Commands


# List resources in the state
terraform state list

# Show details of a specific resource
terraform state show aws_instance.web

# Move a resource within the state (rename)
terraform state mv aws_instance.old_name aws_instance.new_name

# Remove a resource from state (without destroying it)
terraform state rm aws_instance.web

# Pull current state and output to stdout
terraform state pull

# Update state from a file
terraform state push terraform.tfstate

# Import existing infrastructure into state
terraform import aws_instance.imported i-1234567890abcdef0

# Force a refresh of state against real resources
terraform refresh

Workspaces

Terraform workspaces allow you to manage multiple distinct sets of infrastructure using the same configuration files.


# List workspaces
terraform workspace list

# Create a new workspace
terraform workspace new development

# Switch workspace
terraform workspace select production

# Show current workspace
terraform workspace show

# Delete a workspace
terraform workspace delete staging

Using Workspaces in Configuration


# Use workspace name for environment-specific configuration
resource "aws_instance" "example" {
  instance_type = terraform.workspace == "production" ? "t2.medium" : "t2.micro"
  
  tags = {
    Environment = terraform.workspace
  }
}

State Management Best Practices

  • Always Use Remote State: For all but the simplest personal projects
  • Secure Your State: Ensure state is encrypted and access-controlled
  • Use State Locking: Prevent concurrent modifications
  • Separate State by Environment: Use workspaces or separate state files
  • Backup State: Enable versioning for state storage
  • Handle Sensitive Data Carefully: Mark outputs as sensitive where needed
  • Limit State Manipulation: Use terraform state commands sparingly

Learning Activities

Activity 1: Create a Multi-Tier Web Application

Extend the basic web server example to create a multi-tier application:

  1. Create a new Terraform project with a VPC module
  2. Add a web tier with EC2 instances in a public subnet
  3. Add an application tier with EC2 instances in a private subnet
  4. Add a database tier using RDS in a private subnet
  5. Set up security groups with appropriate rules for each tier
  6. Configure an Application Load Balancer for the web tier
  7. Use variables to parameterize the configuration
  8. Define outputs to provide useful information

Activity 2: Remote State and CI/CD

Set up a proper state management and CI/CD workflow:

  1. Create an S3 bucket and DynamoDB table for remote state
  2. Configure your Terraform project to use remote state
  3. Set up multiple workspaces (dev, staging, prod)
  4. Create environment-specific variable files
  5. Set up a basic CI/CD pipeline (GitHub Actions or similar)
  6. Implement a plan stage that runs on pull requests
  7. Add an apply stage that runs on merge to main branch

Activity 3: Custom Module Development

Create a reusable custom module:

  1. Identify a common infrastructure pattern in your project
  2. Extract it into a reusable module with variables and outputs
  3. Add validation and sensible defaults
  4. Create a README.md with usage examples
  5. Use the module in your infrastructure configuration
  6. Consider publishing the module to a private or public registry

Key Takeaways

Further Learning Resources