VOOZH about

URL: https://tech-insider.org/terraform-aws-tutorial-deploy-infrastructure-2026/

⇱ Terraform AWS: Deploy Infrastructure in 15 Minutes [2026]


Skip to content
March 22, 2026
24 min read

Terraform has become the industry standard for managing cloud infrastructure as code, and AWS remains the world’s largest cloud platform. Combining the two gives you reproducible, version-controlled infrastructure that scales from a single EC2 instance to a multi-region production environment. This Terraform AWS tutorial walks you through every step – from installation to deploying a complete three-tier web application – with real code you can copy, paste, and extend for your own projects.

Whether you are a developer who has been clicking through the AWS Console, a DevOps engineer migrating manual runbooks, or a platform team standardizing on Infrastructure as Code (IaC), this guide gives you the practical foundation you need. Every example uses Terraform 1.12 and the AWS Provider 5.x (current as of March 2026), and every code block has been tested against a live AWS account.

Prerequisites and Environment Setup for Terraform AWS

Before you write your first line of HCL, you need four things installed and configured. The table below lists each prerequisite, the minimum version this tutorial requires, and why it matters.

ToolMinimum VersionPurposeInstall Command
Terraform CLI1.12.xCore IaC engine that reads HCL and calls AWS APIsbrew install hashicorp/tap/terraform
AWS CLI v22.22.xConfigures credentials and allows manual verificationbrew install awscli
An AWS AccountFree Tier eligibleTarget cloud environmentSign up at aws.amazon.com
A code editorVS Code 1.96+HCL syntax highlighting and Terraform extensioncode.visualstudio.com
Git2.44+Version control for your Terraform configurationsbrew install git

AWS credentials: Run aws configure and enter your Access Key ID, Secret Access Key, default region (we use us-east-1), and output format (json). Terraform reads these credentials automatically from ~/.aws/credentials. For production workloads, use IAM Identity Center (SSO) or environment variables instead of long-lived keys.

Terraform installation verification: After installing, confirm everything works by running the version check command.

$ terraform version
Terraform v1.12.1
on linux_amd64
+ provider registry.terraform.io/hashicorp/aws v5.82.0

$ aws sts get-caller-identity
{
 "UserId": "AIDAEXAMPLEID",
 "Account": "123456789012",
 "Arn": "arn:aws:iam::123456789012:user/terraform-user"
}

If terraform version returns a version below 1.9, upgrade before continuing. Features like terraform test (GA since 1.6) and Stacks (GA in 2025) require recent releases. The AWS provider should be 5.x; version 4.x lacks support for newer services like Amazon Bedrock and EKS Pod Identity.

Step 1: Initialize Your First Terraform AWS Project

Every Terraform project starts with a directory and a provider configuration. Create a new folder, add a main.tf file, and declare the AWS provider. This is where you tell Terraform which cloud to talk to, which region to target, and which provider version to lock.

# main.tf — Provider configuration
terraform {
 required_version = ">= 1.12.0"

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

provider "aws" {
 region = var.aws_region

 default_tags {
 tags = {
 Project = "terraform-tutorial"
 Environment = var.environment
 ManagedBy = "terraform"
 }
 }
}

# variables.tf — Input variables
variable "aws_region" {
 description = "AWS region for all resources"
 type = string
 default = "us-east-1"
}

variable "environment" {
 description = "Deployment environment"
 type = string
 default = "dev"
}

Run terraform init from this directory. Terraform downloads the AWS provider plugin into a hidden .terraform folder and creates a .terraform.lock.hcl file that pins the exact provider version. Commit this lock file to Git – it ensures every team member and CI pipeline uses identical provider binaries.

$ terraform init

Initializing the backend...

Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.82"...
- Installing hashicorp/aws v5.82.0...
- Installed hashicorp/aws v5.82.0 (signed by HashiCorp)

Terraform has been successfully initialized!

The default_tags block is a best practice introduced in AWS Provider 3.38 that automatically applies tags to every resource. This saves you from repeating tag blocks and ensures consistent cost allocation and resource identification across your entire infrastructure. In a production environment, you would also add a CostCenter and Owner tag for FinOps tracking.

Step 2: Create a VPC with Public and Private Subnets

Networking is the foundation of any AWS deployment. A properly designed VPC isolates your workloads, controls traffic flow, and meets compliance requirements. The configuration below creates a VPC with two public subnets (for load balancers), two private subnets (for application servers), and the routing rules that connect them to the internet.

# network.tf — VPC and Subnets
resource "aws_vpc" "main" {
 cidr_block = "10.0.0.0/16"
 enable_dns_support = true
 enable_dns_hostnames = true

 tags = {
 Name = "${var.environment}-vpc"
 }
}

resource "aws_subnet" "public" {
 count = 2
 vpc_id = aws_vpc.main.id
 cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index)
 availability_zone = data.aws_availability_zones.available.names[count.index]
 map_public_ip_on_launch = true

 tags = {
 Name = "${var.environment}-public-${count.index + 1}"
 Tier = "public"
 }
}

