Terraform Fundamentals

Terraform is an infrastructure as code tool that provisions and manages cloud resources through declarative configuration files. It supports multiple cloud providers and enables version-controlled, automated infrastructure management.

What is Terraform

Terraform manages infrastructure lifecycle through code, supporting AWS, Azure, GCP, and hundreds of other providers.

Core Workflow

Terraform follows a consistent three-step workflow:

# 1. Initialize working directory terraform init # 2. Preview changes terraform plan # 3. Apply changes terraform apply

This workflow provides safety through explicit preview before making changes.

graph LR A[Write Configuration] --> B[terraform init] B --> C[terraform plan] C --> D{Review Changes} D -->|Approve| E[terraform apply] D -->|Reject| F[Modify Code] F --> C E --> G[Infrastructure Updated] style E fill:#e1f5ff style G fill:#d4f1d4

Declarative Configuration

Terraform uses HashiCorp Configuration Language (HCL):

# main.tf provider "aws" { region = "us-east-1" } resource "aws_instance" "web" { ami = "ami-0c55b159cbfafe1f0" instance_type = "t3.medium" tags = { Name = "web-server" Environment = "production" } } resource "aws_security_group" "web" { name = "web-server-sg" description = "Security group for web server" 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"] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } }

Terraform reads these declarations and creates the specified resources.

Terraform Basics

Understanding fundamental concepts enables effective Terraform usage.

Providers

Providers connect Terraform to infrastructure platforms:

# Configure AWS provider provider "aws" { region = "us-east-1" access_key = var.aws_access_key secret_key = var.aws_secret_key } # Configure Azure provider provider "azurerm" { features {} subscription_id = var.azure_subscription_id } # Configure multiple provider instances provider "aws" { alias = "us_west" region = "us-west-2" } # Use alternate provider resource "aws_instance" "west_coast" { provider = aws.us_west ami = "ami-0f87f5c6c0c8e4c0f" instance_type = "t3.medium" }

Providers translate Terraform configuration into API calls for specific platforms.

Resources

Resources define infrastructure components:

# VPC resource resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" enable_dns_hostnames = true enable_dns_support = true tags = { Name = "main-vpc" } } # Subnet resource referencing VPC 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 = "public-subnet-1a" } } # Internet gateway resource "aws_internet_gateway" "main" { vpc_id = aws_vpc.main.id tags = { Name = "main-igw" } }

Resource blocks have type (aws_vpc), local name (main), and configuration arguments.

Data Sources

Data sources query existing infrastructure:

# Lookup existing VPC data "aws_vpc" "existing" { id = "vpc-12345678" } # Find latest Amazon Linux AMI data "aws_ami" "amazon_linux" { most_recent = true owners = ["amazon"] filter { name = "name" values = ["amzn2-ami-hvm-*-x86_64-gp2"] } } # Use data source in resource resource "aws_instance" "web" { ami = data.aws_ami.amazon_linux.id instance_type = "t3.medium" subnet_id = data.aws_subnet.public.id }

Data sources enable referencing resources not managed by the current Terraform configuration.

Variables and Outputs

Variables parameterize configurations, while outputs expose values.

Input Variables

Define reusable parameters:

# variables.tf variable "environment" { description = "Environment name (dev, staging, production)" type = string default = "dev" } variable "instance_count" { description = "Number of instances to create" type = number default = 1 validation { condition = var.instance_count >= 1 && var.instance_count <= 10 error_message = "Instance count must be between 1 and 10." } } variable "instance_type" { description = "EC2 instance type" type = string default = "t3.medium" } variable "allowed_cidr_blocks" { description = "CIDR blocks allowed to access resources" type = list(string) default = ["10.0.0.0/16"] } variable "tags" { description = "Tags to apply to resources" type = map(string) default = { ManagedBy = "Terraform" } }

Use variables in configuration:

# main.tf resource "aws_instance" "app" { count = var.instance_count ami = data.aws_ami.amazon_linux.id instance_type = var.instance_type tags = merge( var.tags, { Name = "app-server-${count.index + 1}" Environment = var.environment } ) }

Provide variable values multiple ways:

