Terraform 101: Managing AWS Infrastructure as Code
Terraform 101: Managing AWS Infrastructure as Code
Clicking through the AWS console works until it doesn't — nobody remembers what they clicked, environments drift apart, and "it works in staging" becomes a meme. Terraform fixes this by making infrastructure code: you describe what you want in HCL (HashiCorp Configuration Language), and Terraform makes the cloud match it. This guide follows the official HashiCorp AWS get-started path — install → write configuration → init → apply → change → destroy — so by the end you'll have provisioned and torn down a real EC2 instance.
Table of Contents
- Install and authenticate
- Write the configuration
- fmt, init, and validate
- Apply: plan then execute
- Inspect state
- Change the infrastructure
- Variables and outputs
- Destroy when done
- Practices that scale
Install and authenticate
Terraform is a single CLI binary. HashiCorp maintains official packages for every platform:
# macOS
brew tap hashicorp/tap && brew install hashicorp/tap/terraform
# Debian/Ubuntu
sudo apt-get update && sudo apt-get install -y gnupg software-properties-common
wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt-get install terraform
terraform -version # requires 1.2.0+
Terraform's AWS provider authenticates exactly like the AWS CLI — same credential chain. For a first run, environment variables are simplest:
export AWS_ACCESS_KEY_ID="..."
export AWS_SECRET_ACCESS_KEY="..."
In real use prefer aws configure profiles or an IAM role — anything the AWS provider's documented credential chain supports works.
Write the configuration
Terraform loads every .tf file in the working directory. The official convention splits configuration by purpose: terraform.tf configures Terraform itself, main.tf holds your infrastructure.
mkdir learn-terraform-aws && cd learn-terraform-aws
terraform.tf — pin both the provider and Terraform versions:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.92"
}
}
required_version = ">= 1.2"
}
hashicorp/aws is shorthand for registry.terraform.io/hashicorp/aws — providers are versioned plugins fetched from the Terraform Registry at init time. The ~> 5.92 constraint accepts any 5.x release ≥ 5.92 but blocks breaking v6 changes — always constrain provider versions.
main.tf — the provider, a data source, and one resource:
provider "aws" {
region = "us-west-2"
}
data "aws_ami" "ubuntu" {
most_recent = true
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
}
owners = ["099720109477"] # Canonical
}
resource "aws_instance" "app_server" {
ami = data.aws_ami.ubuntu.id
instance_type = "t2.micro"
tags = {
Name = "learn-terraform"
}
}
Three block types doing three different jobs:
providerconfigures the plugin — here, which AWS region everything lands in.datareads existing information instead of creating anything.data.aws_ami.ubuntuqueries AWS for the newest Ubuntu 24.04 AMI owned by Canonical — no hardcoded AMI IDs that silently go stale.resourcemanages something real:aws_instance.app_serveris the EC2 instance Terraform will create, update, and eventually destroy.t2.microkeeps you inside the AWS free tier.
fmt, init, and validate
terraform fmt # canonical formatting — prints files it changed
terraform init # install the aws provider into .terraform/
terraform validate # syntax + internal consistency check
init does three things that matter: downloads the provider plugin into the hidden .terraform/ directory, initializes the state backend, and writes .terraform.lock.hcl — a lockfile recording the exact provider versions selected. Commit that lockfile; it guarantees every teammate and CI run resolves the same providers.
Re-run terraform init whenever you change providers, modules, or backend configuration — other commands will remind you if you forget.
Apply: plan then execute
terraform apply
Apply is a two-step process: Terraform prints an execution plan — every action it will take, in a + create / - destroy / ~ update diff format — then waits for you to type yes. You'll see the AMI data source resolve first (data.aws_ami.ubuntu: Reading...), then the plan showing aws_instance.app_server will be created.
Attributes shown as (known after apply) — like id, arn, public_ip — don't exist until AWS creates the instance. Terraform resolves them during apply so dependent resources can consume them.
For previewing without applying, terraform plan prints the same plan and exits. Once you're confident, terraform apply -auto-approve skips the prompt (CI pipelines use it; humans should read plans).
Inspect state
Terraform recorded your instance in terraform.tfstate — the state file that maps configuration to real-world resource IDs. It's Terraform's memory of what it manages.
terraform state list # resources Terraform is tracking
terraform show # current state in detail
Two rules that will save you pain later:
- Never edit state by hand. Use
terraform statesubcommands — state surgery by text editor ends in tears. - Don't commit it to Git. It can contain sensitive values, and local state dies with your laptop. The moment a second person touches the project, move to a remote backend (S3 + DynamoDB locking, or HCP Terraform). We cover this properly in Terraform State Explained.
Change the infrastructure
Edit main.tf — bump the tag:
tags = {
Name = "learn-terraform-prod"
}
terraform apply
The plan shows ~ update in-place on the tags — a one-line change, surgically applied. Now try instance_type = "t3.micro": the plan shows -/+ destroy and then create replacement, because EC2 can't change instance type on a running instance without a stop/start, and Terraform models that honestly as a replace. Reading plans like this — knowing which changes are in-place vs destructive — is the core Terraform skill.
Variables and outputs
Hardcoded values don't scale. Parameterize with variables in variables.tf:
variable "instance_name" {
description = "Value of the EC2 instance's Name tag"
type = string
default = "learn-terraform"
}
# in main.tf
tags = {
Name = var.instance_name
}
Override at apply time — terraform apply -var="instance_name=staging-web" — or via terraform.tfvars files per environment. Surface useful attributes with outputs.tf:
output "instance_id" {
value = aws_instance.app_server.id
}
output "instance_public_ip" {
value = aws_instance.app_server.public_ip
}
Outputs print after every apply, and — more importantly — other Terraform configurations can consume them via remote state. That's how a VPC config hands subnet IDs to an app config.
Destroy when done
terraform destroy
Terraform plans destruction of everything it manages, shows the plan, asks to confirm, and removes it. For a learning project this is free insurance — you can't leave a surprise EC2 bill running. It only destroys what's in this configuration's state.
Practices that scale
- Keep configurations small and focused — one giant config means giant, scary plans and blast radius.
- Run
terraform fmtandterraform validatein CI on every PR. - Commit
.terraform.lock.hcl; gitignore.terraform/and*.tfstate*. - Review plans in pull requests — infrastructure changes deserve code review too.
- Reaching for more than a couple of resources? That's the graduation point — read Terraform AWS Modules to see how the community packages entire VPCs and EKS clusters, then Terraform Modules to write your own.
Related Articles
- Terraform AWS Modules: Production AWS in 20 Lines of HCL
- Terraform State Explained: Backends, Locking, and Surgery
- Terraform Modules: Building Reusable Infrastructure
Last Updated: October 2026 Author: CloudOpsGuide Team Difficulty: Beginner Estimated Reading Time: 14 minutes