resource "aws_subnet" "private" {
 count = 2
 vpc_id = aws_vpc.main.id
 cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index + 10)
 availability_zone = data.aws_availability_zones.available.names[count.index]

 tags = {
 Name = "${var.environment}-private-${count.index + 1}"
 Tier = "private"
 }
}

data "aws_availability_zones" "available" {
 state = "available"
}

resource "aws_internet_gateway" "main" {
 vpc_id = aws_vpc.main.id
 tags = { Name = "${var.environment}-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.environment}-public-rt" }
}

resource "aws_route_table_association" "public" {
 count = 2
 subnet_id = aws_subnet.public[count.index].id
 route_table_id = aws_route_table.public.id
}

# NAT Gateway for private subnet internet access
resource "aws_eip" "nat" {
 domain = "vpc"
 tags = { Name = "${var.environment}-nat-eip" }
}

resource "aws_nat_gateway" "main" {
 allocation_id = aws_eip.nat.id
 subnet_id = aws_subnet.public[0].id
 tags = { Name = "${var.environment}-nat" }
}

resource "aws_route_table" "private" {
 vpc_id = aws_vpc.main.id

 route {
 cidr_block = "0.0.0.0/0"
 nat_gateway_id = aws_nat_gateway.main.id
 }

 tags = { Name = "${var.environment}-private-rt" }
}

resource "aws_route_table_association" "private" {
 count = 2
 subnet_id = aws_subnet.private[count.index].id
 route_table_id = aws_route_table.private.id
}

The cidrsubnet function automatically calculates non-overlapping CIDR blocks from the VPC’s /16 range. Public subnets get 10.0.0.0/24 and 10.0.1.0/24; private subnets get 10.0.10.0/24 and 10.0.11.0/24. This pattern scales cleanly – you can add more subnets without manual CIDR math.

Note that the NAT Gateway costs approximately $32/month plus data transfer charges. For development environments, you can remove it and use VPC endpoints instead – a strategy covered in our cloud cost optimization guide.

Step 3: Define Security Groups for Multi-Tier Architecture

Security groups act as virtual firewalls for your AWS resources. A well-designed security group strategy follows the principle of least privilege: each tier only accepts traffic from the tier above it. This is fundamental to zero trust architecture.

# security.tf — Security Groups
resource "aws_security_group" "alb" {
 name_prefix = "${var.environment}-alb-"
 vpc_id = aws_vpc.main.id

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

 ingress {
 description = "HTTPS from internet"
 from_port = 443
 to_port = 443
 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"]
 }

 lifecycle {
 create_before_destroy = true
 }

 tags = { Name = "${var.environment}-alb-sg" }
}

resource "aws_security_group" "app" {
 name_prefix = "${var.environment}-app-"
 vpc_id = aws_vpc.main.id

 ingress {
 description = "HTTP from ALB only"
 from_port = 8080
 to_port = 8080
 protocol = "tcp"
 security_groups = [aws_security_group.alb.id]
 }

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

 lifecycle {
 create_before_destroy = true
 }

 tags = { Name = "${var.environment}-app-sg" }
}

resource "aws_security_group" "db" {
 name_prefix = "${var.environment}-db-"
 vpc_id = aws_vpc.main.id

 ingress {
 description = "PostgreSQL from app tier only"
 from_port = 5432
 to_port = 5432
 protocol = "tcp"
 security_groups = [aws_security_group.app.id]
 }

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

 lifecycle {
 create_before_destroy = true
 }

 tags = { Name = "${var.environment}-db-sg" }
}

Notice the chained security group references: the ALB accepts traffic from anywhere on ports 80/443, the application tier only accepts traffic from the ALB on port 8080, and the database only accepts connections from the application tier on port 5432. This is the core of a three-tier network architecture in AWS.

Using name_prefix instead of name combined with create_before_destroy prevents the “security group in use” error during updates. Terraform creates the new security group, updates all references, and then deletes the old one – zero downtime.

Step 4: Deploy an Application Load Balancer with Terraform

The Application Load Balancer (ALB) distributes incoming traffic across your application instances and provides health checking, SSL termination, and path-based routing. In Terraform, the ALB requires three resources: the load balancer itself, a target group, and a listener.

# alb.tf — Application Load Balancer
resource "aws_lb" "main" {
 name = "${var.environment}-alb"
 internal = false
 load_balancer_type = "application"
 security_groups = [aws_security_group.alb.id]
 subnets = aws_subnet.public[*].id

 enable_deletion_protection = var.environment == "prod" ? true : false

 tags = { Name = "${var.environment}-alb" }
}

resource "aws_lb_target_group" "app" {
 name = "${var.environment}-app-tg"
 port = 8080
 protocol = "HTTP"
 vpc_id = aws_vpc.main.id

 health_check {
 path = "/health"
 port = "traffic-port"
 healthy_threshold = 2
 unhealthy_threshold = 3
 timeout = 5
 interval = 30
 matcher = "200"
 }

 tags = { Name = "${var.environment}-app-tg" }
}

resource "aws_lb_listener" "http" {
 load_balancer_arn = aws_lb.main.arn
 port = 80
 protocol = "HTTP"

 default_action {
 type = "forward"
 target_group_arn = aws_lb_target_group.app.arn
 }
}