# Command line terraform apply -var="environment=production" -var="instance_count=3" # Variable file terraform apply -var-file="production.tfvars" # Environment variables export TF_VAR_environment=production export TF_VAR_instance_count=3 terraform apply

Variable file example:

# production.tfvars environment = "production" instance_count = 5 instance_type = "t3.large" allowed_cidr_blocks = [ "10.0.0.0/16", "172.16.0.0/12" ] tags = { ManagedBy = "Terraform" Environment = "production" CostCenter = "engineering" }

Output Values

Expose information from resources:

# outputs.tf output "instance_ids" { description = "IDs of created instances" value = aws_instance.app[*].id } output "instance_public_ips" { description = "Public IP addresses of instances" value = aws_instance.app[*].public_ip } output "load_balancer_dns" { description = "DNS name of load balancer" value = aws_lb.main.dns_name } output "database_endpoint" { description = "Database connection endpoint" value = aws_db_instance.main.endpoint sensitive = true # Hide from console output }

View outputs:

# Show all outputs terraform output # Show specific output terraform output instance_public_ips # Output as JSON terraform output -json

Outputs enable sharing information between Terraform configurations and external tools.

State Management

Terraform tracks infrastructure state to determine required changes.

Local State

Default state storage in local file:

# Creates terraform.tfstate terraform apply # State file contents (JSON) { "version": 4, "terraform_version": "1.5.0", "serial": 3, "resources": [ { "mode": "managed", "type": "aws_instance", "name": "web", "instances": [ { "attributes": { "id": "i-0123456789abcdef0", "ami": "ami-0c55b159cbfafe1f0", "instance_type": "t3.medium" } } ] } ] }

Local state works for single-user scenarios but causes issues with teams.

Remote State

Store state remotely for team collaboration:

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

Configure backend:

# Initialize with remote backend terraform init # Migrate existing local state to remote terraform init -migrate-state

Remote state benefits:

sequenceDiagram participant A as Engineer A participant B as Engineer B participant S3 as S3 Backend participant DDB as DynamoDB Lock A->>DDB: Acquire lock DDB->>A: Lock granted A->>S3: Read state A->>A: Calculate changes A->>S3: Update state A->>DDB: Release lock B->>DDB: Acquire lock Note over B,DDB: Waits for A to finish DDB->>B: Lock granted B->>S3: Read state

State Commands

Manage state directly:

# List resources in state terraform state list # Show specific resource terraform state show aws_instance.web # Move resource to different name terraform state mv aws_instance.web aws_instance.app # Remove resource from state (doesn't destroy) terraform state rm aws_instance.old # Import existing resource terraform import aws_instance.web i-0123456789abcdef0 # Replace resource (force recreation) terraform apply -replace="aws_instance.web"

Modules

Modules organize and reuse Terraform code.

Creating Modules

Module structure:

modules/ └── vpc/ ├── main.tf ├── variables.tf ├── outputs.tf └── README.md

Module implementation:

# modules/vpc/variables.tf variable "cidr_block" { description = "CIDR block for VPC" type = string } variable "environment" { description = "Environment name" type = string } variable "availability_zones" { description = "List of availability zones" type = list(string) } # modules/vpc/main.tf resource "aws_vpc" "main" { cidr_block = var.cidr_block enable_dns_hostnames = true enable_dns_support = true tags = { Name = "${var.environment}-vpc" Environment = var.environment } } resource "aws_subnet" "public" { count = length(var.availability_zones) vpc_id = aws_vpc.main.id cidr_block = cidrsubnet(var.cidr_block, 8, count.index) availability_zone = var.availability_zones[count.index] map_public_ip_on_launch = true tags = { Name = "${var.environment}-public-${var.availability_zones[count.index]}" Environment = var.environment } } resource "aws_internet_gateway" "main" { vpc_id = aws_vpc.main.id tags = { Name = "${var.environment}-igw" Environment = var.environment } } # modules/vpc/outputs.tf output "vpc_id" { description = "VPC ID" value = aws_vpc.main.id } output "public_subnet_ids" { description = "Public subnet IDs" value = aws_subnet.public[*].id } output "cidr_block" { description = "VPC CIDR block" value = aws_vpc.main.cidr_block }

