How to Set Up Terraform for AWS: 13 Steps, 75 Min [2026]

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.

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

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.

FactorTerraformAWS CloudFormationPulumi
Configuration languageHashiCorp Configuration Language (HCL)JSON or YAMLTypeScript, Python, Go, C#, Java
Cloud supportAWS, Azure, GCP, and thousands of other providersAWS onlyAWS, Azure, GCP, Kubernetes
State managementExplicit state file, local or remote backendManaged by AWS directly, no file to loseState file, self-hosted or Pulumi Cloud
Cost to runCLI is free; OpenTofu is the open-source forkFree, you only pay for the AWS resources it createsFree CLI; Pulumi Cloud has a paid tier for teams
Best fitMulti-cloud teams and existing HCL codebasesAWS-only shops that want zero extra toolingDevelopers 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.
ToolMinimum versionCheck commandInstall source
TerraformCurrent 1.x releaseterraform versionHashiCorp install docs
AWS CLIv2.xaws --versionAWS CLI v2 installer
hashicorp/aws provider~> 5.0 or newerset in providers.tfTerraform Registry
Text editorAnyVS Code + HashiCorp Terraform extension recommended
Git (optional)Latestgit --versionFor 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.tfvars file 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 yes reflexively 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/0 on 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/aws provider can jump a major version on the next terraform init and 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 plan will 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 messageLikely causeFix
No valid credential sources foundEnvironment variables not set, or wrong shell sessionRe-export AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, or run aws configure
InvalidClientTokenId: The security token included in the request is invalidAccess key was rotated or deleted in IAMGenerate a fresh access key with aws iam create-access-key and update your environment
Error acquiring the state lockA previous apply crashed or was killed mid-run, leaving a stale lockConfirm 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 stateBring it under management with terraform import instead of recreating it
BucketAlreadyExists / BucketAlreadyOwnedByYouS3 bucket names are globally unique across all AWS accountsAdd an account ID or random suffix to the bucket name, as shown in Step 8
Provider produced inconsistent final planUsually a provider bug or a computed attribute read incorrectlyUpgrade to the latest hashicorp/aws provider release and re-run terraform init -upgrade
Unsupported argument after a provider upgradeA 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 outOutbound HTTPS to AWS API endpoints is blocked by a network ACL, proxy, or firewallConfirm the machine running Terraform can reach *.amazonaws.com on port 443
Error: CycleTwo or more resources reference each other, creating a circular dependencyRestructure the dependency or break the cycle with an explicit depends_on
Access Denied on the S3 backend bucketThe IAM user lacks permissions on the state bucket itself, separate from the resources it managesGrant 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

For more cloud infrastructure and DevOps tutorials, browse the full cloud computing section on Tech Insider.

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