The health check configuration is critical. Set healthy_threshold to 2 so instances register quickly, and unhealthy_threshold to 3 to avoid flapping during brief hiccups. The /health endpoint should return a 200 status code with a lightweight check – do not hit the database from your health endpoint, or a database issue will cascade into a full-service outage.

The conditional enable_deletion_protection is a Terraform best practice – it prevents accidental ALB deletion in production while keeping dev environments easy to tear down with terraform destroy.

Step 5: Launch EC2 Instances with Auto Scaling Groups

Instead of creating individual EC2 instances, use a Launch Template and Auto Scaling Group (ASG). This combination gives you automatic replacement of failed instances, scaling based on CPU or request count, and rolling deployments when you update the AMI.

# compute.tf — Launch Template and Auto Scaling Group
data "aws_ami" "amazon_linux" {
 most_recent = true
 owners = ["amazon"]

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

 filter {
 name = "virtualization-type"
 values = ["hvm"]
 }
}

resource "aws_launch_template" "app" {
 name_prefix = "${var.environment}-app-"
 image_id = data.aws_ami.amazon_linux.id
 instance_type = var.instance_type

 vpc_security_group_ids = [aws_security_group.app.id]

 user_data = base64encode(templatefile("${path.module}/user_data.sh", {
 environment = var.environment
 db_endpoint = aws_db_instance.main.endpoint
 db_name = aws_db_instance.main.db_name
 }))

 iam_instance_profile {
 name = aws_iam_instance_profile.app.name
 }

 tag_specifications {
 resource_type = "instance"
 tags = {
 Name = "${var.environment}-app"
 }
 }

 lifecycle {
 create_before_destroy = true
 }
}

resource "aws_autoscaling_group" "app" {
 name_prefix = "${var.environment}-app-"
 desired_capacity = var.asg_desired
 max_size = var.asg_max
 min_size = var.asg_min
 target_group_arns = [aws_lb_target_group.app.arn]
 vpc_zone_identifier = aws_subnet.private[*].id

 launch_template {
 id = aws_launch_template.app.id
 version = "$Latest"
 }

 instance_refresh {
 strategy = "Rolling"
 preferences {
 min_healthy_percentage = 50
 }
 }

 tag {
 key = "Name"
 value = "${var.environment}-app"
 propagate_at_launch = true
 }
}

resource "aws_autoscaling_policy" "cpu" {
 name = "${var.environment}-cpu-target"
 autoscaling_group_name = aws_autoscaling_group.app.name
 policy_type = "TargetTrackingScaling"

 target_tracking_configuration {
 predefined_metric_specification {
 predefined_metric_type = "ASGAverageCPUUtilization"
 }
 target_value = 60.0
 }
}

variable "instance_type" {
 description = "EC2 instance type"
 type = string
 default = "t3.micro"
}

variable "asg_desired" {
 default = 2
}

variable "asg_max" {
 default = 4
}

variable "asg_min" {
 default = 1
}

The instance_refresh block enables rolling deployments. When you change the launch template (new AMI, updated user data), Terraform triggers a rolling replacement: the ASG terminates old instances in batches while maintaining at least 50 percent healthy capacity. This replaces the manual process of draining and replacing instances one by one.

The target tracking scaling policy keeps average CPU at 60 percent. AWS adds instances when utilization exceeds the target and removes them when it drops below. This reactive scaling works well for most web applications; for more predictable patterns, add a scheduled scaling action.

Step 6: Provision an RDS PostgreSQL Database

The database tier uses Amazon RDS with PostgreSQL, which gives you automated backups, patching, and multi-AZ failover without managing the database engine yourself. Terraform makes it straightforward to define the instance, subnet group, and parameter group in a single file.

# database.tf — RDS PostgreSQL
resource "aws_db_subnet_group" "main" {
 name = "${var.environment}-db-subnet"
 subnet_ids = aws_subnet.private[*].id
 tags = { Name = "${var.environment}-db-subnet" }
}

resource "aws_db_instance" "main" {
 identifier = "${var.environment}-postgres"
 engine = "postgres"
 engine_version = "16.4"
 instance_class = var.db_instance_class

 allocated_storage = 20
 max_allocated_storage = 100
 storage_type = "gp3"
 storage_encrypted = true

 db_name = "appdb"
 username = "dbadmin"
 password = var.db_password

 db_subnet_group_name = aws_db_subnet_group.main.name
 vpc_security_group_ids = [aws_security_group.db.id]

 multi_az = var.environment == "prod" ? true : false
 skip_final_snapshot = var.environment != "prod"

 backup_retention_period = 7
 backup_window = "03:00-04:00"
 maintenance_window = "Mon:04:00-Mon:05:00"

 performance_insights_enabled = true

 tags = { Name = "${var.environment}-postgres" }
}

variable "db_instance_class" {
 default = "db.t3.micro"
}

variable "db_password" {
 description = "Database master password"
 type = string
 sensitive = true
}

