DevOps Roadmap -- Part 7: Terraform & Infrastructure as Code

By Suraj Ahir 2025-11-08 11 min read

← Part 6DevOps Roadmap · Part 7 of 12Part 8 →
DevOps Roadmap -- Part 7: Terraform & Infrastructure as Code

Before Terraform, provisioning cloud infrastructure meant clicking through console UIs, running manual AWS CLI commands, or writing brittle setup scripts. One engineer's infrastructure differed from another's. Production had configuration drift from staging. Rolling back was impossible. Terraform changed all of this by making infrastructure declarative and reproducible.

Terraform Core Concepts

Provider, resource, variable, output
# main.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
  backend "s3" {
    bucket = "mycompany-terraform-state"
    key    = "prod/terraform.tfstate"
    region = "us-east-1"
  }
}

provider "aws" {
  region = var.aws_region
}

# variables.tf
variable "aws_region" {
  type    = string
  default = "us-east-1"
}

variable "environment" {
  type = string
}

# main resources
resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.micro"
  
  tags = {
    Name        = "${var.environment}-web-server"
    Environment = var.environment
  }
}

# outputs.tf
output "web_public_ip" {
  value = aws_instance.web.public_ip
}

Terraform Workflow

Plan, apply, destroy
terraform init          # Download providers, initialise backend
terraform validate      # Check syntax
terraform fmt           # Format code
terraform plan          # Preview changes (NEVER skip this)
terraform plan -out=tfplan  # Save plan to file
terraform apply tfplan  # Apply the saved plan
terraform apply         # Plan and apply interactively
terraform destroy       # Destroy all managed resources

# Working with specific resources
terraform plan -target=aws_instance.web
terraform apply -target=aws_security_group.web_sg

Terraform Modules

Reusable infrastructure components
# modules/ec2/main.tf
resource "aws_instance" "this" {
  ami           = var.ami_id
  instance_type = var.instance_type
  tags          = var.tags
}

output "instance_id" {
  value = aws_instance.this.id
}

# Call the module in root main.tf
module "web_server" {
  source        = "./modules/ec2"
  ami_id        = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.medium"
  tags = {
    Name = "production-web"
  }
}

Frequently Asked Questions

What is Terraform state?

Terraform tracks what it created in a state file (terraform.tfstate). It compares current state to desired state and plans changes. Store state remotely in S3 with DynamoDB locking for teams -- never commit state files to Git.

Terraform vs Ansible?

Terraform provisions infrastructure (create EC2, VPC, RDS). Ansible configures software on that infrastructure (install nginx, deploy app). They are complementary. Terraform first, Ansible second.

What is terraform import?

Import existing resources into Terraform state without recreating them. terraform import aws_instance.web i-1234567890. Use when you want to bring manually created resources under Terraform management.

What are Terraform workspaces?

Workspaces allow multiple states for the same configuration -- one for dev, one for staging, one for prod. terraform workspace new staging creates a new workspace. Used to manage multiple environments with the same code.

How do I prevent Terraform from destroying production resources?

Use lifecycle prevent_destroy = true on critical resources. Use targeted applies. Require plan review before apply. In CI/CD, only allow terraform apply on specific branches with required approvals.

In Part 8, we cover Ansible -- automating configuration management across fleets of servers.

Key takeaways

Continue reading
Part 8 — Logging at Scale
Tame the firehose.
Suraj Ahir — author of SRJahir Tech

Written by

Suraj Ahir

Cloud & DevOps engineer running four live production services on my own AWS infrastructure. I write everything on this site myself — no ghostwriters, no AI filler.

← Part 6DevOps Roadmap · Part 7 of 12Part 8 →
← Back to Blog
Disclaimer: Educational content only.

Terraform State Management in Teams

Remote state with S3 and DynamoDB locking
# backend.tf -- configure remote state FIRST before anything else
terraform {
  backend "s3" {
    bucket         = "mycompany-terraform-state"
    key            = "production/webapp/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"  # Prevent concurrent applies
  }
}

# Create the S3 bucket and DynamoDB table first (bootstrap):
aws s3api create-bucket --bucket mycompany-terraform-state --region us-east-1
aws s3api put-bucket-versioning --bucket mycompany-terraform-state   --versioning-configuration Status=Enabled
aws dynamodb create-table   --table-name terraform-state-lock   --attribute-definitions AttributeName=LockID,AttributeType=S   --key-schema AttributeName=LockID,KeyType=HASH   --billing-mode PAY_PER_REQUEST

Terraform Workspace Strategy for Environments

Managing dev/staging/prod with workspaces
terraform workspace new dev
terraform workspace new staging
terraform workspace new production
terraform workspace list

# Switch workspace
terraform workspace select staging

# Reference workspace in resources
resource "aws_instance" "web" {
  instance_type = terraform.workspace == "production" ? "t3.medium" : "t3.micro"
  
  tags = {
    Environment = terraform.workspace
    Name        = "${terraform.workspace}-web-server"
  }
}

Terraform Testing with Terratest

Automated infrastructure tests in Go
# test/main_test.go
package test

import (
    "testing"
    "github.com/gruntwork-io/terratest/modules/terraform"
    "github.com/stretchr/testify/assert"
)

func TestWebServer(t *testing.T) {
    opts := &terraform.Options{
        TerraformDir: "../",
        Vars: map[string]interface{}{
            "environment": "test",
        },
    }
    defer terraform.Destroy(t, opts)
    terraform.InitAndApply(t, opts)
    
    publicIP := terraform.Output(t, opts, "web_public_ip")
    assert.NotEmpty(t, publicIP)
}

Terraform Provider Configuration Patterns

Multi-provider and assumed role patterns
# Multiple AWS accounts with different providers
provider "aws" {
  alias  = "primary"
  region = "us-east-1"
}

provider "aws" {
  alias  = "dr"
  region = "ap-south-1"
  assume_role {
    role_arn = "arn:aws:iam::123456789:role/TerraformDR"
  }
}

resource "aws_s3_bucket" "primary" {
  provider = aws.primary
  bucket   = "myapp-primary"
}

resource "aws_s3_bucket" "dr" {
  provider = aws.dr
  bucket   = "myapp-disaster-recovery"
}

Terraform data Sources and Dynamic Lookups

Reference existing resources dynamically
# data sources: reference existing AWS resources
data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"]  # Canonical
  
  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
  }
}

data "aws_vpc" "main" {
  filter {
    name   = "tag:Name"
    values = ["production-vpc"]
  }
}

data "aws_subnets" "private" {
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.main.id]
  }
  tags = { Tier = "private" }
}

resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id    # Latest Ubuntu AMI
  instance_type = var.instance_type
  subnet_id     = data.aws_subnets.private.ids[0]
}

for_each and Dynamic Blocks

Create multiple resources from a map
variable "environments" {
  default = {
    dev  = { instance_type = "t3.micro",  min_size = 1 }
    prod = { instance_type = "t3.medium", min_size = 2 }
  }
}

resource "aws_autoscaling_group" "env" {
  for_each = var.environments
  
  name             = "${each.key}-asg"
  min_size         = each.value.min_size
  max_size         = each.value.min_size * 5
  
  launch_template {
    id      = aws_launch_template.app[each.key].id
    version = "$Latest"
  }
}