Using Modules

Reference modules in configuration:

# main.tf module "vpc" { source = "./modules/vpc" cidr_block = "10.0.0.0/16" environment = "production" availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"] } # Use module outputs resource "aws_instance" "app" { ami = data.aws_ami.amazon_linux.id instance_type = "t3.medium" subnet_id = module.vpc.public_subnet_ids[0] tags = { Name = "app-server" } } output "vpc_id" { value = module.vpc.vpc_id }

Modules from registry:

# Use public module from Terraform Registry module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "5.1.0" name = "production-vpc" cidr = "10.0.0.0/16" azs = ["us-east-1a", "us-east-1b", "us-east-1c"] private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"] public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"] enable_nat_gateway = true enable_vpn_gateway = false tags = { Environment = "production" } }

Terraform Workflow

Production Terraform usage follows structured workflows.

Development Cycle

# 1. Write configuration vim main.tf # 2. Format code terraform fmt # 3. Initialize (download providers) terraform init # 4. Validate syntax terraform validate # 5. Plan changes terraform plan -out=tfplan # 6. Review plan output # ... review proposed changes ... # 7. Apply plan terraform apply tfplan # 8. Verify infrastructure aws ec2 describe-instances --filters "Name=tag:Name,Values=web-server"

Workspace Management

Workspaces isolate multiple instances of infrastructure:

# List workspaces terraform workspace list # Create new workspace terraform workspace new staging # Switch workspace terraform workspace select production # Show current workspace terraform workspace show

Use workspace in configuration:

locals { environment = terraform.workspace instance_count = { dev = 1 staging = 2 production = 5 } } resource "aws_instance" "app" { count = local.instance_count[local.environment] ami = data.aws_ami.amazon_linux.id instance_type = "t3.medium" tags = { Name = "app-${local.environment}-${count.index + 1}" Environment = local.environment } }

Drift Detection

Identify manual changes outside Terraform:

# Compare state with actual infrastructure terraform plan -refresh-only # Shows resources modified outside Terraform terraform plan # Example output: # Note: Objects have changed outside of Terraform # # aws_instance.web has changed: # ~ resource "aws_instance" "web" { # ~ instance_type = "t3.medium" -> "t3.large" # } # Import manual changes to state terraform apply -refresh-only # Or revert to Terraform configuration terraform apply

Common Patterns

Effective Terraform usage follows established patterns.

Resource Dependencies

Explicit dependencies:

resource "aws_instance" "web" { ami = data.aws_ami.amazon_linux.id instance_type = "t3.medium" # Explicit dependency depends_on = [ aws_security_group.web, aws_subnet.public ] }

Implicit dependencies through references:

resource "aws_security_group" "web" { vpc_id = aws_vpc.main.id # Implicit dependency on VPC } resource "aws_instance" "web" { security_groups = [aws_security_group.web.id] # Implicit dependency }

Conditional Resources

Create resources conditionally:

variable "enable_monitoring" { type = bool default = false } resource "aws_cloudwatch_metric_alarm" "high_cpu" { count = var.enable_monitoring ? 1 : 0 alarm_name = "high-cpu-alarm" comparison_operator = "GreaterThanThreshold" evaluation_periods = 2 metric_name = "CPUUtilization" namespace = "AWS/EC2" period = 300 statistic = "Average" threshold = 80 }

Dynamic Blocks

Generate repeated nested blocks:

variable "ingress_rules" { type = list(object({ from_port = number to_port = number protocol = string cidr_blocks = list(string) description = string })) default = [ { from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] description = "HTTP" }, { from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] description = "HTTPS" } ] } resource "aws_security_group" "web" { name = "web-sg" description = "Security group for web servers" 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 } } }

Common Pitfalls

State File Loss: Losing state files prevents Terraform from managing infrastructure. Always use remote backends with backups.

Manual Changes: Modifying infrastructure outside Terraform causes drift. Either import changes or revert them.

Large State Files: Managing too many resources in single state causes slow operations. Split into multiple configurations with separate states.

Hardcoded Values: Embedding environment-specific values prevents reuse. Use variables and modules for flexibility.

Key Takeaways