Provisioning AWS resources by clicking through the console works fine until you need to rebuild an environment, hand it off to a teammate, or prove to an auditor exactly what changed and when. Terraform solves that by turning infrastructure into version-controlled code, and it remains the default choice for teams that want a single workflow across AWS, Azure, and Google Cloud rather than three separate consoles. This tutorial walks through installing Terraform, wiring it up to AWS with proper credentials, and shipping a real three-tier setup (VPC, EC2, and S3) using current 2026 best practices for state locking and security. Even HashiCorp’s own official AWS get-started series — six AWS-focused tutorials in total — has kept pace with that shift, with HashiCorp Developer refreshing the set as recently as September 2026, and this guide follows the same current conventions.
By the end you will have a working Terraform project that provisions and tears down AWS infrastructure on command, stores its state safely in S3 with native locking, and follows the access patterns AWS itself recommends for infrastructure-as-code pipelines in 2026.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
Why Terraform for AWS in 2026
Terraform reached what HashiCorp calls “enterprise maturity” for serverless and container workloads this year, and the same shift applies to core infrastructure. Organizations are no longer treating IaC as a nice-to-have for a handful of resources; entire application tiers, from networking to compute to storage, are defined declaratively and applied through CI/CD rather than clicked into existence. That matters for AWS specifically because Terraform’s hashicorp/aws provider tracks new AWS services faster than most competing tools, and its plan-before-apply workflow catches expensive mistakes (like an accidentally public S3 bucket or an oversized instance type) before they hit your bill. For context on how much backward compatibility the tool preserves even as it adds features, HashiCorp’s own AWS get-started tutorial still lists a minimum requirement of just Terraform CLI 1.2.0+ as of July 2025, per HashiCorp Developer — several major releases behind the 1.15.x line this guide targets.
The competitive picture has also shifted. OpenTofu, the open-source fork spun out after HashiCorp’s 2023 license change, has grown to roughly 12% adoption among infrastructure-as-code practitioners with around a quarter of teams evaluating or expanding its use, driven by strong registry growth. Terraform itself remains the incumbent with the largest installed base, particularly in enterprises already using HCP Terraform, Vault, or Sentinel policy-as-code. For this tutorial we’re using mainline Terraform because its tooling, documentation, and AWS provider support are still the most complete of the two, but the HCL syntax and workflow you’ll learn here transfer directly to OpenTofu if you switch later.
Terraform CLI 1.15.8 is the current stable release as of this writing, alongside AWS provider version 6.60.0. Terraform 1.15 closed a long-standing gap with OpenTofu by adding dynamic module sources (you can now use variables in a module’s source and version attributes), a formal deprecated attribute for variables and outputs, an inline convert() type-conversion function, and native Windows ARM64 builds. A 1.16 release is in release-candidate testing but not yet generally available, so this guide targets the 1.15.x line throughout.
Prerequisites and versions
Before you start, get these in place. Version mismatches are the single most common source of “it worked on my machine” bugs in Terraform projects, so pin what you can.
| Requirement | Version / detail | Why it matters |
|---|---|---|
| Terraform CLI | 1.15.8 (current stable, released July 8, 2026) | Supports native S3 state locking and the deprecated-attribute warnings used later in this guide |
| AWS provider (hashicorp/aws) | ~> 6.60.0 (released August 13, 2026) | Current major version 6.x; older 5.x configs need minor syntax updates for some resources |
| AWS CLI | v2 (latest) | Used to configure credentials and verify identity before Terraform runs |
| AWS account | IAM user or role with programmatic access | Never use root account credentials for Terraform |
| Operating system | macOS, Linux, or Windows (including native ARM64) | Terraform 1.15 ships native Windows ARM64 builds for the first time |
| Editor | VS Code 1.86+ with the HashiCorp Terraform extension (2.34.5 or newer) | Adds syntax highlighting, validation, and formatting via the Terraform Language Server |
| Git | Any recent version | Version-controlling your .tf files is the entire point of this exercise |
You’ll also need a way to keep secrets out of your codebase. This tutorial uses AWS CLI named profiles rather than hardcoded access keys, which is the pattern AWS’s own prescriptive guidance recommends for Terraform-driven pipelines.
Step 1: Install Terraform
On macOS, install via Homebrew, which tracks HashiCorp’s official tap:
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
terraform -version
On Windows, use Chocolatey or winget:
winget install HashiCorp.Terraform
terraform -version
On Linux (Debian/Ubuntu), add HashiCorp’s apt repository rather than pulling a binary from a random mirror:
wget -O- https://apt.releases.hashicorp.com/gpg |
gpg --dearmor |
sudo tee /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 install terraform
terraform -version
Confirm you’re on 1.15.x before continuing. If you’re managing multiple projects on different Terraform versions, install tfenv or tfswitch instead of a single global binary, since production teams routinely juggle two or three pinned versions across repos during upgrade windows.
Step 2: Install and configure the AWS CLI
Terraform’s AWS provider reads credentials the same way the AWS CLI does, so setting up the CLI first makes debugging much easier. Install AWS CLI v2 from AWS’s official installer for your platform, then verify:
aws --version
# aws-cli/2.x.x Python/3.x.x
Create an IAM user specifically for Terraform (do not reuse a personal admin account). In the IAM console, create a user named something like terraform-deployer, skip console access, and attach a scoped policy rather than AdministratorAccess wherever practical. For this tutorial’s resources (VPC, EC2, S3) a policy covering ec2:*, s3:*, and iam:PassRole on your account is sufficient; tighten it further for production.
Generate an access key for that user, then configure a named profile instead of the default profile — this keeps Terraform credentials isolated from anything else on your machine:
aws configure --profile terraform-tutorial
# AWS Access Key ID: AKIA...
# AWS Secret Access Key: ****************
# Default region name: us-east-1
# Default output format: json
aws sts get-caller-identity --profile terraform-tutorial
A successful get-caller-identity call returns your account ID, user ARN, and user ID as JSON — that’s your confirmation the credentials work before Terraform ever touches them.
Step 3: Understand the AWS free tier before you provision anything
AWS’s free tier structure depends on when your account was created, and the difference is large enough to change how you should think about costs while following this tutorial.
| Account created | Free tier model | EC2 allowance | S3 allowance |
|---|---|---|---|
| Before July 15, 2025 | Legacy 12-month free tier | 750 hrs/month of t2.micro or t3.micro (enough for one instance running continuously) | 5 GB storage, 20,000 GET, 2,000 PUT requests/month for 12 months |
| On or after July 15, 2025 | Credit-based Free Plan | Free-tier-eligible types (t3.micro, t3.small, t4g.micro, t4g.small, c7i-flex.large, m7i-flex.large) drawn against credit balance | Covered by the same shared credit balance, no separate S3-only quota |
Newer accounts get $100 in automatic credit at signup, plus up to $100 more for completing onboarding tasks like launching and terminating an EC2 instance or deploying a Lambda function, for a maximum of $200. That balance is usable for six months from account creation or until it’s exhausted, whichever comes first, but the credit itself remains usable for 12 months from account creation. If you’re outside any free tier window, the resources in this tutorial are cheap: a t3.micro instance runs about $0.0104/hour, so leaving one up for a few hours costs a few cents, and S3 Standard storage is $0.023 per GB-month — negligible for a handful of test objects. The real risk is forgetting to run terraform destroy and leaving something running for weeks, which is why Step 13 covers cleanup explicitly.
Step 4: Scaffold the project directory
Create a clean project directory and split configuration into logical files rather than one giant main.tf. This is a convention, not a Terraform requirement — the CLI reads every .tf file in a directory as one configuration — but it keeps larger projects navigable.
mkdir terraform-aws-tutorial && cd terraform-aws-tutorial
touch main.tf variables.tf outputs.tf terraform.tf
git init
Add a .gitignore immediately, before you run any Terraform command. State files can contain sensitive values (database passwords, private key material), and committing one to a public repo is a recurring cause of real security incidents.
cat > .gitignore << 'EOF'
*.tfstate
*.tfstate.*
.terraform/
.terraform.lock.hcl
*.tfvars
crash.log
EOF
Note that .terraform.lock.hcl is sometimes committed in production repos to pin exact provider versions across a team — decide based on your workflow, but for this tutorial we'll keep it local.
Step 5: Declare the provider and required version
Open terraform.tf and pin both the Terraform CLI version range and the AWS provider version. Pinning matters because a provider minor upgrade can quietly change default behavior — the AWS provider ships new minor versions almost weekly.
terraform {
required_version = ">= 1.15.0, < 2.0.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.60"
}
}
}
provider "aws" {
region = var.aws_region
profile = "terraform-tutorial"
default_tags {
tags = {
Project = "terraform-aws-tutorial"
ManagedBy = "terraform"
Environment = "tutorial"
}
}
}
The default_tags block applies those three tags to every taggable resource this configuration creates, which is the fastest way to find and clean up tutorial resources later if you lose track of your terminal session.
Step 6: Define variables
Hardcoding values like region or instance type into resource blocks makes a config brittle. Put anything you might reasonably change into variables.tf:
variable "aws_region" {
description = "AWS region to deploy into"
type = string
default = "us-east-1"
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "public_subnet_cidr" {
description = "CIDR block for the public subnet"
type = string
default = "10.0.1.0/24"
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "bucket_name" {
description = "Globally unique S3 bucket name"
type = string
}
Notice bucket_name has no default. S3 bucket names are globally unique across all of AWS, so forcing yourself to pass one explicitly (rather than hardcoding something that will collide with someone else's bucket) avoids a confusing BucketAlreadyExists error on your first apply. Create a terraform.tfvars file (already gitignored) with your value:
bucket_name = "terraform-tutorial-yourname-2026"
Step 7: Build the VPC and networking layer
Now the actual infrastructure. Add this to main.tf: a VPC, one public subnet, an internet gateway, and a route table that sends outbound traffic through it. This is the minimum viable network for an internet-reachable EC2 instance.
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "terraform-tutorial-vpc"
}
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidr
map_public_ip_on_launch = true
availability_zone = "${var.aws_region}a"
tags = {
Name = "terraform-tutorial-public-subnet"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "terraform-tutorial-igw"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = {
Name = "terraform-tutorial-public-rt"
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
Notice there's no NAT gateway here — deliberately. NAT gateways bill roughly $0.045 per GB processed plus an hourly charge, and this tutorial's EC2 instance sits in the public subnet directly, so it doesn't need one. If you're building a real private-subnet architecture later, that's the piece to add, and it's the single most common source of surprise AWS bills among people learning Terraform.
Step 8: Add a security group and EC2 instance
Define a security group that allows SSH from your own IP only — not 0.0.0.0/0, which is the single most common misconfiguration flagged in AWS's own 2026 infrastructure-as-code guidance on exposure restriction. Then attach a small EC2 instance using the latest Amazon Linux AMI, resolved dynamically via a data source so you never hardcode a stale AMI ID.
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
resource "aws_security_group" "web" {
name = "terraform-tutorial-sg"
description = "Allow SSH from a single trusted IP"
vpc_id = aws_vpc.main.id
ingress {
description = "SSH from admin IP"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["YOUR.PUBLIC.IP.ADDRESS/32"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "terraform-tutorial-sg"
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.web.id]
tags = {
Name = "terraform-tutorial-instance"
}
}
Replace YOUR.PUBLIC.IP.ADDRESS with your actual IP (run curl ifconfig.me to find it) before applying. Leaving SSH open to the world, even on a throwaway tutorial instance, is exactly the kind of exposed compute AWS's June 2026 threat technique catalog update calls out as a common initial-access vector.
Step 9: Add the S3 bucket
Add a private, versioned, encrypted S3 bucket. In modern AWS provider versions, bucket configuration is split across several resources rather than one monolithic block, which trips up people following outdated tutorials that still use the pre-4.0 provider syntax.
resource "aws_s3_bucket" "data" {
bucket = var.bucket_name
tags = {
Name = "terraform-tutorial-bucket"
}
}
resource "aws_s3_bucket_versioning" "data" {
bucket = aws_s3_bucket.data.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "data" {
bucket = aws_s3_bucket.data.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "data" {
bucket = aws_s3_bucket.data.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
That last block — aws_s3_bucket_public_access_block — is not optional in any configuration you write going forward. Public S3 buckets are still a routine cause of large-scale data exposure, and this resource is the belt-and-suspenders control AWS recommends applying to every bucket by default, even ones you're sure will stay private.
Step 10: Add outputs
Outputs surface values you'll actually need after applying — the instance's public IP, the bucket name, the VPC ID. Add these to outputs.tf:
output "instance_public_ip" {
description = "Public IP address of the EC2 instance"
value = aws_instance.web.public_ip
}
output "bucket_name" {
description = "Name of the S3 bucket"
value = aws_s3_bucket.data.id
}
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
Step 11: Initialize, validate, and plan
With every file in place, initialize the working directory. This downloads the AWS provider plugin and sets up the local .terraform directory:
terraform init
You should see output ending in Terraform has been successfully initialized!. Next, validate the syntax without touching AWS at all:
terraform fmt -recursive
terraform validate
terraform fmt auto-corrects indentation and spacing to HashiCorp's canonical style; validate checks that your HCL is syntactically correct and internally consistent (matching types, no undefined references) — but it does not check against real AWS state or catch things like a bad AMI filter. Terraform 1.15 also extended validate to check backend block arguments, which is relevant once you migrate to a remote backend in Step 12.
Now generate an execution plan, which shows exactly what Terraform intends to create, modify, or destroy without actually doing it:
terraform plan -out=tfplan
Expected output for this configuration:
Plan: 10 to add, 0 to change, 0 to destroy.
Saved the plan to: tfplan
To perform exactly these actions, run the following command to apply:
terraform apply "tfplan"
Read the plan output line by line before applying, especially the resource count. If you see more destroys than you expect, stop and figure out why before proceeding — that's usually a sign a resource's identifying attribute changed and Terraform wants to replace it rather than update it in place.
Step 12: Apply and move state to a remote S3 backend
Apply the saved plan:
terraform apply "tfplan"
After a minute or two you'll see Apply complete! Resources: 10 added, 0 changed, 0 destroyed. followed by your three outputs. At this point your state lives in a local terraform.tfstate file — fine for a solo tutorial, but unsafe for any team workflow, since two people applying against the same local state file will corrupt it.
Migrate to a remote S3 backend with native locking, which is the current recommended pattern. DynamoDB-based state locking, the old standard, is now explicitly deprecated in HashiCorp's own S3 backend documentation and will be removed in a future minor version — skip it entirely for new projects. First create a dedicated state bucket (outside this Terraform config, to avoid a chicken-and-egg problem):
aws s3api create-bucket --bucket terraform-state-yourname-2026
--profile terraform-tutorial --region us-east-1
aws s3api put-bucket-versioning --bucket terraform-state-yourname-2026
--versioning-configuration Status=Enabled --profile terraform-tutorial
Then add a backend block to terraform.tf:
terraform {
backend "s3" {
bucket = "terraform-state-yourname-2026"
key = "tutorial/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
encrypt = true
}
}
use_lockfile = true is the S3-native locking mechanism, available since Terraform 1.10 — it writes a lock object alongside your state file to prevent two concurrent applies from racing, without needing a separate DynamoDB table at all. Re-run terraform init and confirm the migration when prompted:
terraform init
# Initializing the backend...
# Do you want to copy existing state to the new backend?
# Enter "yes" to copy...
#
# Successfully configured the backend "s3"!
Use separate state files (different key values, or entirely separate buckets) per environment — dev, staging, production — rather than one shared state file with workspaces for everything. Mixing environments in a single state file is a common cause of a mistyped terraform destroy taking down production instead of a sandbox.
Step 13: Verify, then clean up with terraform destroy
Confirm the instance is reachable and the bucket exists:
terraform output instance_public_ip
ssh -i your-key.pem ec2-user@$(terraform output -raw instance_public_ip)
aws s3 ls s3://$(terraform output -raw bucket_name) --profile terraform-tutorial
Once you're done experimenting, tear everything down to stop the billing clock. This is the step people skip, and it's the reason "small tutorial leaves you with a surprise bill" is such a common complaint:
terraform plan -destroy -out=destroy-plan
terraform apply "destroy-plan"
Terraform will report Destroy complete! Resources: 10 destroyed. Double-check in the AWS console that the EC2 instance shows "terminated" and the S3 bucket is gone — terraform destroy will fail on a non-empty bucket unless you've set force_destroy = true on the aws_s3_bucket resource, so if it errors out, empty the bucket manually first and re-run.
Common pitfalls when learning Terraform on AWS
These are the mistakes that show up repeatedly among people running through their first real Terraform-on-AWS project, based on patterns in AWS's own IaC governance guidance and HashiCorp's documentation.
- Committing terraform.tfstate to git. State files can contain plaintext secrets (database passwords passed as resource arguments, for instance). Your
.gitignorefrom Step 4 prevents this, but double-check withgit statusbefore every commit until it's habit. - Using DynamoDB locking on a new project. It still works, but it's deprecated and adds an extra resource to manage for no benefit over native S3 locking with
use_lockfile = true. - Hardcoding an AMI ID. AMIs are region-specific and get deprecated. Use a
data "aws_ami"lookup withmost_recent = trueas shown in Step 8, not a copy-pasted ID from a blog post. - Opening security groups to 0.0.0.0/0. Scoping SSH to your own IP takes ten extra seconds and closes the most exploited AWS misconfiguration.
- Running terraform apply without reviewing the plan. Always separate
planandapplyinto two steps for anything beyond a scratch sandbox, especially once real teammates share a state file. - Mixing manual console changes with Terraform-managed resources. Editing a Terraform-managed security group by hand in the console causes drift; the next
terraform planwill try to revert your manual change, which is confusing if you don't know why. - Forgetting region-specific availability zones. Not every AWS region has an
a,b, andcAZ in the same pattern — always confirm withaws ec2 describe-availability-zonesrather than assuming. - Leaving test infrastructure running after the tutorial. Set a calendar reminder or run
terraform destroythe same session you finish testing — a forgotten NAT gateway or larger instance type can turn a few cents into real money over a month.
Troubleshooting Terraform and AWS errors
Here are the errors you're most likely to hit, and what actually fixes them.
- "Error: error configuring Terraform AWS Provider: no valid credential sources found." Your profile name doesn't match, or you forgot
--profile terraform-tutorialwhen runningaws configure. Runaws sts get-caller-identity --profile terraform-tutorialto confirm credentials resolve before touching Terraform again. - "Error: creating S3 Bucket: BucketAlreadyExists." S3 bucket names are global across all AWS accounts, not just yours. Change
bucket_nameinterraform.tfvarsto something more unique, like appending a random suffix. - "Error acquiring the state lock." A previous apply or plan crashed mid-run and left a lock in place. Confirm no other process is actually running, then release it with
terraform force-unlock <LOCK_ID>— the lock ID is printed in the error message. - "Error: InvalidAMIID.NotFound." Your AMI filter matched an image that isn't available in the region you're deploying to. Re-check the
filterblock'svaluespattern against what's actually published for your target region. - Plan shows unexpected resource replacement instead of an update. Some AWS resource attributes (like an EC2 instance's
availability_zone) force a full replace rather than an in-place update. Check the provider docs for that resource — attributes markedForceNewalways trigger a destroy-and-recreate. - "Error: The security group … does not exist in default VPC." Your security group reference is pointing at the wrong VPC (often because a default VPC and a custom VPC both exist). Double check
vpc_idis set explicitly on every relevant resource. - terraform destroy hangs or fails on a non-empty bucket. S3 buckets with objects (or versioned objects) inside them won't delete by default. Either empty the bucket manually with
aws s3 rm s3://bucket-name --recursiveor addforce_destroy = trueto the bucket resource before destroying. - Provider version keeps drifting between machines. You're not committing
.terraform.lock.hcl, or team members are runningterraform init -upgradeinconsistently. Decide on one policy for your team and stick to it — most production teams do commit the lock file, unlike the solo setup in this tutorial.
Terraform 1.15 features worth knowing about
Terraform 1.15, released April 29, 2026 with patches through 1.15.8.15.8 in July, added several features that matter once you move past a single-file tutorial project into real multi-module infrastructure.
| Feature | What it does | When you'll use it |
|---|---|---|
| Dynamic module sources | Use variables and locals in a module's source and version attributes | Conditionally sourcing modules per environment (e.g. a local module path in dev, a registry version in production) |
| Deprecated attribute on variables/outputs | Marks a variable or output as deprecated, producing a warning when it's used | Sunsetting old module interfaces without breaking consumers immediately |
convert() function | Inline type conversion for complex types | Cleaning up type mismatches between module inputs and outputs without extra locals |
Backend validation in terraform validate | Checks backend block arguments for correctness | Catching a malformed S3 backend config before init fails halfway through |
| Native Windows ARM64 builds | First-class Terraform binaries for ARM64 Windows devices | Running Terraform natively on Windows-on-ARM laptops without emulation |
Advanced tips: modules, HCP Terraform, and CI/CD
Once the basic workflow feels comfortable, a few patterns will save real time as your infrastructure grows. If you'd rather work through structured video instruction alongside this walkthrough, Cloudsoft Solutions launched a dedicated "Master Terraform on AWS in 2026" course on June 19, 2026 built around full AWS projects, and a separate "Terraform Full Course 2026 | Pass Associate Exam Fast" video covering AWS deployments went up on YouTube on April 7, 2026 for readers prepping for certification — either is a reasonable next step once you're past the basics covered here.
Break repeated infrastructure into modules. If you find yourself copy-pasting the VPC block from this tutorial into a second project, stop and extract it into a module instead. A module is just a directory of .tf files referenced with a module block and a source path — local modules are the easiest starting point before you publish anything to the public or a private registry. For a deeper walkthrough of exactly this kind of module extraction at production scale, an advanced AWS infrastructure-as-code course spanning nine modules went live on YouTube on September 6, 2026.
Consider HCP Terraform for remote runs and collaboration. HashiCorp's legacy free Terraform Cloud plan reached end-of-life on March 31, 2026, and existing free-tier organizations were automatically migrated to an enhanced free tier: unlimited users, up to 500 managed resources, and one concurrent run, plus a single policy set for basic policy-as-code. That's enough for a small team's shared state and remote apply workflow without paying anything, and it removes the need to self-host a state bucket at all. Paid tiers scale from roughly $0.10 per managed resource per month at the Essentials tier up to about $0.99 at Premium.
Wire Terraform into CI/CD early, not as an afterthought. A minimal GitHub Actions job that runs terraform fmt -check, terraform validate, and terraform plan on every pull request — with apply gated behind manual approval on merge to main — catches most mistakes before they touch real infrastructure. Store your AWS credentials as encrypted repository secrets or, better, use OIDC federation so no long-lived AWS keys exist in CI at all.
Adopt policy-as-code once you have more than a handful of resources. AWS's own 2026 guidance on governing infrastructure as code recommends organizing policies around five recurring control patterns: required metadata (tags), allowed configuration, exposure restriction (no public S3, no open security groups), protection enforcement (deletion protection on critical resources), and privilege constraint (least-privilege IAM). Sentinel (HCP Terraform) or Open Policy Agent both implement this pattern well.
Keep your MCP and automation tooling patched. If you're using terraform-mcp-server to let AI coding assistants interact with Terraform directly, upgrade to version 1.1.0 or later immediately — HashiCorp disclosed three vulnerabilities on July 28, 2026 (tracked as HCSEC-2026-23), including a cross-tenant credential reuse flaw rated CVSS 10.0 that could let one user's Terraform token be reused for another user's requests in stateless deployments.
The complete working project
For reference, here is every file from this tutorial assembled in one place. Copy these four files into a fresh directory, add your own IP address and bucket name, and you have a complete, working Terraform project that provisions a VPC, a public subnet, an EC2 instance, and an encrypted S3 bucket on AWS.
terraform.tf — provider and backend configuration:
terraform {
required_version = ">= 1.15.0, < 2.0.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.60"
}
}
backend "s3" {
bucket = "terraform-state-yourname-2026"
key = "tutorial/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
encrypt = true
}
}
provider "aws" {
region = var.aws_region
profile = "terraform-tutorial"
default_tags {
tags = {
Project = "terraform-aws-tutorial"
ManagedBy = "terraform"
Environment = "tutorial"
}
}
}
variables.tf — every input this configuration accepts:
variable "aws_region" {
description = "AWS region to deploy into"
type = string
default = "us-east-1"
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "public_subnet_cidr" {
description = "CIDR block for the public subnet"
type = string
default = "10.0.1.0/24"
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "bucket_name" {
description = "Globally unique S3 bucket name"
type = string
}
variable "admin_ip" {
description = "Your public IP address, in CIDR notation, for SSH access"
type = string
}
main.tf — the networking, compute, and storage resources, with the security group updated to reference the new admin_ip variable instead of a hardcoded placeholder:
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = { Name = "terraform-tutorial-vpc" }
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidr
map_public_ip_on_launch = true
availability_zone = "${var.aws_region}a"
tags = { Name = "terraform-tutorial-public-subnet" }
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "terraform-tutorial-igw" }
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = { Name = "terraform-tutorial-public-rt" }
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
resource "aws_security_group" "web" {
name = "terraform-tutorial-sg"
description = "Allow SSH from a single trusted IP"
vpc_id = aws_vpc.main.id
ingress {
description = "SSH from admin IP"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.admin_ip]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "terraform-tutorial-sg" }
}
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.web.id]
tags = { Name = "terraform-tutorial-instance" }
}
resource "aws_s3_bucket" "data" {
bucket = var.bucket_name
tags = { Name = "terraform-tutorial-bucket" }
}
resource "aws_s3_bucket_versioning" "data" {
bucket = aws_s3_bucket.data.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "data" {
bucket = aws_s3_bucket.data.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "data" {
bucket = aws_s3_bucket.data.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
outputs.tf — the values printed after every apply:
output "instance_public_ip" {
description = "Public IP address of the EC2 instance"
value = aws_instance.web.public_ip
}
output "bucket_name" {
description = "Name of the S3 bucket"
value = aws_s3_bucket.data.id
}
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
Finally, a terraform.tfvars file with your personal values, which stays out of version control per the .gitignore from Step 4:
bucket_name = "terraform-tutorial-yourname-2026"
admin_ip = "203.0.113.42/32"
With all five files in the same directory, the full workflow is four commands: terraform init, terraform plan -out=tfplan, terraform apply "tfplan", and — when you're finished — terraform destroy. That's the entire loop production teams run thousands of times a day, just aimed at a bigger and more complex set of resources than a single VPC and instance.
Terraform vs. the AWS-native alternatives
Terraform isn't the only way to define AWS infrastructure as code, and it's worth knowing when a competing tool fits better.
| Tool | Best for | Trade-off vs. Terraform |
|---|---|---|
| AWS CloudFormation | AWS-only shops that want a fully native, no-third-party-dependency tool | No multi-cloud support; YAML/JSON is more verbose than HCL for complex logic |
| AWS CDK | Teams that want to define infrastructure in TypeScript, Python, or Java instead of a DSL | Compiles to CloudFormation under the hood, so you inherit its AWS-only scope and some of its quirks |
| Pulumi | Teams wanting general-purpose programming languages across multiple clouds | Smaller provider ecosystem than Terraform's registry; steeper learning curve for infra-focused engineers |
| OpenTofu | Teams wanting Terraform's HCL workflow under a fully open-source license | Newer, smaller ecosystem (~12% adoption vs. Terraform's larger installed base), though growing fast |
| Terraform | Multi-cloud or hybrid environments, and teams that want the largest module/provider ecosystem | Requires learning HCL as a dedicated configuration language |
If you're only ever going to touch AWS and want to stay inside the AWS console's support umbrella, CloudFormation or CDK are reasonable choices. Terraform earns its place the moment you touch a second cloud, need a provider AWS hasn't built native tooling for yet, or want to reuse the same skill set across Azure and GCP projects covered elsewhere on this site.
Related Coverage
- How to Set Up AWS CDK: 10 Steps, 80 Min [2026]
- How to Set Up Amazon RDS: 12 Steps, 90 Min [2026]
- How to Set Up AWS Secrets Manager: 13 Steps, 80 Min [2026]
- How to Set Up AWS CloudTrail: 13 Steps, 70 Min [2026]
- Set Up Zero Trust Network Access: 15 Steps, 100 Min [2026]
- AWS vs Azure vs Google Cloud: 4x H100 GPU Price Gap [2026]
Frequently asked questions
Is Terraform free to use with AWS?
Yes. The Terraform CLI is free and open source under the Business Source License, and using it against your own AWS account costs nothing beyond whatever AWS resources you actually create. HCP Terraform's enhanced free tier (unlimited users, up to 500 managed resources, one concurrent run) is also free and covers most individual or small-team use cases.
Should I use Terraform or OpenTofu for a new AWS project?
For most people starting today, Terraform remains the safer default because of its larger provider ecosystem, more complete documentation, and bigger installed base. OpenTofu is a credible alternative if you specifically want a fully open-source license (MPL-2.0) and Linux Foundation governance, and its registry usage has been growing quickly, but its overall adoption (roughly 12% among IaC practitioners) is still well behind Terraform's.
Do I need DynamoDB for Terraform state locking on AWS?
No, not anymore. Native S3 state locking (available since Terraform 1.10, enabled with use_lockfile = true in the S3 backend block) replaces DynamoDB locking, which HashiCorp has formally deprecated. Skip the DynamoDB table entirely for new projects.
How much does it cost to run this tutorial's infrastructure?
If you're inside AWS's free tier window, effectively nothing. Outside the free tier, a t3.micro instance costs about $0.0104/hour and S3 storage is $0.023 per GB-month, so a few hours of testing typically totals a few cents — as long as you remember to run terraform destroy when you're done.
Can I use Terraform to manage existing AWS resources I created manually?
Yes, using terraform import (or the import block introduced in more recent Terraform versions) to bring an existing resource under Terraform management without recreating it. You'll still need to write the matching HCL resource block yourself, and it's worth double-checking the generated state against the real resource's configuration before trusting it.
Why does Terraform want to destroy and recreate a resource instead of updating it?
Some AWS resource attributes can't be changed in place — AWS's API only supports setting them at creation time. Terraform's provider marks these as force-new attributes, and any change to one triggers a full destroy-and-recreate cycle rather than an in-place update. Always read the plan output carefully to catch this before applying.
What's the difference between terraform plan and terraform apply?terraform plan calculates and displays what would change without touching any real infrastructure. terraform apply executes those changes. Separating them (with plan -out=tfplan followed by apply "tfplan") guarantees the changes you review are exactly the changes that get applied, with no drift in between.
Is it safe to store Terraform state in S3?
Yes, as long as you enable bucket versioning, server-side encryption, and block all public access on the state bucket, which is standard AWS-recommended practice. Treat the state bucket itself as sensitive infrastructure — restrict IAM access to only the roles and users that actually run Terraform.


