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

Terraform still runs the show when it comes to describing AWS infrastructure in code. Click through the EC2 console to launch a server by hand and you get a resource nobody else can reliably reproduce. Write the same server as a Terraform resource block and you get a file that any teammate, any CI pipeline, or you in six months, can read, review, and re-run. That gap between “it works on my account” and “it works because the code says so” is why Terraform has stuck around even as HashiCorp’s 2023 license change pushed a chunk of its own community toward OpenTofu.

This tutorial builds a working AWS setup from a blank terminal: installing Terraform 1.15, wiring up least-privilege IAM credentials, writing and deploying a real EC2 web server, moving state to a remote S3 backend with native locking, and organizing the whole thing into a reusable module. Budget about 90 minutes, plus a few minutes of AWS waiting on each apply. By the end you’ll have a small, working project you can extend, plus the troubleshooting instincts for when something breaks.

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, and Why AWS Teams Still Choose It in 2026

Terraform is HashiCorp’s infrastructure-as-code tool. You describe the AWS resources you want, an EC2 instance, a security group, an S3 bucket, in HashiCorp Configuration Language, and Terraform works out the AWS API calls needed to make reality match your file. Change the file, run Terraform again, and it calculates the difference and applies only what changed. That plan-then-apply loop is the entire value proposition: you see what’s about to happen before it happens.

Under the hood, Terraform talks to AWS through a provider, a plugin that translates HCL resource blocks into AWS API requests. The hashicorp/aws provider currently sits at version 6.28.0, and it’s what turns a block like resource "aws_instance" into an actual RunInstances call against the EC2 API. Providers version independently from Terraform’s own CLI, which is currently on the 1.15 line, with 1.15.7 as the latest stable release. A config written last year against provider 5.x needs a review pass before it runs cleanly against 6.x.

AWS teams keep choosing Terraform for a few concrete reasons. It’s cloud-agnostic, so the same skills and sometimes the same modules carry over to Azure or GCP work, unlike AWS-only tools such as CloudFormation or the CDK. It has the largest module ecosystem of any infrastructure-as-code tool, so a working VPC or RDS module usually already exists on the Terraform Registry before you’d finish writing one from scratch. And its state file model, however much it trips up beginners, gives Terraform something CloudFormation’s server-side state doesn’t: a local, inspectable record of exactly what Terraform believes exists.

The typical adoption path looks the same at most companies. Someone provisions a handful of resources by hand first, hits the wall where nobody remembers exactly how the staging database was configured, and reaches for Terraform to write down what already exists. That’s a legitimate way to start, and terraform import exists specifically for it, but this tutorial takes the cleaner route of building everything from an empty AWS account so every resource traces back to a line of code from the start.

The licensing story matters too. HashiCorp moved Terraform from the open-source Mozilla Public License to the Business Source License in August 2023, a change that restricts competing commercial use but affects almost nothing for teams just running their own infrastructure. The fork that resulted, OpenTofu, stayed on MPL 2.0, joined the Linux Foundation, and has kept shipping its own releases, currently in the 1.12.x line. Every command in this tutorial works the same if you swap the terraform binary for tofu, since OpenTofu tracks Terraform’s HCL syntax and provider ecosystem closely by design.

None of this costs much to run at small scale. Terraform itself is free no matter which license track you’re on. The AWS resources you create during this tutorial are what cost money, and they’re cheap: a single t3.micro instance runs $0.0104 an hour in US East (N. Virginia), and S3 Standard storage for a state file measured in kilobytes runs $0.023 per GB per month. Leave the EC2 instance running for a full month and you’re looking at roughly $7.59. Tear it down at the end of this tutorial, which Step 12 covers, and you’ll spend closer to a few cents.

Prerequisites: Tools, Accounts, and Exact Versions You Need

Get these in place before Step 1. Skipping any of them turns a 90-minute tutorial into an afternoon spent debugging authentication errors instead of learning Terraform.

ToolMinimum VersionWhat It DoesCheck Command
Terraform1.15.xReads your .tf files and manages AWS resourcesterraform version
AWS CLIv2Authenticates to AWS and stores the credentials Terraform reusesaws --version
AWS accountAny, billing enabledA brand-new free-tier account works fine for this tutorialConsole login
Text editorAnyVS Code with the HashiCorp Terraform extension adds syntax highlighting and validationN/A

You’ll also need an IAM identity with enough permissions to create EC2 instances, security groups, and S3 buckets. Step 3 walks through building a scoped-down IAM user instead of reusing your root account, which you should never do for anything beyond the very first login. If you already have AWS CLI credentials configured from other work, that’s fine. Just confirm they point at an account you’re comfortable spinning up billable resources in.