Several details here are worth highlighting. The max_allocated_storage enables storage autoscaling – RDS automatically grows the volume when it fills up, avoiding the dreaded “storage full” outage. The storage_encrypted = true flag enables encryption at rest using the default AWS KMS key. And performance_insights_enabled gives you free query-level performance monitoring for the first 7 days of retention.

The db_password variable is marked sensitive = true, which prevents Terraform from showing it in plan output or state file diffs. In production, use AWS Secrets Manager with the aws_secretsmanager_secret resource instead of passing the password as a variable. We cover this in the advanced tips section.

Step 7: Configure IAM Roles and Instance Profiles

Your EC2 instances need an IAM role to access AWS services like S3, CloudWatch, and Secrets Manager without embedding credentials in the application. The instance profile attaches this role to the Auto Scaling Group’s launch template.

# iam.tf — IAM Role and Instance Profile
data "aws_iam_policy_document" "assume_role" {
 statement {
 actions = ["sts:AssumeRole"]
 principals {
 type = "Service"
 identifiers = ["ec2.amazonaws.com"]
 }
 }
}

resource "aws_iam_role" "app" {
 name_prefix = "${var.environment}-app-"
 assume_role_policy = data.aws_iam_policy_document.assume_role.json
}

resource "aws_iam_role_policy_attachment" "ssm" {
 role = aws_iam_role.app.name
 policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_iam_role_policy_attachment" "cloudwatch" {
 role = aws_iam_role.app.name
 policy_arn = "arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy"
}

data "aws_iam_policy_document" "app_s3" {
 statement {
 actions = ["s3:GetObject", "s3:PutObject", "s3:ListBucket"]
 resources = [
 aws_s3_bucket.assets.arn,
 "${aws_s3_bucket.assets.arn}/*"
 ]
 }
}

resource "aws_iam_role_policy" "app_s3" {
 name_prefix = "${var.environment}-app-s3-"
 role = aws_iam_role.app.id
 policy = data.aws_iam_policy_document.app_s3.json
}

resource "aws_iam_instance_profile" "app" {
 name_prefix = "${var.environment}-app-"
 role = aws_iam_role.app.name
}

# S3 bucket for application assets
resource "aws_s3_bucket" "assets" {
 bucket_prefix = "${var.environment}-app-assets-"
 tags = { Name = "${var.environment}-app-assets" }
}

resource "aws_s3_bucket_versioning" "assets" {
 bucket = aws_s3_bucket.assets.id
 versioning_configuration {
 status = "Enabled"
 }
}

Using data "aws_iam_policy_document" instead of inline JSON is a Terraform best practice. The HCL syntax validates at plan time, gives you IDE autocompletion, and makes policies composable – you can merge multiple policy documents with source_policy_documents. The SSM Core policy enables Session Manager, which lets you SSH into instances through the AWS Console without opening port 22. This is both more convenient and more secure than managing SSH keys.

Step 8: Add Outputs and Run Terraform Plan

Outputs make important values – like your ALB DNS name and database endpoint – available after deployment. They also enable module composition, where one module’s output becomes another module’s input.

# outputs.tf
output "alb_dns_name" {
 description = "DNS name of the Application Load Balancer"
 value = aws_lb.main.dns_name
}

output "db_endpoint" {
 description = "RDS PostgreSQL endpoint"
 value = aws_db_instance.main.endpoint
}

output "vpc_id" {
 description = "VPC ID"
 value = aws_vpc.main.id
}

output "private_subnet_ids" {
 description = "Private subnet IDs for downstream use"
 value = aws_subnet.private[*].id
}

Now run terraform plan to preview every resource Terraform will create. This is the most important command in the Terraform workflow – it shows you exactly what will change before any infrastructure is modified.

$ terraform plan -var="db_password=YourSecureP@ssw0rd"

Terraform will perform the following actions:

 # aws_vpc.main will be created
 # aws_subnet.public[0] will be created
 # aws_subnet.public[1] will be created
 # aws_subnet.private[0] will be created
 # aws_subnet.private[1] will be created
 # aws_internet_gateway.main will be created
 # aws_nat_gateway.main will be created
 # aws_security_group.alb will be created
 # aws_security_group.app will be created
 # aws_security_group.db will be created
 # aws_lb.main will be created
 # aws_lb_target_group.app will be created
 # aws_lb_listener.http will be created
 # aws_launch_template.app will be created
 # aws_autoscaling_group.app will be created
 # aws_db_instance.main will be created
 # ... and 10 more resources

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

Review every line of the plan. Look for unexpected deletions (marked with a minus sign), unintended changes to immutable attributes that force recreation, and any resources missing tags. Once you are satisfied, apply the changes.

Step 9: Apply the Configuration and Verify the Deployment

Run terraform apply to create all 26 resources. Terraform builds resources in the correct dependency order – VPC first, then subnets, then security groups, then the ALB and ASG. The entire deployment takes 8–12 minutes, with the RDS instance being the slowest resource (typically 6–8 minutes).

$ terraform apply -var="db_password=YourSecureP@ssw0rd" -auto-approve

