Every time you click “Create Instance” in the AWS console, you are creating infrastructure that cannot be reproduced, reviewed, or rolled back. Terraform replaces console clicking with code — declarative configuration files that describe your entire infrastructure and can be version-controlled, peer-reviewed, and applied automatically.
.tf files
HCL config
Providers
download plugins
Preview
safe, read-only
Create
real infra changes
Track
source of truth
Core Concepts in 5 Minutes
- Provider: A plugin that talks to a cloud API (AWS, GCP, Azure, Kubernetes)
- Resource: A single piece of infrastructure (EC2 instance, S3 bucket, DNS record)
- State: A JSON file tracking what Terraform has created (the source of truth)
- Plan: A preview of what Terraform will create, modify, or destroy
- Apply: Execute the plan and make real infrastructure changes
Your First Terraform Configuration
# main.tf
terraform {
required_version = ">= 1.7"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
# Create a VPC
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
tags = {
Name = "production-vpc"
Environment = "production"
}
}
# Create a public subnet
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id # Reference another resource
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a"
map_public_ip_on_launch = true
tags = {
Name = "public-subnet-1a"
}
}
# The Terraform workflow:
terraform init # Download providers
terraform plan # Preview changes (safe, read-only)
terraform apply # Create/modify infrastructure
terraform destroy # Tear everything down
Variables and Outputs
# variables.tf
variable "environment" {
description = "Deployment environment"
type = string
default = "staging"
validation {
condition = contains(["staging", "production"], var.environment)
error_message = "Environment must be staging or production."
}
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "db_password" {
description = "Database master password"
type = string
sensitive = true # Never shown in plan output or logs
}
# outputs.tf
output "vpc_id" {
description = "The ID of the VPC"
value = aws_vpc.main.id
}
output "public_subnet_id" {
description = "The ID of the public subnet"
value = aws_subnet.public.id
}
# Use variables:
# terraform apply -var="environment=production" -var="instance_type=t3.large"
# Or create terraform.tfvars:
# environment = "production"
# instance_type = "t3.large"
Modules: Reusable Infrastructure Components
# modules/web-server/main.tf
variable "name" { type = string }
variable "instance_type" { type = string }
variable "subnet_id" { type = string }
variable "vpc_id" { type = string }
resource "aws_security_group" "web" {
name_prefix = "${var.name}-web-"
vpc_id = var.vpc_id
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"]
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
subnet_id = var.subnet_id
vpc_security_group_ids = [aws_security_group.web.id]
tags = {
Name = var.name
}
}
output "public_ip" {
value = aws_instance.web.public_ip
}
# Use the module in your main config:
# main.tf
module "api_server" {
source = "./modules/web-server"
name = "api-server"
instance_type = "t3.medium"
subnet_id = aws_subnet.public.id
vpc_id = aws_vpc.main.id
}
module "admin_server" {
source = "./modules/web-server"
name = "admin-server"
instance_type = "t3.small"
subnet_id = aws_subnet.public.id
vpc_id = aws_vpc.main.id
}
State Management
The state file is Terraform’s memory of what exists. By default it is stored locally (terraform.tfstate), but in production you must use remote state with locking.
# Remote state with S3 + DynamoDB locking
terraform {
backend "s3" {
bucket = "mycompany-terraform-state"
key = "production/infrastructure.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks" # Prevents concurrent modifications
}
}
# State locking prevents two people from running terraform apply
# simultaneously and corrupting the state file.
State Commands
# List all resources in state
terraform state list
# Show details of a specific resource
terraform state show aws_instance.web
# Move a resource (rename without destroying)
terraform state mv aws_instance.old aws_instance.new
# Remove from state (resource still exists in cloud)
terraform state rm aws_instance.temporary
# Import existing infrastructure into state
terraform import aws_instance.existing i-1234567890abcdef0
Workspaces: Multiple Environments
# Create separate environments from the same code
terraform workspace new staging
terraform workspace new production
# Switch between them
terraform workspace select staging
terraform apply # Changes only staging infrastructure
terraform workspace select production
terraform apply # Changes only production infrastructure
# Use workspace name in configuration
resource "aws_instance" "web" {
instance_type = terraform.workspace == "production" ? "t3.large" : "t3.micro"
tags = {
Environment = terraform.workspace
}
}
CI/CD Pipeline
# .github/workflows/terraform.yml
name: Terraform
on:
pull_request:
paths: ['terraform/**']
push:
branches: [main]
paths: ['terraform/**']
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.7.0
- name: Terraform Init
run: terraform init
working-directory: terraform/
- name: Terraform Validate
run: terraform validate
working-directory: terraform/
- name: Terraform Plan
run: terraform plan -out=tfplan -no-color
working-directory: terraform/
- name: Comment Plan on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const plan = require('fs').readFileSync('terraform/tfplan.txt', 'utf8');
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: '## Terraform Plan
```
' + plan + '
```'
});
apply:
needs: plan
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production # Requires manual approval
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init && terraform apply -auto-approve
working-directory: terraform/
Common Mistakes
- Storing state locally: Local state files get lost, cannot be shared, and have no locking. Always use remote state.
- Committing .tfstate to git: State files contain secrets (database passwords, API keys). Never commit them. Use .gitignore.
- Not using -out with plan: Without
-out=tfplan, the apply might execute a different plan than what you reviewed. - Manual changes after Terraform apply: Changing resources in the console creates drift. Terraform will try to revert your manual changes on the next apply.
- Monolithic state file: One state file for everything means one mistake affects everything. Split by environment and service.
- Ignoring terraform plan output: Always review the plan. “1 to destroy” might be your production database.
Project Structure for Large Teams
infrastructure/
modules/ # Reusable modules
networking/
compute/
database/
monitoring/
environments/
staging/
main.tf # Uses modules with staging values
terraform.tfvars
backend.tf # Staging state location
production/
main.tf # Uses modules with production values
terraform.tfvars
backend.tf # Production state location
global/ # Shared resources (IAM, DNS)
main.tf
backend.tf
Key Takeaways
- Infrastructure as Code is not optional — if it is not in code, it is not reproducible
- Always use remote state with locking — local state is a disaster waiting to happen
- Review terraform plan like you review code — a careless apply can destroy production
- Use modules for reusable components — same pattern as functions in application code
- Split state by environment and service — blast radius reduction
- CI/CD should run plan on PRs, apply on merge — with mandatory approval for production
- Never make manual changes to Terraform-managed resources — drift is the enemy
- Mark sensitive variables to prevent secrets from appearing in plan output
Terraform gives you a superpower: the ability to create, modify, and destroy entire cloud environments with a single command. But with great power comes great responsibility. Always plan before you apply, always use remote state, and always review what Terraform intends to do before you let it do it.