One more decision to make upfront: HCP Terraform, formerly known as Terraform Cloud, offers a free tier for up to 5 users that handles remote state and locking for you, which removes the need for the S3 backend this tutorial builds by hand in Step 10. This tutorial sticks with a self-managed S3 backend because it’s the more common setup in existing AWS-only shops, and it teaches you what HCP Terraform automates away. If you’d rather skip straight to the managed option, HashiCorp’s own getting-started tutorial covers that path.

Everything here assumes a bash-compatible terminal, whether that’s native on Linux and macOS or WSL on Windows. Every command is written for that shell.

Set up an AWS Budget alert before Step 1, not after. From the Billing console, a zero-spend or low-dollar budget with an email alert costs nothing to configure and catches the one scenario this tutorial can’t fully protect you from: forgetting to run terraform destroy in Step 12 and leaving a t3.micro running for a month. At $0.0104 an hour that’s not a disaster, but it’s still money spent on nothing, and the same budget alert keeps paying off on every AWS project after this one.

Step 1: Install Terraform 1.15 on Your Machine

HashiCorp distributes Terraform as a single static binary with no runtime dependencies, which keeps installation short regardless of OS. The fastest path on macOS is HashiCorp’s own Homebrew tap.

brew tap hashicorp/tap
brew install hashicorp/tap/terraform

On Debian or Ubuntu, add HashiCorp’s apt repository, then install through the normal package manager:

wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform

On Windows, download the matching zip from HashiCorp’s install page and add the extracted terraform.exe to your PATH, or run choco install terraform if you have Chocolatey. Whichever path you take, confirm the install:

terraform version
Terraform v1.15.7
on linux_amd64

If a package manager index hasn’t caught up yet and you land on something older, that’s not a dealbreaker. Terraform’s 1.14 line is still receiving security patches as of April 2026, so a 1.14.x install runs this tutorial fine, just double-check Step 10’s state-locking syntax against your version since that behavior firmed up around the 1.10 release.

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 solves both problems at once. Install AWS CLI v2 for your platform, then run the interactive configuration wizard.

aws configure

It prompts for four values in sequence: an access key ID, a secret access key, a default region, and a default output format.

AWS Access Key ID [None]: AKIAEXAMPLE7Q3ZKD2P
AWS Secret Access Key [None]: ****************************************
Default region name [None]: us-east-1
Default output format [None]: json

Don’t paste your root account’s access keys here. Step 3 creates a scoped-down IAM user specifically for Terraform, and those are the credentials that belong in this wizard. If you’re only test-driving this tutorial and already have a working non-root credential, reuse it and move on. Confirm everything connects before continuing:

aws sts get-caller-identity

A working setup echoes back your account ID, user ARN, and user ID as JSON. An error here means Terraform won’t authenticate either, so it’s worth resolving before Step 6.

Step 3: Create a Least-Privilege IAM User for Terraform

Handing Terraform your AWS account’s root keys works, technically, the same way leaving your front door unlocked technically works. Instead, create an IAM user scoped to only what this tutorial’s resources need: EC2, security groups, and the S3 bucket you’ll add in Step 10.

In the IAM console, or via the CLI, create a user named something like terraform-deploy, then attach a customer-managed policy instead of reaching for broad AWS-managed policies like AdministratorAccess.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "EC2Access",
      "Effect": "Allow",
      "Action": [
        "ec2:RunInstances",
        "ec2:TerminateInstances",
        "ec2:DescribeInstances",
        "ec2:CreateTags",
        "ec2:CreateSecurityGroup",
        "ec2:DeleteSecurityGroup",
        "ec2:DescribeSecurityGroups",
        "ec2:AuthorizeSecurityGroupIngress",
        "ec2:AuthorizeSecurityGroupEgress",
        "ec2:RevokeSecurityGroupIngress",
        "ec2:DescribeImages",
        "ec2:DescribeAvailabilityZones"
      ],
      "Resource": "*"
    },
    {
      "Sid": "StateBucketAccess",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::ti-terraform-demo-state-*",
        "arn:aws:s3:::ti-terraform-demo-state-*/*"
      ]
    }
  ]
}

Generate an access key for this user under Security credentials, then rerun aws configure with the new key pair. This policy is intentionally narrow. It won’t let Terraform touch RDS, Lambda, or IAM itself, which is exactly the point: if these credentials ever leak, the blast radius stops at a handful of EC2 resources and one S3 prefix. AWS’s own IAM documentation covers the full permission reference if you extend this policy later.