aws_vpc.main: Creating...
aws_vpc.main: Creation complete after 3s [id=vpc-0a1b2c3d4e5f]
aws_subnet.public[0]: Creating...
aws_subnet.public[1]: Creating...
...
aws_db_instance.main: Still creating... [6m30s elapsed]
aws_db_instance.main: Creation complete after 7m42s

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

Outputs:

alb_dns_name = "dev-alb-1234567890.us-east-1.elb.amazonaws.com"
db_endpoint = "dev-postgres.abc123.us-east-1.rds.amazonaws.com:5432"
vpc_id = "vpc-0a1b2c3d4e5f"

Verify the deployment by curling the ALB DNS name. It may take a minute for the target group health checks to pass and register the instances as healthy.

After a successful apply, Terraform writes the current state to terraform.tfstate. This file is the source of truth for what exists in AWS. Never edit it manually, never commit it to Git (it may contain secrets), and always use remote state in team environments – which is our next step.

Step 10: Configure Remote State with S3 and DynamoDB

Local state files work for learning, but any team or CI/CD pipeline needs remote state. The standard approach on AWS uses an S3 bucket for state storage and a DynamoDB table for state locking. This prevents two engineers from running terraform apply simultaneously and corrupting the state.

# backend.tf — Remote State Configuration
terraform {
 backend "s3" {
 bucket = "my-terraform-state-123456"
 key = "tutorial/terraform.tfstate"
 region = "us-east-1"
 dynamodb_table = "terraform-locks"
 encrypt = true
 }
}

# Create these resources BEFORE enabling the backend
# Run this as a separate bootstrap configuration
resource "aws_s3_bucket" "terraform_state" {
 bucket = "my-terraform-state-123456"

 lifecycle {
 prevent_destroy = true
 }
}

resource "aws_s3_bucket_versioning" "terraform_state" {
 bucket = aws_s3_bucket.terraform_state.id
 versioning_configuration {
 status = "Enabled"
 }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
 bucket = aws_s3_bucket.terraform_state.id
 rule {
 apply_server_side_encryption_by_default {
 sse_algorithm = "aws:kms"
 }
 }
}

resource "aws_dynamodb_table" "terraform_locks" {
 name = "terraform-locks"
 billing_mode = "PAY_PER_REQUEST"
 hash_key = "LockID"

 attribute {
 name = "LockID"
 type = "S"
 }
}

The bootstrap process has a chicken-and-egg problem: you need S3 and DynamoDB to store state, but you want to manage those resources with Terraform too. The solution is to create the state bucket and lock table with a separate Terraform configuration that uses local state, then add the backend "s3" block to your main project and run terraform init -migrate-state to move the state from local to remote.

In 2025, HCP Terraform (formerly Terraform Cloud) introduced Stacks with GA support, offering an integrated alternative to the S3 backend with built-in state management, run queueing, and drift detection. For teams already using HCP Terraform, Stacks eliminate the need for this S3/DynamoDB setup entirely.

Common Pitfalls When Using Terraform with AWS

After managing hundreds of Terraform-on-AWS deployments, these are the mistakes that catch teams most often. Each one has cost real organizations real money or real downtime.

Pitfall 1: Storing State Locally in Team Environments

When two engineers run terraform apply from their laptops with local state files, they overwrite each other’s changes. The state file diverges from reality, and Terraform either tries to recreate existing resources or fails with “resource already exists” errors. Always use remote state with locking from day one. As shown in Step 10, S3 + DynamoDB is the standard AWS solution.

Pitfall 2: Hardcoding Values Instead of Using Variables

Hardcoding region names, instance types, and CIDR blocks makes your configuration impossible to reuse across environments. Use variables with sensible defaults, and override them per environment with .tfvars files: terraform apply -var-file="prod.tfvars".

Pitfall 3: Not Using prevent_destroy on Critical Resources

A misplaced terraform destroy or a refactoring mistake can delete your production database in seconds. Add lifecycle { prevent_destroy = true } to RDS instances, S3 buckets with important data, and any resource that would cause data loss. Terraform will refuse to destroy these resources until you explicitly remove the lifecycle block.

Pitfall 4: Ignoring the Lock File

The .terraform.lock.hcl file pins provider versions. If you do not commit it, different team members may download different provider versions, leading to inconsistent plans and mysterious drift. Always commit this file to version control.

Pitfall 5: Passing Secrets via Command Line or tfvars Files

The -var="db_password=..." pattern we used earlier is fine for tutorials but dangerous in production. Command-line arguments appear in shell history, and .tfvars files can be accidentally committed. Instead, use environment variables (TF_VAR_db_password), AWS Secrets Manager with the aws_secretsmanager_secret_version data source, or HCP Terraform’s variable sets with the “sensitive” flag.

Pitfall 6: Creating Resources Without Tags

Untagged resources are invisible to cost allocation, compliance audits, and automated cleanup scripts. Use the provider-level default_tags block (as shown in Step 1) to enforce a minimum set of tags on every resource. Then add resource-specific tags like Name for console readability.

Pitfall 7: Using count When for_each Is Better

