
Introduction
Someone clicks the wrong button in the AWS console and a production database disappears. The team spends four hours reconstructing it from memory. That's Tuesday without infrastructure as code.
Terraform is HashiCorp's tool for defining cloud infrastructure in configuration files. You describe what you want, Terraform figures out what to create and in what order, and it tracks everything it builds. Tear it all down? One command. But the real value isn't the automation — it's the state file. Terraform knows what exists. It knows what you asked for. It computes the difference. That's a fundamentally different relationship with infrastructure than clicking around in a console.
The provider model is what made Terraform win. Same language, same workflow for AWS, Google Cloud, Azure, Datadog, GitHub repos. Hundreds of services. Not locked to one cloud. That flexibility matters more than people think it does early on, and by the time they realize it, they're glad they picked something portable.
Infrastructure as code is not about replacing the cloud console. It is about making your infrastructure reviewable, repeatable, and recoverable.
HCL Quick Reference
HCL is the configuration language. More readable than JSON, less ambiguous than YAML. A Terraform project is one or more .tf files in a directory — Terraform merges all of them together, so split them however makes sense. Most teams: main.tf, variables.tf, outputs.tf, providers.tf.
Basic EC2 instance:
# Configure the AWS provider
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
# 1.10+ is needed for S3 native state locking (use_lockfile)
required_version = ">= 1.10.0"
}
provider "aws" {
region = "us-east-1"
}
# Create an EC2 instance
resource "aws_instance" "web_server" {
# Placeholder: AMI IDs are region-specific and get retired. The aws_ami
# data source in the modules section looks one up instead.
ami = "ami-0c02fb55956c7d316"
instance_type = "t3.micro"
tags = {
Name = "web-server"
Environment = "production"
ManagedBy = "terraform"
}
}
The examples target Terraform 1.10 or later (1.17 is current as of October 2026) and version 6 of the AWS provider. Pin provider versions. Pin the Terraform version. Skip this and a new provider release will break your config silently on someone else's machine. Every resource follows resource "TYPE" "NAME" — type from the provider, name is your local label. Reference it elsewhere as aws_instance.web_server.
Workflow: terraform init (downloads providers), terraform plan (previews changes), terraform apply (creates resources), terraform destroy (tears them down). The plan step is the most valuable part. Shows you exactly what will happen before anything happens.
Providers and Resources
Providers are plugins. AWS, Google Cloud, Azure, Kubernetes, Datadog, GitHub — thousands of them in the Terraform Registry. Each exposes resources (things Terraform creates) and data sources (things Terraform reads but doesn't own).
# Create a VPC
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "main-vpc"
}
}
# Create a public subnet
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"
}
}
# Internet gateway for outbound traffic
resource "aws_internet_gateway" "gw" {
vpc_id = aws_vpc.main.id
tags = {
Name = "main-igw"
}
}
# Route table for public subnet
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.gw.id
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
The dependency graph is implicit. The subnet references aws_vpc.main.id, the route table references both the VPC and the internet gateway. Terraform reads these references and creates resources in the right order. VPC before subnet, internet gateway before route table entry. You rarely need to specify ordering manually. Reach for depends_on only for hidden dependencies Terraform cannot see from references, such as an IAM policy that must exist before an instance uses it. If you add it everywhere, your references probably aren't structured correctly.
Variables, Outputs and Locals
Hardcoded values work for experiments. They fall apart the moment you need the same config in a different region or with a different instance size.
variable "aws_region" {
description = "AWS region to deploy resources"
type = string
default = "us-east-1"
}
variable "environment" {
description = "Deployment environment name"
type = string
validation {
condition = contains(["dev", "staging", "production"], var.environment)
error_message = "Environment must be dev, staging, or production."
}
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "allowed_cidr_blocks" {
description = "CIDR blocks allowed to access the instance"
type = list(string)
# Placeholder: 0.0.0.0/0 means the whole internet. Replace it with your
# office or VPN ranges before using this for anything like SSH.
default = ["0.0.0.0/0"]
}
variable "extra_tags" {
description = "Additional tags to apply to all resources"
type = map(string)
default = {}
}
# Local values for computed or combined values
locals {
common_tags = merge({
Environment = var.environment
ManagedBy = "terraform"
Project = "web-platform"
}, var.extra_tags)
name_prefix = "${var.environment}-web"
}
# Outputs expose values after apply
output "instance_public_ip" {
description = "Public IP of the web server"
value = aws_instance.web_server.public_ip
}
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
The validation block on environment — if someone passes "prod" instead of "production", Terraform catches it before creating anything. Cheap safety net.
Locals are computed values. local.common_tags merges standard tags with extra tags from a variable. Every resource references one tag map instead of duplicating the same block everywhere. Outputs expose values after apply: the IP of a new server, the ID of a VPC. They also pass data between modules.
In practice, one .tfvars file per environment. production.tfvars sets instance type to t3.large, dev.tfvars keeps it at t3.micro. Pass the file with terraform apply -var-file="production.tfvars". Simple, but it works.
Modules for Reusable Infrastructure
Every application needs a VPC. Every application needs a load balancer and an auto-scaling group. Copy-pasting those resource blocks across projects is how you end up with five slightly different VPC configurations and no idea which one is correct.
A module is a directory of .tf files with inputs and outputs. Your root config is already a module. Child modules encapsulate reusable infrastructure.
The module structure matters more than most people realize, so here's what a standardized web application stack looks like before you see the code: security group, launch template, and auto-scaling group, all parameterized by app name, VPC, subnets, and sizing. One module, callable with different parameters for different services.
# modules/web-app/main.tf
# A reusable module for web application infrastructure
resource "aws_security_group" "web" {
name_prefix = "${var.app_name}-web-"
vpc_id = var.vpc_id
# Public web traffic: HTTP and HTTPS from anywhere is intended here
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"]
}
tags = var.tags
}
resource "aws_launch_template" "web" {
name_prefix = "${var.app_name}-"
image_id = var.ami_id
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.web.id]
tag_specifications {
resource_type = "instance"
tags = merge(var.tags, {
Name = "${var.app_name}-web"
})
}
}
resource "aws_autoscaling_group" "web" {
name = "${var.app_name}-asg"
desired_capacity = var.desired_capacity
max_size = var.max_size
min_size = var.min_size
vpc_zone_identifier = var.subnet_ids
launch_template {
id = aws_launch_template.web.id
version = "$Latest"
}
}The module declares its inputs in its own variables.tf:
# modules/web-app/variables.tf
variable "app_name" {
type = string
}
variable "vpc_id" {
type = string
}
variable "subnet_ids" {
type = list(string)
}
variable "ami_id" {
type = string
}
variable "instance_type" {
type = string
}
variable "desired_capacity" {
type = number
}
variable "min_size" {
type = number
}
variable "max_size" {
type = number
}
variable "tags" {
type = map(string)
default = {}
}Calling it from root config. The aws_ami data source looks up the latest Amazon Linux 2023 image so no AMI ID is hardcoded:
# Latest Amazon Linux 2023 AMI in the provider's region
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-2023.*-x86_64"]
}
}
module "api_service" {
source = "./modules/web-app"
app_name = "api"
vpc_id = aws_vpc.main.id
subnet_ids = aws_subnet.public[*].id
ami_id = data.aws_ami.amazon_linux.id
instance_type = "t3.small"
desired_capacity = 2
min_size = 1
max_size = 4
tags = local.common_tags
}
module "frontend_service" {
source = "./modules/web-app"
app_name = "frontend"
vpc_id = aws_vpc.main.id
subnet_ids = aws_subnet.public[*].id
ami_id = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
desired_capacity = 3
min_size = 2
max_size = 6
tags = local.common_tags
}One module. Two separate application stacks. Change how security groups work in the module and every caller picks up the fix.
The Terraform Registry has community modules for common patterns — VPCs, EKS clusters, RDS databases. The official AWS VPC module handles public and private subnets, NAT gateways, and route tables in a single call. Use those for infrastructure primitives. Write custom modules for your application-specific patterns.
Rule of thumb: module when you build the same resources more than twice. But keep each module focused. A VPC module should not also deploy an application. That's not a module, that's a monolith wearing a module's clothes.
State Management and Remote Backends
This is the hardest concept in Terraform. Not the syntax. Not HCL. State.
Terraform's state file is a JSON mapping between what's in your config and what actually exists in the cloud. aws_instance.web_server maps to instance i-0abc123def456. Corrupt that file and Terraform no longer knows what it created. You're untangling things by hand, and "by hand" in this context means reading API responses and cross-referencing them against your config while production is potentially broken.
By default, state lives in a local file called terraform.tfstate. Solo work, fine. On a team, two people running terraform apply with different local state files will overwrite each other and potentially corrupt live infrastructure.
Get this wrong and you'll corrupt your state file. Read it carefully.
terraform {
backend "s3" {
bucket = "mycompany-terraform-state"
key = "web-platform/terraform.tfstate"
region = "us-east-1"
encrypt = true
# S3 native locking (Terraform 1.10+): writes a .tflock object next to the state
use_lockfile = true
# Migrating from DynamoDB locking? Keep the old line alongside use_lockfile
# for one release cycle, then delete it. dynamodb_table is deprecated.
# dynamodb_table = "terraform-locks"
}
# required_providers lives in main.tf; declaring it twice in one module is an error
}Every plan or apply now fetches state from S3, takes a lock by creating a .tflock object next to the state file, runs, writes state back, and deletes the lock. Older guides use a DynamoDB table for this. HashiCorp's S3 backend docs now mark dynamodb_table as deprecated and say DynamoDB-based locking will be removed in a future minor version, so new setups should use use_lockfile. Someone else tries to run at the same time? "State locked." They wait.
Never edit the state file by hand. Ever. Use terraform state mv and terraform state rm if you need to move or remove resources. The state file contains sensitive data — database passwords, API keys, connection strings — so encrypt the S3 bucket and restrict access with IAM policies. Enable versioning on the bucket. You will, at some point, need to recover from accidental corruption. When that day comes, you'll be glad the previous state file is still there.
Terraform Cloud handles all of this automatically. For larger teams, probably worth it. For smaller teams, an S3 backend with use_lockfile is enough.
Workspaces for Multiple Environments
Dev, staging, production. Same infrastructure, different sizes. Workspaces are Terraform's answer: same configuration, separate state files, completely independent resources. You reference the current workspace name with terraform.workspace to vary settings per environment.
# Environment-specific configuration using workspaces
locals {
environment = terraform.workspace
# Different settings per environment
env_config = {
dev = {
instance_type = "t3.micro"
desired_capacity = 1
max_size = 2
multi_az = false
db_instance_class = "db.t3.micro"
}
staging = {
instance_type = "t3.small"
desired_capacity = 2
max_size = 4
multi_az = false
db_instance_class = "db.t3.small"
}
production = {
instance_type = "t3.large"
desired_capacity = 3
max_size = 10
multi_az = true
db_instance_class = "db.r6g.large"
}
}
# Select config for current workspace
config = local.env_config[local.environment]
}
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = local.config.instance_type
tags = {
Name = "${local.environment}-web-server"
Environment = local.environment
}
}
resource "aws_db_instance" "main" {
identifier = "${local.environment}-database"
engine = "postgres"
# Major version only: RDS picks the default minor (exact minors get retired)
engine_version = "16"
instance_class = local.config.db_instance_class
multi_az = local.config.multi_az
# ... other configuration
}
terraform workspace new staging, terraform workspace select staging, apply. Resources get created with staging-sized instances and staging tags. Switch to production and the same code provisions production-grade resources.
Workspaces have a real problem, though. Nothing stops you from running terraform apply in the wrong workspace. Picture dev-sized database settings applied to production because someone forgot to switch: the database buckles under real traffic and needs an emergency resize. Running terraform workspace show before every apply, or printing the workspace in your shell prompt, guards against it.
Many teams prefer separate directories per environment with shared modules instead. When the directory is literally named environments/production/, ambiguity disappears. But separate directories mean more files to maintain. Pick your tradeoff.
Terraform in CI/CD Pipelines
Running Terraform from laptops is how teams get into trouble. Version mismatches. Forgotten git pull before apply. Zero audit trail. Moving Terraform into a CI/CD pipeline fixes all of these and, honestly, should happen earlier than most teams do it.
The pattern: branch, PR, CI runs terraform plan and posts results as a comment, reviewers approve, CD runs terraform apply on merge to main.
name: Terraform CI/CD
on:
pull_request:
branches: [main]
paths: ['infrastructure/**']
push:
branches: [main]
paths: ['infrastructure/**']
permissions:
contents: read
pull-requests: write
id-token: write
jobs:
plan:
name: Terraform Plan
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.17.0"
terraform_wrapper: false
- name: Terraform Init
run: terraform init
working-directory: infrastructure/
- name: Terraform Plan
id: plan
run: terraform plan -no-color -out=tfplan
working-directory: infrastructure/
- name: Render Plan
run: terraform show -no-color tfplan > plan.txt
working-directory: infrastructure/
- name: Comment Plan on PR
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const plan = fs.readFileSync('infrastructure/plan.txt', 'utf8');
// PR comments are capped at 65,536 characters
const body = '#### Terraform plan\n```\n' + plan.slice(0, 60000) + '\n```';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
apply:
name: Terraform Apply
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
# A GitHub environment with required reviewers pauses here for approval
environment: production
steps:
- uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.17.0"
- name: Terraform Init
run: terraform init
working-directory: infrastructure/
- name: Terraform Plan
run: terraform plan -no-color -out=tfplan
working-directory: infrastructure/
- name: Terraform Apply
# Applying the saved plan file applies exactly what the step above planned
run: terraform apply -no-color tfplan
working-directory: infrastructure/OIDC authentication via role-to-assume instead of stored AWS keys. Short-lived credentials, nothing to rotate. Pinned Terraform version for consistency. Plan on PRs, apply only on pushes to main. terraform_wrapper: false keeps the plan output clean when it is redirected to a file.
The apply job saves its plan with -out=tfplan and then runs terraform apply tfplan. A saved plan applies exactly what was planned, with no second diff and no prompt. Note that the PR plan and the apply plan are separate runs: if main or the real infrastructure moved after review, the apply plan can differ from the comment reviewers saw. The production environment with required reviewers gives someone the chance to check the apply job's plan output before it continues. Teams that need the reviewed plan applied byte-for-byte upload tfplan as an artifact and apply that file instead, or use a tool built for this.
For more involved setups, Atlantis (open source, self-hosted) or Spacelift (managed, policy enforcement, drift detection). Both purpose-built for Terraform in PR workflows. But even a basic GitHub Actions setup like the one above is vastly better than running from laptops.
Next Steps
Set up remote state with S3 and use_lockfile before writing your first resource. Not after. Before. Migrating from local state later is doable with terraform init -migrate-state, but it is easier to never need it.
Terraform vs Pulumi vs CDK vs OpenTofu — the tooling is fragmenting. But the practice of codifying infrastructure is only getting more important. Which tool you pick matters less than picking one and using it for everything.