Widening this policy later is easy, narrowing it after the fact is the part teams tend to skip. Start scoped to exactly what a given project touches, then add actions one denied API call at a time. AWS’s IAM policy simulator lets you test a policy against a specific action before you attach it for real, which beats discovering a gap in the middle of an on-call incident.

Step 4: Scaffold Your First Terraform Project

Terraform doesn’t care how you split files, it reads every .tf file in a directory as one combined configuration. Splitting by purpose is a convention that keeps larger projects readable. Create a project directory with this layout:

mkdir terraform-aws-demo && cd terraform-aws-demo
touch provider.tf main.tf variables.tf outputs.tf terraform.tfvars user_data.sh

Each file has one job: provider.tf configures the AWS connection, main.tf declares resources, variables.tf declares inputs, outputs.tf declares what Terraform should print after apply, and terraform.tfvars holds the actual values for this specific deployment. That last split matters more than it looks: variables define the shape, tfvars fills it in, and keeping them separate is what makes the module refactor in Step 12 painless.

Add a .gitignore before you write a single resource block, since Terraform generates files you should never commit:

.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl
terraform.tfvars

That last line is a judgment call. Some teams commit .terraform.lock.hcl for reproducible provider versions across a team, which is generally the right call for shared repositories. This tutorial ignores it for simplicity, but treat that as a starting point, not a rule.

Step 5: Configure the AWS Provider Block

Open provider.tf and declare two things: which provider Terraform should download, and which region it should target.

terraform {
  required_version = ">= 1.15.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.28"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

The required_providers block pins the AWS provider to the 6.28 line using the ~> operator, which allows patch and minor updates but blocks an unannounced jump to 7.x. Skipping this pin is one of the pitfalls covered further down, an unpinned provider can change behavior under you with no warning. required_version does the same job for Terraform’s own CLI, refusing to run if someone’s local install is older than 1.15.0.

Notice the provider block references var.aws_region instead of a hardcoded string. That variable doesn’t exist yet, it’s coming in Step 11, but Terraform doesn’t validate references until you run a command, so it’s safe to write forward-looking code like this as you go.

Step 6: Run terraform init and Read the Lock File

Every Terraform project starts with init. It downloads the providers named in required_providers, sets up the local .terraform directory, and writes a lock file recording the exact provider version and checksums it resolved.

terraform init
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 6.28"...
- Installing hashicorp/aws v6.28.0...
- Installed hashicorp/aws v6.28.0 (signed by HashiCorp)

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure.

The generated .terraform.lock.hcl is worth opening once, even if you never edit it by hand. It records the provider’s exact version and a set of cryptographic hashes Terraform checks on every future init, which is what stops a compromised provider mirror from silently swapping in a different binary. If init fails here, it’s almost always credentials (revisit Step 2) or a typo in the provider source string.

Two more commands are worth running before every plan, and they cost nothing since neither touches AWS. terraform fmt rewrites your files into HashiCorp’s canonical formatting, two-space indents, aligned equals signs, so diffs stay clean across a team that doesn’t argue about style. terraform validate checks that the configuration is internally consistent, catching typos in argument names or references to variables that don’t exist, before Terraform ever reaches out to AWS.

terraform fmt
terraform validate
Success! The configuration is valid.

Run both as a habit before every plan, and add them as the first two steps of any CI pipeline, which is exactly where the GitHub Actions example in the advanced tips section puts them.

Step 7: Write Your First Resource, an EC2 Web Server

This is where the tutorial stops being abstract. Open main.tf and declare three things: a data source that looks up the latest Amazon Linux AMI, a security group, and the EC2 instance itself.

data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }
}

resource "aws_security_group" "web" {
  name        = "ti-terraform-demo-sg"
  description = "Allow HTTP and restricted SSH"

  ingress {
    description = "HTTP from anywhere"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "SSH from your IP only"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.allowed_ssh_cidr]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_instance" "web" {
  ami                    = data.aws_ami.amazon_linux.id
  instance_type          = var.instance_type
  vpc_security_group_ids = [aws_security_group.web.id]
  user_data              = file("${path.module}/user_data.sh")

  tags = {
    Name      = "ti-terraform-demo"
    ManagedBy = "terraform"
  }
}

