Terraform has become the default way engineering teams describe AWS infrastructure in code instead of clicking through the console. Search for terraform aws provider or terraform aws tutorial right now and you’ll find dozens of half-finished examples that skip the parts that actually break in practice: credential bootstrapping, remote state, and what happens when terraform plan disagrees with what’s actually running in your account.
This tutorial walks through a complete, working Terraform AWS setup, from a blank folder to a deployed VPC, EC2 instance, and S3 bucket, then on to a production-style remote state backend and reusable modules. Every code block below is a real Terraform configuration you can copy, run, and destroy without leaving orphaned resources in your AWS bill. By the end you’ll have a working project structure, a troubleshooting list for the errors that show up most often, and a clear answer to when Terraform is the right call versus CloudFormation or Pulumi.
This is written for someone who has an AWS account and is comfortable in a terminal, but has never run terraform init for real. If you’ve already got a working provider block and just need the networking, compute, and state-backend pieces, jump straight to Step 6. Everyone else should start from Step 1, since the credential bootstrap in Step 2 is the part most tutorials gloss over and the part that causes the most confusing first errors.
Estimated time: 75 minutes. Estimated cost: under $1 in AWS charges if you run terraform destroy when you’re finished, since every resource used here qualifies for the AWS Free Tier.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
What Terraform Does for AWS Infrastructure (and Why It Matters in 2026)
Cloud computing, at its core, is on-demand access to compute, storage, and networking delivered over the internet with pay-per-use pricing, as IBM’s own definition puts it. Terraform’s job is to make that access repeatable, and increasingly, safer by default: when HashiCorp shipped AWS provider v6.0 in June 2025, it flipped resources like Redshift clusters to publicly_accessible = false and encrypted = true out of the box, so a team no longer has to remember to lock those settings down by hand. HashiCorp’s own v6.0 upgrade guide is blunt about the safest path there: move to the latest 5.x release first, then to 6.0.0, a sequencing that’s applied to every upgrade path since the guide’s May 2025 publication. Instead of clicking through the AWS console to create a VPC, a subnet, an EC2 instance, and the security group that connects them, you describe the end state in configuration files and let Terraform work out the API calls needed to get there.
That declarative model is why Terraform still shows up on nearly every current cloud computing syllabus, alongside virtualization, networking, IAM, DevOps automation, containers and Kubernetes, serverless computing, and FinOps, according to Scaler’s 2026 cloud computing curriculum breakdown. Multi-cloud and hybrid-cloud delivery models are among the biggest cloud themes carrying through 2026, and Terraform is one of the few tools built to span AWS, Azure, and GCP with the same workflow and the same command set.
Under the hood, Terraform tracks every resource it creates in a state file: a JSON record that maps your configuration to real AWS resource IDs. That state file is what lets terraform plan tell you, before anything changes, exactly what will be added, changed, or destroyed. It’s also the single biggest source of confusion for first-time users, which is why the second half of this tutorial focuses specifically on moving that file somewhere safer than a laptop’s hard drive.
For AWS specifically, the Terraform AWS provider is the plugin that translates your HCL configuration into AWS API calls. It’s maintained against the official AWS provider registry page, and every code block in this tutorial depends on it — a plugin HashiCorp reported had crossed 4 billion downloads by May 6, 2025, making it one of the most widely used entries in the entire Terraform Registry. Coverage keeps expanding, too: AWS’s own DevOps blog put the related Terraform AWS Cloud Control provider at 950+ supported resources and data sources back in May 2024, a floor the mainline provider has only pushed higher through the v6.x releases that followed across 2025 and 2026. Getting the provider block right in Step 3 is the single most common point where beginners get stuck, so that step walks through the full authentication chain Terraform checks, in order.
Worth knowing before you start: HashiCorp moved Terraform off a fully open-source license to the Business Source License back in 2023, which is what led a group of cloud vendors to fork the last open-source release into OpenTofu, now hosted under the Linux Foundation. For everything in this tutorial, the distinction doesn’t matter. OpenTofu is command-for-command compatible with the Terraform CLI syntax shown here, so every code block works whether you install terraform or tofu. It only starts to matter if your organization has a licensing policy that specifically requires fully open-source tooling, in which case OpenTofu is worth a look before you standardize on either one.
Terraform vs CloudFormation vs Pulumi on AWS
Terraform isn’t the only infrastructure-as-code option for AWS. Here’s how the three most common choices compare before you commit to one for a real project.
| Factor | Terraform | AWS CloudFormation | Pulumi |
|---|---|---|---|
| Configuration language | HashiCorp Configuration Language (HCL) | JSON or YAML | TypeScript, Python, Go, C#, Java |
| Cloud support | AWS, Azure, GCP, and thousands of other providers | AWS only | AWS, Azure, GCP, Kubernetes |
| State management | Explicit state file, local or remote backend | Managed by AWS directly, no file to lose | State file, self-hosted or Pulumi Cloud |
| Cost to run | CLI is free; OpenTofu is the open-source fork | Free, you only pay for the AWS resources it creates | Free CLI; Pulumi Cloud has a paid tier for teams |
| Best fit | Multi-cloud teams and existing HCL codebases | AWS-only shops that want zero extra tooling | Developers who prefer a general-purpose language |
If your team is AWS-only and wants the tightest native integration with no separate state file to manage, CloudFormation removes an entire failure mode that this tutorial spends two steps working around. If you’re already comfortable in Python or TypeScript and don’t want to learn HCL syntax, Pulumi uses similar state concepts to Terraform but in a language you already know. Terraform remains the default answer for teams managing more than one cloud provider, since it’s the only one of the three built from the ground up to treat AWS, Azure, and GCP as equally first-class targets.
Prerequisites: Accounts, Tools, and Versions You’ll Need
Get these in place before writing a single .tf file. Skipping any of them is the most common reason the first terraform init or terraform apply fails.
- An AWS account with billing enabled. The AWS Free Tier covers every resource this tutorial creates.
- A terminal on macOS, Linux, or WSL on Windows.
- Terraform installed locally (latest stable release).
- AWS CLI v2 installed and ready to configure.
- A code editor. VS Code with the HashiCorp Terraform extension is the most common setup.
- 20-30 uninterrupted minutes, since infrastructure that’s half-applied is harder to clean up than infrastructure that finished.
| Tool | Minimum version | Check command | Install source |
|---|---|---|---|
| Terraform | Current 1.x release | terraform version | HashiCorp install docs |
| AWS CLI | v2.x | aws --version | AWS CLI v2 installer |
| hashicorp/aws provider | ~> 5.0 or newer | set in providers.tf | Terraform Registry |
| Text editor | Any | — | VS Code + HashiCorp Terraform extension recommended |
| Git (optional) | Latest | git --version | For version-controlling your .tf files |
A note on versions: this tutorial was written and tested against the current 1.x line of Terraform and the ~> 5.0 major version of the hashicorp/aws provider referenced in the code blocks above; teams starting fresh today should target the newer ~> 6.0 line instead, which HashiCorp took GA on June 18, 2025 (after a May 7, 2025 public beta), retiring 17 legacy OpsWorks resources and adding per-resource region overrides for multi-region workflows. Both projects ship frequently — the AWS provider alone moved from 6.5.0’s four new resources on July 24, 2025, to 6.15.0’s Lake Formation tag-expression support that October, 6.25.0’s CloudWatch and EKS additions in December, and 6.29.0 on January 22, 2026, before reaching v6.52.0 (its 414th tagged GitHub release) on June 24, 2026, 6.57.1 on the Terraform Registry by July 29, 2026, and 6.62.0 on August 26, 2026. A February 2026 OneUptime piece on upgrading AWS provider versions put the right habit simply: check the GitHub CHANGELOG before touching a version constraint, since it’s the canonical record of what actually changed release to release. Run terraform version after installing and compare it against the release notes linked above rather than assuming the exact patch version matches what’s printed here.
One thing that isn’t on the list above on purpose: a specific AWS region. Every code block in this tutorial defaults to us-east-1, but nothing about the setup is region-specific. If your team standardizes on a different region for latency or compliance reasons, change the aws_region variable in Step 5 and the rest of the configuration follows automatically, since every resource block below reads from that variable instead of a hardcoded region string.
Step 1-2: Install Terraform and Create a Least-Privilege IAM User
Install Terraform using your platform’s package manager (Homebrew on macOS, Chocolatey on Windows, or the official binary on Linux), then confirm it’s on your PATH:
$ terraform version
Terraform v1.x.x
on linux_amd64
Next, resist the temptation to use your AWS root account’s access keys for Terraform. Root credentials have no guardrails, and if a .tfvars file with root keys ever leaks, the blast radius is your entire account. Instead, create a dedicated IAM user with the AWS CLI first, since you need credentials to exist before Terraform can authenticate with them. This is the one part of the setup Terraform can’t bootstrap itself:
aws iam create-user --user-name terraform-deployer
aws iam attach-user-policy \
--user-name terraform-deployer \
--policy-arn arn:aws:iam::aws:policy/PowerUserAccess
aws iam create-access-key --user-name terraform-deployer
The last command prints an access key ID and secret. Save both somewhere secure; the secret is only shown once:
{
"AccessKey": {
"UserName": "terraform-deployer",
"AccessKeyId": "AKIAIOSFODNN7EXAMPLE",
"Status": "Active",
"SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"CreateDate": "2026-06-29T14:02:11+00:00"
}
}
PowerUserAccess is fine for following along with this tutorial. For a real team environment, scope the policy down to only the services you actually provision (EC2, VPC, S3, and IAM role management for this project) once you know what those are. Least-privilege IAM design is covered in detail in AWS’s own IAM roles documentation.
Step 3: Configure the Terraform AWS Provider
This is the step most terraform aws provider errors trace back to. Create a project folder and a providers.tf file inside it:
mkdir terraform-aws-starter && cd terraform-aws-starter
touch providers.tf variables.tf main.tf outputs.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
ManagedBy = "Terraform"
Project = "terraform-aws-starter"
}
}
}
Terraform never asks you to paste credentials into a configuration file, and you shouldn’t build a habit of doing so. Instead, the AWS provider looks for credentials in a fixed order: environment variables first, then the shared ~/.aws/credentials file, then an EC2 instance profile if you’re running on AWS compute. For local development, environment variables are the simplest option:
export AWS_ACCESS_KEY_ID="AKIAIOSFODNN7EXAMPLE"
export AWS_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
export AWS_DEFAULT_REGION="us-east-1"
The default_tags block is easy to skip and expensive to regret. Every resource this provider creates inherits those tags automatically, which is what makes cost allocation and FinOps tracking possible later without going back and manually tagging dozens of resources.
Step 4-5: Initialize Terraform and Write Your main.tf Configuration
With providers.tf saved, initialize the working directory. This downloads the AWS provider plugin and sets up the local .terraform folder:
$ terraform init
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.0"...
- Installing hashicorp/aws v5.x...
- Installed hashicorp/aws v5.x (signed by HashiCorp)
Terraform has been successfully initialized!
Next, define the variables the rest of this project will reuse. Put these in variables.tf:
variable "aws_region" {
description = "AWS region to deploy into"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Name prefix applied to all resources"
type = string
default = "terraform-aws-starter"
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "admin_cidr" {
description = "Your IP address, for SSH access, in CIDR notation"
type = string
}
Notice admin_cidr has no default. That’s deliberate: a security group rule that defaults to your IP address for everyone who clones this project is a rule that will eventually default to the wrong IP address. Terraform will prompt for it at apply time, or you can set it in a terraform.tfvars file that you keep out of version control.
Step 6: Provision a VPC, Subnets, and Networking
Add the networking layer to main.tf. This creates a VPC, a public subnet, an internet gateway, and the route table that ties them together, the same four pieces you’d normally click through five console screens to set up. Background on what each piece does is in AWS’s VPC documentation:
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.project_name}-vpc"
}
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "${var.aws_region}a"
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-subnet"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-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 = "${var.project_name}-public-rt"
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
Every resource block follows the same pattern: a type (aws_vpc), a local name Terraform uses to reference it elsewhere (main), and arguments. The aws_route_table_association resource at the bottom is what actually connects the subnet to the route pointing at the internet gateway. Forgetting it is a common reason an EC2 instance in a “public” subnet still can’t be reached.
The CIDR sizing here is deliberately generous. A /16 VPC (10.0.0.0/16) gives you roughly 65,000 addresses to split across subnets later, and a /24 public subnet (10.0.1.0/24) covers 256 of them, more than enough for a single EC2 instance with room to add a load balancer or a second instance later without re-architecting the network. Resist the temptation to size a VPC tightly around exactly what you need today. Expanding a CIDR block after other resources depend on it is far more disruptive than starting slightly larger than necessary.
Step 7: Launch an EC2 Instance With Terraform
With networking in place, add a security group and an EC2 instance. Rather than hardcoding an AMI ID, which goes stale the moment AWS ships a new patched image, use a data source to look up the current Amazon Linux AMI at apply time:
resource "aws_security_group" "web" {
name = "${var.project_name}-web-sg"
description = "Allow SSH from admin IP and HTTP from anywhere"
vpc_id = aws_vpc.main.id
ingress {
description = "SSH"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.admin_cidr]
}
ingress {
description = "HTTP"
from_port = 80
to_port = 80
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"]
}
}
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.web.id]
tags = {
Name = "${var.project_name}-web"
}
}
t3.micro is Free Tier eligible in most regions, which is why it’s the default in almost every Terraform AWS tutorial you’ll find, including this one. Double-check current Free Tier terms for your account age and region before assuming zero cost.
Step 8: Add an S3 Bucket and IAM Role With Least Privilege
Most real applications need somewhere to store data and a way for the EC2 instance to reach it without embedding credentials on the box. That means an S3 bucket, plus an IAM role the instance can assume:
data "aws_caller_identity" "current" {}
resource "aws_s3_bucket" "app_data" {
bucket = "${var.project_name}-data-${data.aws_caller_identity.current.account_id}"
}
resource "aws_s3_bucket_versioning" "app_data" {
bucket = aws_s3_bucket.app_data.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_iam_role" "ec2_s3_access" {
name = "${var.project_name}-ec2-s3-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy" "s3_read_write" {
name = "${var.project_name}-s3-rw"
role = aws_iam_role.ec2_s3_access.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject", "s3:ListBucket"]
Resource = [
aws_s3_bucket.app_data.arn,
"${aws_s3_bucket.app_data.arn}/*"
]
}]
})
}
The bucket name includes the AWS account ID from data.aws_caller_identity.current because S3 bucket names are globally unique across every AWS customer, not just your account. Skipping that suffix is why BucketAlreadyExists is one of the most common first errors newcomers hit, and it’s listed again in the troubleshooting table further down. Also worth knowing if you’re touching bucket ACLs or logging configuration on an existing project: AWS provider 6.12.0 (September 4, 2025) deprecated the display_name attribute on S3 ACL and logging resources, since AWS itself stopped supporting that field on November 21, 2025, so older configurations that still set it need updating before they’ll apply cleanly. It’s part of a broader pattern of the provider trailing AWS’s own service retirements: the v6.0 upgrade guide also flags Amazon Elastic Transcoder, discontinued on November 13, 2025, which took the single aws_elastictranscoder_pipeline resource down with it, and CloudWatch Evidently, whose AWS-side support ended October 17, 2025 ahead of its related resources being pulled from a future provider release. If either shows up in an older configuration, budget time to migrate off it rather than assuming the next terraform init -upgrade will just work.
Notice the IAM policy in aws_iam_role_policy lists exactly three actions, GetObject, PutObject, and ListBucket, scoped only to this one bucket’s ARN. It would be faster to attach the AWS-managed AmazonS3FullAccess policy and move on, but that grants read and write access to every S3 bucket in the account, including ones this project has nothing to do with. Writing the narrower policy by hand takes an extra two minutes now and removes an entire category of “why does this EC2 instance have access to that” questions during a future security review.
Step 9-10: Run terraform plan and Apply Your Configuration
Never run terraform apply without reading the plan first. terraform plan is a dry run: it tells you exactly what will change without touching your AWS account.
$ terraform plan
Terraform will perform the following actions:
# aws_vpc.main will be created
+ resource "aws_vpc" "main" { ... }
# aws_instance.web will be created
+ resource "aws_instance" "web" { ... }
# aws_s3_bucket.app_data will be created
+ resource "aws_s3_bucket" "app_data" { ... }
# aws_iam_role.ec2_s3_access will be created
+ resource "aws_iam_role" "ec2_s3_access" { ... }
Plan: 9 to add, 0 to change, 0 to destroy.
The count on that last line matters more than it looks. If you expected 9 resources and Terraform says 14, something in your configuration is duplicating a block, most often a copy-pasted resource with the local name left unchanged. Once the plan matches what you expect, apply it:
$ terraform apply
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
aws_vpc.main: Creating...
aws_vpc.main: Creation complete after 2s
aws_internet_gateway.main: Creating...
aws_internet_gateway.main: Creation complete after 1s
aws_instance.web: Creating...
aws_instance.web: Still creating... [10s elapsed]
aws_instance.web: Creation complete after 24s
Apply complete! Resources: 9 added, 0 changed, 0 destroyed.
Outputs:
instance_public_ip = "54.204.xx.xx"
vpc_id = "vpc-0a1b2c3d4e5f6g7h8"
Typing yes by hand is intentional friction. It’s the last checkpoint before Terraform starts making real API calls against a real AWS bill, which is why -auto-approve should stay out of your local workflow and live only inside a reviewed CI/CD pipeline, covered later in the advanced tips section.
Step 11: Migrate State to a Remote S3 Backend With Locking
Right now your state file lives at terraform.tfstate on your local disk. That’s fine for this tutorial, but it breaks the moment a second person on your team runs terraform apply from their own laptop, because there’s no shared source of truth and no lock to stop two applies from colliding. Fix that by creating a dedicated state bucket and moving to an S3 backend:
aws s3api create-bucket --bucket my-terraform-state-bucket --region us-east-1
aws s3api put-bucket-versioning \
--bucket my-terraform-state-bucket \
--versioning-configuration Status=Enabled
terraform {
backend "s3" {
bucket = "my-terraform-state-bucket"
key = "terraform-aws-starter/terraform.tfstate"
region = "us-east-1"
encrypt = true
}
}
State locking prevents two apply commands from running against the same state file at once. The exact locking mechanism has changed across Terraform releases, so check the current requirement on the official S3 backend documentation before deploying this for a team: some setups still pair the S3 backend with a separate DynamoDB table for locking, while newer releases support native locking through the backend itself. Either way, run terraform init again after adding this block. Terraform will detect the new backend and offer to copy your existing local state into it automatically.
The encrypt = true line isn’t optional in practice, even though Terraform will let you omit it. State files store resource attributes in plain text, and depending on what you provision, that can include database passwords, private keys, or other values passed in through variables. Treat the state bucket with the same care you’d give a secrets manager: enable versioning (already set above), turn on default encryption, and make sure the IAM policy on that bucket is scoped only to the people and pipelines that actually need it, not the same broad policy you might use for application data.
Step 12: Use Variables, Outputs, and Reusable Modules
With the core setup working, the last thing to clean up is the fact that every resource so far lives in one flat main.tf file. Add outputs so other configurations, or your teammates, can read values back out without digging through the state file:
output "instance_public_ip" {
description = "Public IP address of the web instance"
value = aws_instance.web.public_ip
}
output "vpc_id" {
description = "ID of the created VPC"
value = aws_vpc.main.id
}
output "s3_bucket_name" {
description = "Name of the app data bucket"
value = aws_s3_bucket.app_data.bucket
}
Then, once you find yourself needing the same VPC-plus-subnet pattern in a second project, pull it into a module instead of copy-pasting it:
module "network" {
source = "./modules/network"
vpc_cidr = var.vpc_cidr
project_name = var.project_name
}
A module is just a subfolder with its own main.tf, variables.tf, and outputs.tf, referenced by relative path. It’s the same VPC code from Step 6, moved into modules/network/main.tf, with the hardcoded values swapped for variables. This is also the point where teams typically stop writing raw resource blocks for common patterns and start pulling verified modules from the Terraform Registry instead of maintaining their own.
Step 13: Destroy Resources Safely When You’re Done
Once you’re done experimenting, tear everything down so nothing keeps billing quietly in the background:
$ terraform destroy
Plan: 0 to add, 0 to change, 9 to destroy.
Do you really want to destroy all resources?
Terraform will destroy all your managed infrastructure.
Only 'yes' will be accepted to confirm.
Enter a value: yes
Destroy complete! Resources: 9 destroyed.
If you moved to the remote S3 backend in Step 11, destroy the VPC and EC2 resources first, then separately empty and delete the state bucket itself, since Terraform can’t destroy the backend it’s currently using to store its own state. Check your AWS billing console 24 hours later to confirm nothing was left running. terraform destroy is reliable, but a resource created manually outside Terraform, during troubleshooting, for example, won’t be in its state and won’t be cleaned up automatically.
Common Pitfalls When Running Terraform on AWS
- Hardcoding credentials in .tf files. Access keys pasted directly into a provider block or committed in a
terraform.tfvarsfile end up in your git history forever, even after you delete them in a later commit. - Skipping the remote backend. A local-only state file works until a second person runs apply, or your laptop dies. Treat Step 11 as mandatory for anything beyond a solo experiment.
- Applying without reading the plan. The habit of typing
yesreflexively is how a variable typo turns into an accidental resource replacement instead of an in-place update. - Overly broad security group rules.
0.0.0.0/0on port 22 is one of the most commonly flagged misconfigurations in cloud security audits. Scope SSH to your own IP, as shown in Step 7. - Not pinning provider and module versions. An unpinned
hashicorp/awsprovider can jump a major version on the nextterraform initand break configuration syntax that worked yesterday. - Editing resources by hand in the AWS console. Any change made outside Terraform creates drift between your configuration and reality. The next
planwill either silently revert your manual fix or fail confusingly. - Skipping tags. Without consistent tags, cost allocation and FinOps reporting become guesswork the moment a bill has more than a handful of resources on it.
- Treating terraform destroy as an afterthought. Tearing down a tutorial environment feels optional right up until a forgotten EC2 instance or NAT gateway shows up on next month’s bill. Make destroying test infrastructure as routine a habit as creating it.
Troubleshooting: Terraform AWS Provider Errors and Fixes
These are the errors that show up most often when running Terraform against AWS, roughly in the order beginners hit them. Most of them share a root cause: Terraform is reporting exactly what AWS told it, which means the fix almost always lives on the AWS side (an IAM policy, a network path, an existing resource) rather than in the Terraform binary itself.
| Error message | Likely cause | Fix |
|---|---|---|
| No valid credential sources found | Environment variables not set, or wrong shell session | Re-export AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, or run aws configure |
| InvalidClientTokenId: The security token included in the request is invalid | Access key was rotated or deleted in IAM | Generate a fresh access key with aws iam create-access-key and update your environment |
| Error acquiring the state lock | A previous apply crashed or was killed mid-run, leaving a stale lock | Confirm no other apply is running, then release it with terraform force-unlock <LOCK_ID> |
| EntityAlreadyExists (IAM role or user) | The resource already exists in AWS but isn’t in Terraform’s state | Bring it under management with terraform import instead of recreating it |
| BucketAlreadyExists / BucketAlreadyOwnedByYou | S3 bucket names are globally unique across all AWS accounts | Add an account ID or random suffix to the bucket name, as shown in Step 8 |
| Provider produced inconsistent final plan | Usually a provider bug or a computed attribute read incorrectly | Upgrade to the latest hashicorp/aws provider release and re-run terraform init -upgrade |
| Unsupported argument after a provider upgrade | A breaking change between major provider versions (for example v4 to v5 syntax) | Check the provider’s upgrade guide on the Terraform Registry before bumping the major version |
| terraform plan hangs or times out | Outbound HTTPS to AWS API endpoints is blocked by a network ACL, proxy, or firewall | Confirm the machine running Terraform can reach *.amazonaws.com on port 443 |
| Error: Cycle | Two or more resources reference each other, creating a circular dependency | Restructure the dependency or break the cycle with an explicit depends_on |
| Access Denied on the S3 backend bucket | The IAM user lacks permissions on the state bucket itself, separate from the resources it manages | Grant s3:GetObject, s3:PutObject, and s3:ListBucket on the state bucket explicitly |
Advanced Tips: Workspaces, CI/CD, and Drift Detection
Once the basic workflow is comfortable, a few practices separate a one-off script from infrastructure a team can actually rely on.
Workspaces for multiple environments. terraform workspace new staging and terraform workspace new production let the same configuration manage separate state files per environment, so a mistake in staging can’t touch production resources.
Run plan and apply in CI, not on laptops. A minimal GitHub Actions workflow that plans on every pull request and applies only on merge to main removes “it worked on my machine” from your infrastructure changes entirely:
name: Terraform
on:
pull_request:
push:
branches: [main]
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init
- run: terraform fmt -check
- run: terraform plan
- if: github.ref == 'refs/heads/main'
run: terraform apply -auto-approve
Detect drift on a schedule. A nightly terraform plan run against production, with output posted to a Slack channel or ticket, catches manual console changes before they cause an incident instead of during one.
Format and validate before every commit. terraform fmt and terraform validate run in under a second and catch syntax issues and inconsistent formatting before a teammate has to review them.
Adopt existing infrastructure instead of recreating it. If a resource already exists in AWS from before you started using Terraform, terraform import (or an import block in newer Terraform versions) brings it under management without tearing it down and rebuilding it.
Estimate cost before you apply, not after the bill arrives. Open-source tools like Infracost can parse a Terraform plan and estimate the monthly cost of what you’re about to create, right in the pull request, before anything gets provisioned. For a FinOps-conscious team, that turns cost review from a monthly surprise into a normal part of code review.
Complete Working Project: Full Directory Reference
Every code block in this tutorial fits together into one deployable project. Here’s the full structure once Step 12’s modules are in place:
terraform-aws-starter/
├── providers.tf # Terraform + AWS provider configuration (Step 3)
├── variables.tf # aws_region, project_name, vpc_cidr, admin_cidr (Step 5)
├── main.tf # EC2, security group, S3 bucket, IAM role (Steps 7-8)
├── outputs.tf # instance_public_ip, vpc_id, s3_bucket_name (Step 12)
├── terraform.tfvars # your admin_cidr value, kept out of git
├── backend.tf # remote S3 backend configuration (Step 11)
└── modules/
└── network/
├── main.tf # VPC, subnet, internet gateway, route table (Step 6)
├── variables.tf
└── outputs.tf
Running terraform init followed by terraform apply against this structure provisions a VPC with a public subnet, an internet-reachable EC2 instance, a versioned S3 bucket, and an IAM role connecting the two, all under a remote state backend with your project’s resources consistently tagged. That’s the same footprint most teams start from before layering on load balancers, auto scaling, or a database tier.
From here, the natural next additions are an Application Load Balancer in front of a second EC2 instance in a separate availability zone, an aws_autoscaling_group so capacity adjusts to traffic instead of sitting fixed at one instance, and an RDS instance if the application needs a database instead of flat files in S3. None of those require rethinking the project structure above. Each one is a new resource block or module that references the same VPC and subnet this tutorial already built.
Frequently Asked Questions
Is Terraform free to use with AWS?
Yes. The Terraform CLI is free to download and run. You’re only billed for the AWS resources it creates on your behalf, the same as if you’d created them through the console.
Do I need an AWS root account to follow these steps?
No, and you shouldn’t use one. Step 2 has you create a dedicated IAM user specifically so root credentials never touch your Terraform configuration.
What’s the difference between terraform plan and terraform apply?plan is a dry run that shows what would change without touching AWS. apply executes that plan and makes real API calls. Always run plan first.
Can I complete this tutorial entirely on the AWS Free Tier?
Yes. The VPC, a t3.micro EC2 instance, and a small S3 bucket all fall within Free Tier limits for a new or lightly used account. Confirm current Free Tier terms for your account before assuming zero cost long-term.
Is it safe to commit the Terraform state file to git?
No. State files often contain sensitive values in plain text, including database passwords and secret keys referenced by your configuration. Keep state in a remote backend like the S3 setup in Step 11, and add *.tfstate to .gitignore.
What happens if I lose my Terraform state file?
Terraform loses track of what it created, though the AWS resources themselves keep running and billing. You can rebuild a state file with terraform import for each resource, but it’s tedious, which is exactly why Step 11’s remote, versioned backend matters.
Should I use Terraform or the AWS Console for production infrastructure?
Terraform, once more than one person touches the infrastructure or it needs to be reproducible. The console is fine for quick one-off experiments, but it leaves no record of what changed, when, or why.
How is the Terraform AWS provider different from Terraform itself?
Terraform is the core engine that reads your configuration, manages state, and calculates plans. The AWS provider is a separate plugin, maintained against the AWS API, that Terraform calls to actually create, read, update, or delete AWS resources. The same core engine uses different providers for Azure, GCP, or any of the thousands of other services Terraform supports.
Related Coverage
- How to Set Up Amazon EKS: 12 Steps, 90 Min [2026]
- How to Set Up Kubecost: 12 Steps, 60 Min [2026]
- Step Functions vs Airflow: $0 vs $357/mo Floor [2026]
- Karpenter vs Cluster Autoscaler vs KEDA: 3x Faster [2026]
- What Is Amazon RDS?
- AWS FinOps Agent Launches Free in $16.5B Market [2026]
For more cloud infrastructure and DevOps tutorials, browse the full cloud computing section on Tech Insider.


