Terraform Infrastructure Guide 2026 — Infrastructure as Code for AWS, GCP, and Azure
Advertisement
Introduction
Why This Matters
"ClickOps" — manually creating resources in the AWS console — does not scale and cannot be audited. Terraform lets you define infrastructure as version-controlled code, reproduce environments exactly, and review infrastructure changes in pull requests. In 2026, Terraform remains the most widely adopted infrastructure-as-code tool across cloud providers.
Core Concepts
Provider → Plugin that manages a cloud (aws, google, azurerm)
Resource → A cloud object to create (aws_s3_bucket, aws_instance)
Variable → Input to parameterize configurations
Output → Values exported for use by other modules
State → Terraform's record of what it created
Module → Reusable, parameterized group of resources
Backend → Where remote state is stored (S3, Terraform Cloud)Basic AWS Setup with Remote State
# main.tf
terraform {
required_version = ">= 1.7"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "my-terraform-state"
key = "production/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = "myapp"
Environment = var.environment
ManagedBy = "terraform"
}
}
}Variables and Validation
# variables.tf
variable "environment" {
description = "Deployment environment"
type = string
validation {
condition = contains(["development", "staging", "production"], var.environment)
error_message = "Environment must be development, staging, or production."
}
}
variable "db_password" {
description = "Database master password"
type = string
sensitive = true
}
variable "app_name" {
type = string
default = "myapp"
}
variable "aws_region" {
type = string
default = "us-east-1"
}Complete AWS Infrastructure
# network.tf
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
}
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
}
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 10}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
}
# S3 bucket with public access blocked
resource "aws_s3_bucket" "assets" {
bucket = "${var.app_name}-assets-${var.environment}"
}
resource "aws_s3_bucket_public_access_block" "assets" {
bucket = aws_s3_bucket.assets.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# RDS PostgreSQL — multi-AZ in production only
resource "aws_db_instance" "postgres" {
identifier = "${var.app_name}-${var.environment}"
engine = "postgres"
engine_version = "16.2"
instance_class = var.environment == "production" ? "db.t3.medium" : "db.t3.micro"
allocated_storage = 20
max_allocated_storage = 100
storage_encrypted = true
db_name = replace(var.app_name, "-", "_")
username = "dbadmin"
password = var.db_password
multi_az = var.environment == "production"
skip_final_snapshot = var.environment != "production"
deletion_protection = var.environment == "production"
vpc_security_group_ids = [aws_security_group.rds.id]
db_subnet_group_name = aws_db_subnet_group.main.name
backup_retention_period = var.environment == "production" ? 7 : 1
backup_window = "03:00-04:00"
}Reusable Modules
# modules/ecs-service/main.tf
variable "service_name" { type = string }
variable "image" { type = string }
variable "port" { type = number }
variable "cpu" { type = number; default = 256 }
variable "memory" { type = number; default = 512 }
variable "desired_count" { type = number; default = 2 }
resource "aws_ecs_task_definition" "service" {
family = var.service_name
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = var.cpu
memory = var.memory
container_definitions = jsonencode([{
name = var.service_name
image = var.image
portMappings = [{ containerPort = var.port }]
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = "/ecs/${var.service_name}"
awslogs-region = "us-east-1"
}
}
}])
}
output "task_definition_arn" {
value = aws_ecs_task_definition.service.arn
}
# Use the module
module "api" {
source = "./modules/ecs-service"
service_name = "api"
image = "ghcr.io/myapp/api:${var.api_version}"
port = 3000
desired_count = 3
}Workspaces for Multiple Environments
# Create and switch workspaces
terraform workspace new staging
terraform workspace new production
terraform workspace select production
terraform workspace list# Use workspace name to vary resources
resource "aws_instance" "app" {
count = terraform.workspace == "production" ? 3 : 1
instance_type = terraform.workspace == "production" ? "t3.medium" : "t3.micro"
# ...
}CI/CD with GitHub Actions
# .github/workflows/terraform.yml
name: Terraform
on:
pull_request:
paths: ['terraform/**']
push:
branches: [main]
paths: ['terraform/**']
jobs:
plan:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- run: terraform plan -no-color
env:
TF_VAR_db_password: ${{ secrets.DB_PASSWORD }}
apply:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform apply -auto-approve
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
TF_VAR_db_password: ${{ secrets.DB_PASSWORD }}Common Mistakes
- No remote state backend — local state files get lost and cannot be shared with teammates; always use S3 + DynamoDB locking
- Committing
terraform.tfvarswith secrets — usesensitive = trueon variables and pass secrets via CI/CD environment variables - No state locking — without DynamoDB locking, concurrent
terraform applyruns corrupt state - Hardcoding AMI IDs — use data sources (
data "aws_ami") to always fetch the latest approved image - No drift detection — run
terraform planin CI regularly to catch resources changed outside Terraform
Best Practices
- Pin provider versions (
~> 5.0) to prevent unexpected breaking changes onterraform init - Use
terraform fmtandterraform validatein pre-commit hooks to catch syntax errors before code review - Separate state per environment (
production/terraform.tfstate,staging/terraform.tfstate) - Use
terraform importto bring manually created resources under Terraform management - Run
checkovortfsecin CI to catch security misconfigurations before applying
Key Takeaways
- Terraform state tracks every resource it creates; never delete or manually edit the state file
- Remote state in S3 with DynamoDB locking is required for team workflows — prevents concurrent apply collisions
- Modules encapsulate reusable infrastructure patterns; use them to avoid copy-pasting VPC, security group, or ECS definitions
- Workspaces let a single Terraform codebase manage multiple environments with different variable values
terraform planshows exactly what will change beforeapply— always review the plan output in CI as part of code review- Mark secrets as
sensitive = trueso Terraform redacts them from logs and plan output - Use
datasources to reference existing resources (like VPC IDs or AMI IDs) without hardcoding values - The HashiCorp Terraform Associate certification validates these skills and is recognized by most cloud employers
Advertisement
Related reading
Terraform Complete Guide — Infrastructure as Code for Cloud Engineers5 min readCloudFormation vs Terraform — Which IaC Tool Should You Choose?5 min readAWS EC2 Complete Guide — Launch, Scale, and Secure Virtual Machines5 min readTerraform Modules at Scale — Reusable, Versioned Infrastructure Components8 min readKubernetes on AWS EKS — Complete Setup Guide 20266 min readAWS S3 — Object Storage, Static Hosting, and CDN Guide4 min read