The data "aws_ami" block matters more than it looks. It’s tempting to hardcode an AMI ID straight from the console, but AMI IDs are region-specific and get deprecated over time, so a hardcoded value quietly breaks the moment you change regions or AWS retires that image. Looking it up dynamically means this configuration keeps working without maintenance.

Now fill in user_data.sh, the boot script EC2 runs as root the first time the instance starts:

#!/bin/bash
dnf install -y nginx
systemctl enable nginx
systemctl start nginx
echo "Deployed by Terraform" | tee /usr/share/nginx/html/index.html

Referencing this as a separate file via file("${path.module}/user_data.sh") instead of an inline heredoc keeps main.tf readable and lets your editor apply proper shell syntax highlighting to the script itself.

Step 8: Preview Changes with terraform plan

Before Terraform touches AWS, run plan. It calculates what would happen without actually doing it, which is the single most important habit this tutorial can teach you.

terraform plan
Terraform will perform the following actions:

  # aws_security_group.web will be created
  + resource "aws_security_group" "web" {
      + name        = "ti-terraform-demo-sg"
      + description = "Allow HTTP and restricted SSH"
      # (8 unchanged attributes hidden)
    }

  # aws_instance.web will be created
  + resource "aws_instance" "web" {
      + ami                    = "ami-0abcd1234efgh5678"
      + instance_type          = "t3.micro"
      + tags                   = {
          + "ManagedBy" = "terraform"
          + "Name"      = "ti-terraform-demo"
        }
      + vpc_security_group_ids = (known after apply)
      # (30 unchanged attributes hidden)
    }

Plan: 2 to add, 0 to change, 0 to destroy.

Read this output like a diff, because that’s exactly what it is. A + prefix means creation, ~ means an in-place update, and - means destruction. The line vpc_security_group_ids = (known after apply) shows up because that value depends on a resource that doesn’t exist yet, Terraform can’t know the security group’s ID until it actually creates it. That’s normal for a first apply and nothing to worry about.

Step 9: Apply, Verify, and Read the State File

With the plan looking right, apply it. Terraform shows the same plan again and asks for explicit confirmation before touching anything.

terraform apply

Type yes at the prompt. Creating the security group takes a few seconds, while the EC2 instance and its boot script take closer to a minute before nginx actually answers requests.

aws_security_group.web: Creating...
aws_security_group.web: Creation complete after 2s
aws_instance.web: Creating...
aws_instance.web: Still creating... [10s elapsed]
aws_instance.web: Creation complete after 24s

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Outputs:

instance_public_ip = "34.201.118.47"

Give the instance another minute for the boot script to finish, then confirm the whole chain actually worked:

curl http://$(terraform output -raw instance_public_ip)
Deployed by Terraform

That single line proves Terraform provisioned the instance, attached the right security group, and the user_data script ran successfully. Behind the scenes, Terraform recorded everything it created in terraform.tfstate, a JSON file that’s now the single source of truth for what this configuration owns. Run terraform state list any time to see every resource Terraform is currently tracking.

When a value doesn’t look right, terraform console opens an interactive prompt for evaluating expressions against the current state without running a plan or apply:

terraform console
> aws_instance.web.public_ip
"34.201.118.47"
> aws_security_group.web.ingress
[...]

It’s a quick way to check exactly what Terraform believes an attribute or expression resolves to, especially useful once configurations grow past a few resources and references start chaining through multiple modules.

Step 10: Move State to a Remote S3 Backend With Native Locking

A local terraform.tfstate file works for a solo tutorial and breaks the moment a second person touches the project. Two people running apply against two different local state files will fight AWS for the same resources. The fix is a remote backend, and as of Terraform 1.10, S3 can handle both storage and locking on its own without a separate DynamoDB table.

Create the state bucket first, outside of this configuration. You can’t have a Terraform config reference a bucket as its own backend before that bucket exists, so this one step happens through the CLI directly:

aws s3api create-bucket --bucket ti-terraform-demo-state-8841 --region us-east-1
aws s3api put-bucket-versioning --bucket ti-terraform-demo-state-8841 --versioning-configuration Status=Enabled

S3 bucket names are globally unique across all of AWS, so swap the 8841 suffix for something only you would pick. Versioning matters here too: it means a corrupted or accidentally-deleted state file is recoverable instead of gone.

Add a backend block to provider.tf, inside the existing terraform {} block:

terraform {
  required_version = ">= 1.15.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.28"
    }
  }

  backend "s3" {
    bucket       = "ti-terraform-demo-state-8841"
    key          = "web-demo/terraform.tfstate"
    region       = "us-east-1"
    use_lockfile = true
    encrypt      = true
  }
}

