
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.
# 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 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
# 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"
}
}
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 provisions infrastructure (create EC2, VPC, RDS). Ansible configures software on that infrastructure (install nginx, deploy app). They are complementary. Terraform first, Ansible second.
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.
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.
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.
# 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 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"
}
}
# 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)
}
# 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"
}
# 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]
}
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"
}
}