How to Set Up Terraform on AWS: 13 Steps, 90 Min [2026]

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.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

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.

RequirementVersion / detailWhy it matters
Terraform CLI1.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 CLIv2 (latest)Used to configure credentials and verify identity before Terraform runs
AWS accountIAM user or role with programmatic accessNever use root account credentials for Terraform
Operating systemmacOS, Linux, or Windows (including native ARM64)Terraform 1.15 ships native Windows ARM64 builds for the first time
EditorVS Code 1.86+ with the HashiCorp Terraform extension (2.34.5 or newer)Adds syntax highlighting, validation, and formatting via the Terraform Language Server
GitAny recent versionVersion-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 createdFree tier modelEC2 allowanceS3 allowance
Before July 15, 2025Legacy 12-month free tier750 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, 2025Credit-based Free PlanFree-tier-eligible types (t3.micro, t3.small, t4g.micro, t4g.small, c7i-flex.large, m7i-flex.large) drawn against credit balanceCovered 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 .gitignore from Step 4 prevents this, but double-check with git status before 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 with most_recent = true as 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 plan and apply into 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 plan will 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, and c AZ in the same pattern — always confirm with aws ec2 describe-availability-zones rather than assuming.
  • Leaving test infrastructure running after the tutorial. Set a calendar reminder or run terraform destroy the 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-tutorial when running aws configure. Run aws sts get-caller-identity --profile terraform-tutorial to 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_name in terraform.tfvars to 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 filter block's values pattern 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 marked ForceNew always 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_id is 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 --recursive or add force_destroy = true to the bucket resource before destroying.
  • Provider version keeps drifting between machines. You're not committing .terraform.lock.hcl, or team members are running terraform init -upgrade inconsistently. 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.

FeatureWhat it doesWhen you'll use it
Dynamic module sourcesUse variables and locals in a module's source and version attributesConditionally sourcing modules per environment (e.g. a local module path in dev, a registry version in production)
Deprecated attribute on variables/outputsMarks a variable or output as deprecated, producing a warning when it's usedSunsetting old module interfaces without breaking consumers immediately
convert() functionInline type conversion for complex typesCleaning up type mismatches between module inputs and outputs without extra locals
Backend validation in terraform validateChecks backend block arguments for correctnessCatching a malformed S3 backend config before init fails halfway through
Native Windows ARM64 buildsFirst-class Terraform binaries for ARM64 Windows devicesRunning 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.

ToolBest forTrade-off vs. Terraform
AWS CloudFormationAWS-only shops that want a fully native, no-third-party-dependency toolNo multi-cloud support; YAML/JSON is more verbose than HCL for complex logic
AWS CDKTeams that want to define infrastructure in TypeScript, Python, or Java instead of a DSLCompiles to CloudFormation under the hood, so you inherit its AWS-only scope and some of its quirks
PulumiTeams wanting general-purpose programming languages across multiple cloudsSmaller provider ecosystem than Terraform's registry; steeper learning curve for infra-focused engineers
OpenTofuTeams wanting Terraform's HCL workflow under a fully open-source licenseNewer, smaller ecosystem (~12% adoption vs. Terraform's larger installed base), though growing fast
TerraformMulti-cloud or hybrid environments, and teams that want the largest module/provider ecosystemRequires 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

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.

Nadia Dubois

Nadia Dubois

AI & Innovation Editor

Nadia Dubois is the AI & Innovation Editor at Tech Insider, where she tracks the rapid evolution of artificial intelligence, from foundation models to real-world enterprise deployment. She previously covered AI and startups for La Tribune and contributed to MIT Technology Review's European coverage. Nadia specializes in generative AI, AI regulation, and the intersection of technology and European industrial policy. She holds a dual degree in Computational Linguistics and Journalism from Sciences Po Paris.

View all articles