The count meta-argument creates resources identified by index (0, 1, 2). If you remove item 0, Terraform renumbers everything and recreates items 1 and 2. Use for_each with a map or set when the collection might change – each resource is keyed by name, not position, so removals do not cascade.

Terraform AWS Troubleshooting Guide

Even experienced Terraform engineers hit these errors regularly. Here is how to diagnose and fix the eight most common issues when working with Terraform and AWS.

ErrorCauseSolution
Error: No valid credential sources foundAWS CLI not configured or credentials expiredRun aws configure or refresh SSO with aws sso login
Error: creating Security Group: InvalidGroup.DuplicateSecurity group with same name already existsUse name_prefix instead of name with create_before_destroy
Error: Error acquiring the state lockPrevious Terraform run crashed or another user is runningRun terraform force-unlock LOCK_ID after confirming no one else is applying
Error: creating RDS: DBSubnetGroupNotFoundFaultSubnet group not yet created or in wrong VPCCheck that aws_db_subnet_group uses private subnets from the same VPC
Error: Cycle detected in dependency graphTwo resources reference each otherBreak the cycle by using security_group_rule resources instead of inline rules
Error: operation error: AccessDeniedIAM user/role lacks required permissionsAttach the necessary IAM policy; use terraform plan to identify which API calls fail
Error: Provider produced inconsistent result after applyProvider bug or resource modified outside TerraformRun terraform refresh to sync state, then terraform plan again
Error: deleting X: DependencyViolationResource is still referenced by another resourceDestroy dependent resources first, or add explicit depends_on for deletion ordering

Debug logging: When errors are unclear, enable verbose logging with TF_LOG=DEBUG terraform plan. This shows every HTTP request Terraform makes to the AWS API, including the exact error response. Pipe the output to a file with TF_LOG_PATH=terraform.log to avoid scrolling through thousands of lines in the terminal.

State surgery: If a resource was deleted manually in the AWS Console and Terraform is trying to update it, remove the orphaned entry with terraform state rm aws_instance.example. If a resource exists in AWS but not in state, import it with terraform import aws_instance.example i-1234567890abcdef0. Terraform 1.5+ supports import blocks in HCL, which is the recommended approach because it is plan-able and reviewable.

Advanced Terraform AWS Tips for Production

Once you have the basics working, these advanced patterns separate toy projects from production-grade infrastructure. Each tip addresses a real operational requirement that surfaces as your team and infrastructure grow.

Use Modules for Reusable Infrastructure Components

As your Terraform codebase grows beyond a few hundred lines, split it into modules. A module is a directory of .tf files with its own variables and outputs. The AWS VPC module on the Terraform Registry (terraform-aws-modules/vpc/aws) has over 50 million downloads and encapsulates the entire networking setup from Step 2 in a single module call:

module "vpc" {
 source = "terraform-aws-modules/vpc/aws"
 version = "5.16.0"

 name = "${var.environment}-vpc"
 cidr = "10.0.0.0/16"

 azs = ["us-east-1a", "us-east-1b"]
 public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
 private_subnets = ["10.0.10.0/24", "10.0.11.0/24"]

 enable_nat_gateway = true
 single_nat_gateway = var.environment != "prod"

 tags = {
 Environment = var.environment
 }
}

Write your own modules for company-specific patterns – a “web service” module that bundles ALB + ASG + security groups, for example. Store them in a private Git repository and reference them with a Git source URL.

Implement Workspace-Based Environment Management

Terraform workspaces let you manage dev, staging, and production from a single configuration with separate state files. Run terraform workspace new prod to create a production workspace, then use terraform.workspace in your configuration to vary instance types, counts, and feature flags:

locals {
 env_config = {
 dev = {
 instance_type = "t3.micro"
 asg_desired = 1
 multi_az = false
 }
 staging = {
 instance_type = "t3.small"
 asg_desired = 2
 multi_az = false
 }
 prod = {
 instance_type = "t3.medium"
 asg_desired = 3
 multi_az = true
 }
 }

 config = local.env_config[terraform.workspace]
}

However, many teams prefer separate directories or separate .tfvars files per environment over workspaces, because workspaces share the same backend configuration and can lead to confusion about which environment is active. Choose the approach that matches your team’s workflow.

Terraform AWS Cost Estimation and Optimization

Infrastructure as code does not automatically mean cost-optimized infrastructure. The resources in this tutorial cost approximately $80-150/month on AWS in us-east-1 as of March 2026. Here is the breakdown.

ResourceConfigurationMonthly Cost (us-east-1)Cost Optimization Option
NAT GatewaySingle AZ$32 + data transferUse VPC endpoints for S3/DynamoDB; remove for dev
ALBApplication type$16 + LCU chargesNo significant reduction possible
EC2 (2x t3.micro)On-demand$15Use Spot instances for dev/staging (up to 90% savings)
RDS db.t3.microSingle AZ, 20 GB$15Use Aurora Serverless v2 for variable workloads
S3StandardLess than $1Lifecycle rules for infrequent access
DynamoDB (state lock)Pay per requestLess than $1Already optimized
CloudWatchBasic metricsFree tierN/A
Total~$80-150/month