The use_lockfile = true line is the part worth pausing on. Before Terraform 1.10, S3-backed state locking required standing up a separate DynamoDB table to hold lock records, an extra resource that plenty of teams forgot to provision correctly. Native locking writes a lock file directly alongside the state object in S3 instead, which means one less moving part for the exact same safety guarantee. HashiCorp’s S3 backend documentation covers the full configuration surface if you need cross-account access or a customer-managed KMS key later.

Run init again to migrate:

terraform init -migrate-state
Initializing the backend...
Do you want to copy existing state to the new backend?
  Enter "yes" to copy and "no" to start with an empty state.

  Enter a value: yes

Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.

From here on, every teammate who clones this project and runs terraform init reads and writes the same S3-hosted state, and the native lock stops two applies from racing each other. Try it: start an apply in one terminal, then run plan against the same config in a second terminal before the first one finishes.

Error: Error acquiring the state lock

Lock Info:
  ID:        7d2f9a1c-4b3e-4a91-9c5d-2e8f6a0b1c3d
  Path:      ti-terraform-demo-state-8841/web-demo/terraform.tfstate
  Operation: OperationTypeApply
  Who:       ubuntu@ip-10-0-1-42
  Created:   2026-06-26 14:12:03 UTC

That’s the lock doing its job. The second command waits, or fails outright depending on your Terraform version, instead of silently corrupting state by writing over the first run. If a lock ever gets stuck, because a process crashed or a CI job got killed mid-apply, confirm nothing is actually running against that state, then clear it with terraform force-unlock and the lock ID from the error message.

Step 11: Add Variables, Outputs, and a terraform.tfvars File

Steps 5 and 7 referenced var.aws_region, var.instance_type, and var.allowed_ssh_cidr before they existed. Define them now in variables.tf:

variable "aws_region" {
  description = "AWS region to deploy into"
  type        = string
  default     = "us-east-1"
}

variable "instance_type" {
  description = "EC2 instance type for the web server"
  type        = string
  default     = "t3.micro"
}

variable "allowed_ssh_cidr" {
  description = "CIDR block allowed to reach the instance over SSH"
  type        = string
}

Notice allowed_ssh_cidr has no default. That’s deliberate: a wide-open SSH rule is one of the pitfalls covered next, and leaving this variable required forces you to think about the value instead of inheriting a careless default. Supply it in terraform.tfvars:

aws_region       = "us-east-1"
instance_type    = "t3.micro"
allowed_ssh_cidr = "203.0.113.10/32"

Swap 203.0.113.10 for your own public IP address, followed by /32 to scope it to exactly one address. Terraform automatically loads terraform.tfvars without any extra flag, which is exactly why it belongs in .gitignore from Step 4, since this file is where region and access decisions, and sometimes secrets, tend to accumulate.

None of this project’s three variables need it, but it’s worth knowing variable blocks accept a sensitive = true argument for anything that actually is a secret, a database password or API token passed in as a Terraform variable. Marking a variable sensitive redacts it from plan and apply output, replacing the value with (sensitive value) so it doesn’t end up in a CI log by accident. It doesn’t encrypt the value inside the state file itself, which is the real reason Step 10’s bucket-locking advanced tip matters as much as it does.

Finally, add outputs.tf so Terraform prints the useful bits after every apply instead of making you dig through the state file:

output "instance_public_ip" {
  description = "Public IP address of the web server"
  value       = aws_instance.web.public_ip
}

output "instance_id" {
  description = "EC2 instance ID"
  value       = aws_instance.web.id
}

Step 12: Organize Modules and Tear Down Safely

A single flat configuration is fine for one web server. It stops being fine the moment you need a second one, or want to reuse this pattern in another project. Modules solve that by packaging a set of resources behind a single interface. Restructure the project like this:

terraform-aws-demo/
  main.tf
  variables.tf
  outputs.tf
  provider.tf
  terraform.tfvars
  modules/
    web-server/
      main.tf
      variables.tf
      outputs.tf
      user_data.sh

Move the data, aws_security_group, and aws_instance blocks from Step 7 into modules/web-server/main.tf unchanged, along with matching variable declarations for instance_type and allowed_ssh_cidr in that module’s own variables.tf. The root main.tf then shrinks to a single module call:

module "web_server" {
  source            = "./modules/web-server"
  instance_type     = var.instance_type
  allowed_ssh_cidr  = var.allowed_ssh_cidr
}

output "instance_public_ip" {
  value = module.web_server.instance_public_ip
}

Run terraform init once more so Terraform registers the local module, then terraform plan should report zero changes, since you’ve reorganized the code without changing what it describes. That’s the real test of a clean refactor: the infrastructure doesn’t move, only the files do.

A local module like this one is the right starting point, but it’s worth knowing the Terraform Registry hosts thousands of published modules for exactly this kind of pattern, VPCs, RDS instances, EKS clusters, already written and versioned by AWS itself and by the community. Once ./modules/web-server feels solid, compare it against the registry’s published equivalents. Reinventing a module that already handles edge cases you haven’t hit yet rarely pays off, but writing your own first, as this tutorial does, is still the fastest way to actually understand what a module needs to expose.

When you’re done experimenting, tear everything down rather than leaving a t3.micro billing you by the hour indefinitely:

terraform destroy
Plan: 0 to add, 0 to change, 2 to destroy.

Do you really want to destroy all resources?
  Enter a value: yes

Destroy complete! Resources: 2 destroyed.

The S3 state bucket itself survives destroy since it was never part of this configuration’s state. Delete it by hand with aws s3 rb s3://ti-terraform-demo-state-8841 --force once you’re fully done, or leave it for your next project.

5 Common Pitfalls That Break Terraform AWS Deployments

Most Terraform AWS problems trace back to one of these habits. Catching them early saves the debugging session later.

  • Committing state files or tfvars to git. A Terraform state file can contain resource attributes in plain text, including anything you passed in as a sensitive variable. Keep *.tfstate and terraform.tfvars in .gitignore from day one, as Step 4 does here.
  • Skipping a remote backend until it’s painful. Local state works right up until a laptop dies or a second person joins the project. Set up the S3 backend from Step 10 before either happens, not after.
  • Hardcoding AMI IDs. An ID copied from the console today is region-specific and will eventually get deprecated. Use a data "aws_ami" lookup, as Step 7 does, and this problem disappears entirely.
  • Running apply with -auto-approve out of habit. Skipping the confirmation prompt is fine for CI pipelines with their own review gates. Doing it on your own machine trains you to stop reading plans, which is exactly the habit that lets a destructive change slip through unnoticed.
  • Leaving SSH open to 0.0.0.0/0 “just for testing.” It’s the single most common misconfiguration security scanners flag on new AWS accounts. Step 7’s security group scopes SSH to one CIDR block for exactly this reason.
  • Leaving the provider version unpinned. Without the ~> constraint from Step 5, a fresh terraform init six months from now can pull a new major provider version with breaking changes, and your plan output turns into a wall of unexpected diffs.
  • One giant state file for an entire organization. Cramming every team’s resources into a single configuration means one broken resource blocks everyone’s apply, and a single mistake carries the blast radius of the whole account. Split state by team or by service boundary well before that becomes a daily problem.

Troubleshooting Guide: 8 Terraform AWS Errors and Fixes

These are the errors most likely to show up while working through this tutorial, or any Terraform AWS project. Match the message, then apply the fix.

Error MessageLikely CauseFix
no valid credential sources foundAWS CLI credentials missing, expired, or misconfiguredRerun aws configure and confirm with aws sts get-caller-identity
UnauthorizedOperation on ec2:RunInstancesIAM user lacks the required EC2 permissionCompare your policy against Step 3’s example and add the missing action
Error acquiring the state lockAnother apply is running, or a previous run crashed mid-applyWait for the other run to finish, or confirm nothing is active and run terraform force-unlock <LOCK_ID>
InvalidAMIID.NotFoundAMI ID doesn’t exist in the target regionReplace the hardcoded ID with the data "aws_ami" lookup from Step 7
Error: CycleTwo resources reference each other, creating a circular dependencyBreak the cycle by removing one implicit reference or restructuring the resources
Backend configuration changedYou edited the backend block without migratingRun terraform init -migrate-state as shown in Step 10
Provider produced inconsistent final planUsually a provider bug, or a value changing outside Terraform mid-applyRerun terraform apply, and if it repeats, upgrade the AWS provider version
Plan shows unexpected replacement of a resourceYou changed an attribute that forces recreation, like availability_zoneCheck the plan for the “# forces replacement” comment on the specific attribute
BucketAlreadyExists when creating the state bucketS3 bucket names are unique across every AWS account globally, not just yoursPick a longer, more specific suffix, as Step 10’s example does with a random number
Saved plan is stale, or apply doesn’t match the reviewed planState changed between your plan and apply, usually a teammate applying in betweenRerun terraform plan immediately before apply, or save a plan file with -out and apply that exact file