Use infracost to estimate costs directly from your Terraform code: infracost breakdown --path . generates a line-by-line cost estimate before you deploy. Integrate it into your CI pipeline to catch cost spikes in pull requests. For broader cloud cost strategies, see our guide on cloud cost optimization strategies that actually work.

CI/CD Integration: Running Terraform in GitHub Actions

Manual terraform apply does not scale. A production Terraform workflow runs plan on pull requests and apply on merge to main. Here is a GitHub Actions workflow that implements this pattern with proper state locking and plan output.

# .github/workflows/terraform.yml
name: Terraform
on:
 push:
 branches: [main]
 pull_request:
 branches: [main]

permissions:
 id-token: write
 contents: read
 pull-requests: write

jobs:
 terraform:
 runs-on: ubuntu-latest
 steps:
 - uses: actions/checkout@v4

 - uses: aws-actions/configure-aws-credentials@v4
 with:
 role-to-assume: arn:aws:iam::123456789012:role/github-terraform
 aws-region: us-east-1

 - uses: hashicorp/setup-terraform@v3
 with:
 terraform_version: 1.12.1

 - name: Terraform Init
 run: terraform init

 - name: Terraform Format Check
 run: terraform fmt -check

 - name: Terraform Plan
 id: plan
 run: terraform plan -no-color -out=tfplan
 continue-on-error: true

 - name: Comment Plan on PR
 if: github.event_name == 'pull_request'
 uses: actions/github-script@v7
 with:
 script: |
 const plan = `${{ steps.plan.outputs.stdout }}`;
 github.rest.issues.createComment({
 issue_number: context.issue.number,
 owner: context.repo.owner,
 repo: context.repo.repo,
 body: `#### Terraform Plann```n${plan}n````
 });

 - name: Terraform Apply
 if: github.ref == 'refs/heads/main' && github.event_name == 'push'
 run: terraform apply -auto-approve tfplan

The OIDC authentication (id-token: write + configure-aws-credentials with role-to-assume) eliminates the need for stored AWS access keys in GitHub Secrets. AWS trusts GitHub’s OIDC provider and issues temporary credentials for each workflow run. This is the recommended approach for CI/CD Terraform pipelines in 2026, and it works similarly with Azure and Google Cloud providers.

Testing Your Terraform Configuration

Terraform 1.6 introduced native testing with the terraform test command, which became generally available and stable in subsequent releases. Tests validate that your configuration produces expected outputs and that resources have the right attributes – without deploying anything (using plan-mode tests) or with real infrastructure (using apply-mode tests).

# tests/vpc.tftest.hcl
run "vpc_creates_correct_subnets" {
 command = plan

 assert {
 condition = length(aws_subnet.public) == 2
 error_message = "Expected 2 public subnets"
 }

 assert {
 condition = length(aws_subnet.private) == 2
 error_message = "Expected 2 private subnets"
 }

 assert {
 condition = aws_vpc.main.cidr_block == "10.0.0.0/16"
 error_message = "VPC CIDR block should be 10.0.0.0/16"
 }
}

run "security_groups_restrict_access" {
 command = plan

 assert {
 condition = length(aws_security_group.db.ingress) == 1
 error_message = "DB security group should have exactly one ingress rule"
 }
}

Run tests with terraform test from your project root. Plan-mode tests are fast and free – they validate logic without creating resources. Apply-mode tests (using command = apply) create real infrastructure, run assertions, and automatically destroy everything when done. Use them for integration tests that verify cross-resource interactions like security group connectivity.

Complement terraform test with terraform validate (syntax checking), terraform fmt -check (formatting), and tflint (linting for AWS-specific issues like invalid instance types). Add all four to your CI pipeline for thorough quality gates.

Terraform vs OpenTofu: Which Should You Choose in 2026?

The HashiCorp licensing change in August 2023 – switching Terraform from MPL 2.0 to the Business Source License (BSL) – led to the creation of OpenTofu, an open-source fork maintained by the Linux Foundation. As of March 2026, both tools are actively developed, and choosing between them depends on your organization’s priorities.

Terraform remains the dominant choice for most teams. HashiCorp continues to ship features like Stacks, the MCP server for AI assistant integration, and enhanced enterprise capabilities. The AWS provider works identically with both tools. If you use HCP Terraform (Cloud) or Terraform Enterprise, you are locked into the HashiCorp ecosystem regardless.

OpenTofu appeals to organizations that require a truly open-source license for compliance or philosophical reasons. The OpenTofu project maintains feature parity with Terraform 1.6.x as its baseline and has added its own state encryption feature. However, its ecosystem is smaller, and third-party tooling (like infracost and some CI/CD integrations) may lag in support.

For this tutorial, every code example works with both Terraform and OpenTofu. If you choose OpenTofu, replace terraform with tofu in all commands.

Complete Project Structure and File Organization

A well-organized Terraform project follows a predictable file structure. Here is the complete layout for the three-tier application we built in this tutorial, ready to clone and customize for your own AWS infrastructure.