One pattern covers more of these than it looks: read the error’s first line literally before assuming something exotic broke. “No valid credential sources” means exactly that, and “UnauthorizedOperation” always names the specific action that failed in the surrounding message, which tells you exactly which IAM permission to add.

Advanced Tips: Workspaces, Drift Detection, and CI/CD

Once the core workflow feels natural, these practices are what separate a tutorial project from something a team can run in production.

Managing Dev, Staging, and Production with Workspaces

Right now this project only knows one environment. Terraform workspaces let the same configuration manage several, each with its own state file, without duplicating a single line of HCL. Create one for staging alongside the default:

terraform workspace new staging
terraform workspace list
  default
* staging

The asterisk marks whichever workspace is active. Reference terraform.workspace inside your config to change behavior per environment, most commonly for naming so staging and production resources never collide:

resource "aws_instance" "web" {
  # ...
  tags = {
    Name = "ti-terraform-demo-${terraform.workspace}"
  }
}

Switch back with terraform workspace select default, and every plan or apply from that point only touches the active workspace’s state. That said, workspaces share the same backend configuration and the same AWS credentials, so a mistake in the wrong workspace still hits real infrastructure. Plenty of teams outgrow workspaces for exactly that reason and switch to fully separate directories, and separate state files, per environment instead. Treat workspaces as the right tool for a handful of similar, low-risk environments, not as an isolation boundary between staging and a production account that actually matters.

Catching Drift Before It Bites You

Drift happens when someone changes a Terraform-managed resource outside of Terraform, through the console, a support ticket, or another script. The next plan will show it as a diff, even though you didn’t touch any code:

  # aws_instance.web has been changed
  ~ resource "aws_instance" "web" {
      ~ instance_type = "t3.micro" -> "t3.small"
        # (30 unchanged attributes hidden)
    }

Run terraform plan on a schedule, even against infrastructure nobody expects to change, and treat any unplanned diff as a signal worth investigating before it becomes a surprise during someone else’s apply.

Wiring Terraform Into GitHub Actions

Moving from a laptop workflow to CI is mostly about running the same four commands somewhere reviewable:

name: terraform
on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  terraform:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.15.7
      - run: terraform fmt -check
      - run: terraform init
      - run: terraform plan
      - if: github.ref == 'refs/heads/main'
        run: terraform apply -auto-approve

The plan step runs on every pull request so reviewers see the exact diff before merging, and only pushes to main trigger the real apply. Use OIDC federation to hand this workflow temporary AWS credentials instead of storing long-lived access keys as repository secrets, which removes an entire category of leaked-credential risk.

Locking Down the State Bucket

The S3 bucket from Step 10 holds a full copy of every attribute Terraform tracks, which for some resource types can include values you’d rather not leave world-readable. Block public access on the bucket explicitly rather than trusting the default, since bucket policies and ACLs are exactly the kind of setting a future teammate might loosen without realizing what’s at stake.

aws s3api put-public-access-block \
  --bucket ti-terraform-demo-state-8841 \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

Pair that with the encrypt = true line already in Step 10’s backend block, which enables server-side encryption on the state object itself, and you’ve covered the two most common state-bucket findings in an AWS security review.

Terraform vs Pulumi vs OpenTofu vs CloudFormation vs CDK in 2026

Terraform AWS work doesn’t happen in a vacuum. Here’s how the tool this tutorial uses compares to the other infrastructure-as-code options AWS teams reach for in 2026.

ToolLanguageState ManagementAWS-OnlyLicense
TerraformHCL (declarative)Local or remote backend (S3, HCP Terraform)No, multi-cloudBusiness Source License
OpenTofuHCL, Terraform-compatibleSame backend model as TerraformNo, multi-cloudMPL 2.0, open source
PulumiPython, TypeScript, Go, C#, JavaPulumi Cloud or self-managed backendNo, multi-cloudApache 2.0 core, paid SaaS tiers
AWS CDKTypeScript, Python, Java, C#, GoCompiles to CloudFormation stacksYes, AWS-onlyApache 2.0
CloudFormationYAML or JSONAWS-managed, no separate state fileYes, AWS-onlyProprietary AWS service, free

The practical decision usually comes down to two questions. If your infrastructure is entirely AWS and your team already thinks in TypeScript or Python, the CDK removes HCL from the equation and compiles down to CloudFormation, inheriting its rollback behavior. If you manage more than one cloud, or want access to Terraform’s much larger module registry, Terraform or OpenTofu stays the more portable choice. Pulumi splits the difference: real programming languages like the CDK, but multi-cloud like Terraform, at the cost of a smaller module ecosystem than either.

OpenTofu deserves a specific note since it’s the newest name in that table. For everything this tutorial covers, installing, writing HCL, running plan and apply against AWS, OpenTofu behaves identically to Terraform because it forked from the same codebase and tracks the same provider ecosystem. Where it diverges is HashiCorp’s commercial layer: HCP Terraform’s hosted run environment and Sentinel policy-as-code don’t exist in OpenTofu, since those were never open source to begin with.

Frequently Asked Questions

Is Terraform free to use with AWS?

Yes. Terraform’s CLI costs nothing regardless of license track, and you only pay for the AWS resources it creates on your behalf. HCP Terraform’s paid tiers add team features like policy enforcement and a hosted run environment, but nothing in this tutorial requires them.

Should I use Terraform or the AWS CDK?

Reach for the CDK if your infrastructure is AWS-only and your team already codes in TypeScript, Python, or a similar language. Reach for Terraform if you manage more than one cloud, or want the larger module ecosystem covered in the comparison table above.

What’s the difference between terraform plan and terraform apply?

plan is a dry run. It calculates and displays what would change without touching AWS. apply executes that same plan against your real infrastructure, after asking for confirmation.

Do I still need DynamoDB for Terraform state locking on AWS?

No, not since Terraform 1.10 added native S3 locking through the use_lockfile setting covered in Step 10. DynamoDB-based locking still works if you’re running an older Terraform version or already have that setup in place, but new projects don’t need to provision it.

Is OpenTofu a safe replacement for Terraform?

For the CLI workflow this tutorial covers, yes. OpenTofu is a drop-in replacement for Terraform’s open-source functionality, HCL syntax and all. What doesn’t carry over is HashiCorp’s commercial layer, since HCP Terraform-specific features were never part of the open-source codebase OpenTofu forked from.

Why did my terraform apply fail with a credentials error?

Almost always expired, missing, or misconfigured AWS CLI credentials. Run aws sts get-caller-identity to confirm what Terraform will actually authenticate as, and revisit Step 2 if that command itself fails.

How much will this tutorial’s AWS resources cost?

A few cents if you tear everything down with terraform destroy once you’re done, as Step 12 covers. The t3.micro instance bills at $0.0104 an hour, and the S3 state file is a few kilobytes, well under a cent a month on its own.

Can Terraform manage an AWS resource I created manually?

Yes, through terraform import, or the newer import block in a .tf file. Either approach adopts an existing resource into Terraform’s state without recreating it, as long as you also write a matching resource block describing its current configuration.

Can one Terraform project manage multiple AWS regions or accounts?

Yes, through provider aliases. Declaring a second provider "aws" block with an alias argument and a different region or assumed role lets a single configuration target more than one region or account, and you reference the alternate provider explicitly on any resource that needs it. This tutorial sticks to one provider block and one region to keep the core workflow clear.

How do I upgrade Terraform or the AWS provider without breaking my config?

Bump the version constraint in provider.tf one minor version at a time, run terraform init -upgrade, then read the plan output carefully before applying. HashiCorp and the AWS provider both publish changelogs listing breaking changes per release, and a plan that suddenly wants to replace resources you didn’t touch is the clearest sign a version bump changed something under the hood.

What’s actually inside a Terraform state file?

A JSON snapshot of every attribute Terraform knows about each resource it manages, not just the arguments you wrote in your .tf files. For an EC2 instance, that includes the ID, the assigned public and private IPs, the exact AMI used, and dozens of other fields AWS fills in at creation time. Treat it as sensitive: it’s the reason Step 4 puts *.tfstate in .gitignore and Step 10 moves it to an access-controlled S3 bucket instead of leaving it on a laptop.

For the broader trends shaping how teams manage AWS spend and infrastructure this year, our cloud computing coverage tracks what’s changing month to month.

Elias Virtanen

Elias Virtanen

Cybersecurity Analyst

Elias Virtanen is the Cybersecurity Analyst at Tech Insider, bringing hands-on expertise from his background in penetration testing and security consulting. He previously worked as a security researcher at F-Secure in Helsinki, where he focused on threat intelligence and vulnerability disclosure. Elias covers ransomware trends, zero-trust architecture, and the evolving regulatory landscape including NIS2 and the EU Cyber Resilience Act. He holds a CISSP certification and an MSc in Information Security from Aalto University.

View all articles