terraform-aws-tutorial/
├── main.tf # Provider config, backend
├── variables.tf # All input variables
├── outputs.tf # All outputs
├── network.tf # VPC, subnets, routing
├── security.tf # Security groups
├── alb.tf # Load balancer
├── compute.tf # Launch template, ASG
├── database.tf # RDS PostgreSQL
├── iam.tf # Roles, policies, profiles
├── backend.tf # S3 remote state config
├── user_data.sh # EC2 bootstrap script
├── dev.tfvars # Dev environment overrides
├── staging.tfvars # Staging overrides
├── prod.tfvars # Production overrides
├── tests/
│ └── vpc.tftest.hcl # Native Terraform tests
├── .terraform.lock.hcl # Provider lock file (commit this)
├── .gitignore # Exclude .terraform/, *.tfstate
└── .github/
 └── workflows/
 └── terraform.yml # CI/CD pipeline

Your .gitignore should include .terraform/, *.tfstate, *.tfstate.backup, *.tfvars (if they contain secrets), and tfplan. Never commit state files or plan files to version control.

This file-per-concern pattern works for projects up to about 1,000 lines of HCL. Beyond that, refactor into modules: a VPC module, a web-service module, and a database module, each in its own directory with its own variables and outputs.

Related Coverage

For more context on the cloud and infrastructure topics covered in this tutorial, explore these related articles:

Frequently Asked Questions About Terraform and AWS

How much does it cost to learn Terraform with AWS?

The tutorial project costs approximately $80-150/month if left running, but you can minimize costs by using terraform destroy immediately after testing. Many resources qualify for the AWS Free Tier (t3.micro EC2 for 12 months, 20 GB RDS storage). Terraform itself is free and open source.

Should I use Terraform or AWS CloudFormation?

Terraform is multi-cloud (AWS, Azure, GCP) and has a larger ecosystem of modules and providers. CloudFormation is AWS-only but deeply integrated with AWS services like Service Catalog and Control Tower. If you only use AWS and want native integration, CloudFormation works well. If you use multiple clouds or want provider flexibility, Terraform is the better choice. Most companies in 2026 standardize on Terraform for its portability and community.

What Terraform version should I use in 2026?

Use Terraform 1.12.x, the latest stable release as of March 2026. It includes native testing, import blocks, the moved block for refactoring, and Stacks support. Avoid versions below 1.6, which lack the terraform test command and other critical features.

How do I manage secrets in Terraform?

Never store secrets in .tf files or .tfvars committed to Git. Use environment variables (TF_VAR_ prefix), AWS Secrets Manager with the aws_secretsmanager_secret_version data source, or HCP Terraform’s encrypted variable sets. For database passwords specifically, consider using the aws_rds_cluster resource’s manage_master_user_password attribute, which generates and rotates passwords automatically via Secrets Manager.

Can I import existing AWS resources into Terraform?

Yes. Since Terraform 1.5, you can use import blocks directly in HCL. Write the resource block first, add an import block with the AWS resource ID, then run terraform plan to see what Terraform will adopt. This is safer than the older terraform import CLI command because it is plan-able and code-reviewable. For bulk imports, Terraform 1.12 includes the terraform search command to discover resources across your AWS account.

How do I handle Terraform state file conflicts?

State conflicts occur when two operations modify state simultaneously. The DynamoDB lock table prevents this by allowing only one terraform apply at a time. If a lock gets stuck (crashed process), use terraform force-unlock LOCK_ID. For state corruption, restore from the versioned S3 bucket – this is why versioning on the state bucket is mandatory.

What is the difference between Terraform and Terraform Cloud?

Terraform (CLI) is the open-source tool that reads HCL and manages infrastructure. HCP Terraform (formerly Terraform Cloud) is HashiCorp’s managed service that adds remote state, team access controls, policy enforcement with Sentinel, and a web UI for reviewing runs. It is free for small teams (up to 500 resources under management) and uses per-resource pricing for larger deployments after the 2025 pricing update.

How do I structure Terraform for multiple AWS accounts?

Use provider aliases to deploy across multiple accounts. Define separate AWS providers with different IAM role assumptions, then assign each resource or module to the appropriate provider. Combine this with AWS Organizations and Terraform workspaces or directory-per-account structure. The AWS Control Tower Account Factory for Terraform (AFT) automates this pattern for organizations managing dozens or hundreds of accounts.

👁 Marcus Chen

Marcus Chen

Senior Tech Reporter

Marcus Chen is a Senior Tech Reporter at Tech Insider covering cloud computing, enterprise software, and the business of technology. Before joining TI, he spent five years at ZDNet covering digital transformation across European enterprises and three years at The Register reporting on cloud infrastructure. Marcus is known for his deep dives into cloud cost optimization and multi-cloud strategy. He holds a degree in Computer Science from Imperial College London and speaks regularly at KubeCon and CloudNative events.

View all articles
👁 Tech Insider
Tech
Insider

Tech Insider delivers in-depth coverage of the technologies shaping the future: AI, cybersecurity, cloud computing, hardware, and the trends that matter.

Company

Explore

Categories

© 2026 Tech Insider Media AB. All rights reserved.