VOOZH about

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

⇱ 12-Step Terraform AWS Tutorial (From Zero to Deploy) [2026]


Skip to content
April 19, 2026
21 min read

Terraform remains the most widely adopted infrastructure as code tool in 2026, with over 3,000 providers in its registry and tens of millions of downloads per month. Whether you are deploying a single EC2 instance or managing a multi-cloud Kubernetes fleet, Terraform gives you a single declarative workflow to provision and manage infrastructure across AWS, Azure, GCP, and dozens of other platforms. This tutorial walks you through 12 hands-on steps – from installing the CLI to building a production-grade, multi-environment AWS deployment with remote state, modules, and automated drift detection.

With the release of Terraform 1.14 (stable as of March 2026) and the ongoing 1.15 release candidates adding Windows ARM64 support, the ecosystem continues to mature. HashiCorp, now part of IBM following the 2024 acquisition, has shifted Terraform Enterprise to quarterly releases and semantic versioning. Meanwhile, the open-source community around Terraform remains one of the largest in DevOps, with more than 42,000 stars on GitHub. This guide uses Terraform 1.14.x and the AWS provider to build a complete, working project you can extend for your own infrastructure needs.

Prerequisites and Environment Setup

Before writing your first line of HCL (HashiCorp Configuration Language), you need four things installed and configured on your machine. Every version listed below has been tested for this terraform tutorial and confirmed compatible as of April 2026.

ToolRequired VersionPurposeInstall Command
Terraform CLI1.14.8+Core IaC enginebrew install hashicorp/tap/terraform
AWS CLI2.xCloud credential managementbrew install awscli
Git2.40+Version control for .tf filesbrew install git
VS Code + HashiCorp ExtensionLatestHCL syntax highlighting and autocompleteVS Code marketplace
An AWS accountFree Tier eligibleCloud provider for deploymentsaws.amazon.com

You should also have basic familiarity with the terminal, JSON, and at least one cloud provider console. If you have used CloudFormation or Ansible before, many concepts will feel familiar – but Terraform’s declarative, plan-before-apply model offers a fundamentally different workflow that eliminates most imperative scripting. The full project we build in this terraform tutorial deploys a VPC, subnets, a security group, and an EC2 instance, then refactors the setup into reusable modules with remote state management.

Step 1: Install Terraform and Verify the CLI

Terraform ships as a single binary with no runtime dependencies, making installation straightforward on any operating system. The official install page covers every platform, but the fastest methods in 2026 are package managers. On macOS use Homebrew, on Ubuntu/Debian use the HashiCorp APT repository, and on Windows use Chocolatey or the new ARM64 build introduced in version 1.15.

πŸ‘ Step 1: Install Terraform and Verify the CLI
# macOS (Homebrew)
brew tap hashicorp/tap
brew install hashicorp/tap/terraform

# Ubuntu / Debian
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

# Verify installation
terraform version

You should see output similar to Terraform v1.14.8 on linux_amd64. If the version is lower than 1.14, upgrade before continuing – earlier versions lack important provider compatibility improvements and the enhanced import workflow we use in Step 10. Also configure tab completion for your shell by running terraform -install-autocomplete, which speeds up daily usage significantly. Terraform stores no global state or daemon processes, so upgrading later is as simple as replacing the binary.

Step 2: Configure AWS Credentials

Terraform needs credentials to communicate with your cloud provider’s API. For AWS, the recommended approach is an IAM user with programmatic access, scoped to the minimum permissions your project requires. Never use root credentials. For this terraform tutorial, an IAM user with the AdministratorAccess managed policy works for learning, but in production you should create a custom policy limited to the exact services you provision.

# Configure AWS CLI with your IAM credentials
aws configure
# AWS Access Key ID: YOUR_ACCESS_KEY
# AWS Secret Access Key: YOUR_SECRET_KEY
# Default region name: us-east-1
# Default output format: json

# Verify credentials work
aws sts get-caller-identity

The aws sts get-caller-identity command returns your account ID, user ARN, and user ID. If it fails, double-check that your access keys are active in the IAM console. Terraform reads credentials from the same chain as the AWS CLI – environment variables first, then the shared credentials file at ~/.aws/credentials, then instance profiles if running on EC2. For CI/CD pipelines, use environment variables (AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY) or, better yet, OIDC-based role assumption with GitHub Actions or GitLab CI to avoid storing long-lived secrets entirely.

Step 3: Write Your First Terraform Configuration

Every Terraform project starts with .tf files written in HCL. Create a new directory for the project, then add three files: main.tf for resources, variables.tf for input variables, and outputs.tf for values you want to display after deployment. This three-file convention is a Terraform community standard documented in the HCL language reference and used by virtually every open-source module.

# main.tf β€” provider and first resource
terraform {
 required_version = ">= 1.14.0"
 required_providers {
 aws = {
 source = "hashicorp/aws"
 version = "~> 6.39"
 }
 }
}

provider "aws" {
 region = var.aws_region
}

resource "aws_vpc" "main" {
 cidr_block = var.vpc_cidr
 enable_dns_hostnames = true
 enable_dns_support = true

 tags = {
 Name = "${var.project_name}-vpc"
 Environment = var.environment
 ManagedBy = "terraform"
 }
}

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

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

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

Notice several key patterns. The terraform block pins both the CLI version and the provider version, preventing accidental upgrades from breaking your infrastructure. The count meta-argument creates two subnets across different availability zones without duplicating code. The cidrsubnet function computes non-overlapping CIDR ranges automatically. And every resource uses consistent tagging, which is critical for cost allocation and compliance in production environments. These patterns scale from a two-resource demo to a thousand-resource enterprise deployment.

Step 4: Define Variables and Outputs

Hard-coding values like region names and CIDR blocks directly in resource definitions makes configuration brittle and impossible to reuse across environments. Terraform variables solve this with typed, validated inputs that can be set via CLI flags, environment variables, .tfvars files, or Terraform Cloud workspaces. Here is the variables.tf file for our project.

πŸ‘ Step 4: Define Variables and Outputs
# variables.tf
variable "aws_region" {
 description = "AWS region for all resources"
 type = string
 default = "us-east-1"
}

variable "project_name" {
 description = "Name prefix for all resources"
 type = string
 default = "tf-tutorial"
}

variable "environment" {
 description = "Deployment environment (dev, staging, prod)"
 type = string
 default = "dev"

 validation {
 condition = contains(["dev", "staging", "prod"], var.environment)
 error_message = "Environment must be dev, staging, or prod."
 }
}

variable "vpc_cidr" {
 description = "CIDR block for the VPC"
 type = string
 default = "10.0.0.0/16"
}

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

# outputs.tf
output "vpc_id" {
 description = "ID of the created VPC"
 value = aws_vpc.main.id
}

output "public_subnet_ids" {
 description = "IDs of public subnets"
 value = aws_subnet.public[*].id
}

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

The validation block on the environment variable is a feature introduced in Terraform 1.0 and refined through subsequent releases. It catches invalid input before any API call is made, saving time and preventing partial deployments. Outputs are equally important – they expose resource attributes that other Terraform configurations, scripts, or CI/CD pipelines can consume. When you run terraform output -json, you get machine-readable data that tools like Ansible or Packer can use directly. This variable-driven approach is what makes Terraform configurations genuinely reusable across teams and environments.

Step 5: Run Init, Plan, and Apply

Terraform’s core workflow is three commands: init, plan, and apply. This is the heart of every terraform tutorial because it represents the declarative model that separates Terraform from imperative tools. You describe what you want, Terraform figures out how to get there, shows you the plan, and only makes changes after you approve.

# Initialize β€” downloads providers and sets up backend
terraform init

# Plan β€” preview changes without modifying infrastructure
terraform plan -out=tfplan

# Apply β€” execute the plan
terraform apply tfplan

# Example output:
# aws_vpc.main: Creating...
# aws_vpc.main: Creation complete after 3s [id=vpc-0a1b2c3d4e5f67890]
# aws_subnet.public[0]: Creating...
# aws_subnet.public[1]: Creating...
# aws_subnet.public[0]: Creation complete after 1s [id=subnet-0abc123]
# aws_subnet.public[1]: Creation complete after 1s [id=subnet-0def456]
#
# Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
#
# Outputs:
# vpc_id = "vpc-0a1b2c3d4e5f67890"
# public_subnet_ids = ["subnet-0abc123", "subnet-0def456"]

The -out=tfplan flag saves the plan to a binary file, ensuring that exactly what you reviewed is what gets applied. This is a best practice for CI/CD pipelines where a human reviews the plan in a pull request before the apply step runs. The init command is idempotent – you can run it repeatedly without side effects. It downloads the AWS provider plugin (version 6.39.x as pinned in our config), initializes the backend, and validates the configuration. If you see provider download errors, check that your internet connection allows HTTPS to registry.terraform.io.

Step 6: Add an EC2 Instance with a Security Group

With the VPC and subnets in place, add a security group and an EC2 instance. This step demonstrates resource dependencies – Terraform automatically understands that the security group must exist before the instance that references it, and it builds a dependency graph to parallelize independent operations.

# Add to main.tf
resource "aws_security_group" "web" {
 name_prefix = "${var.project_name}-web-"
 description = "Allow HTTP and SSH inbound"
 vpc_id = aws_vpc.main.id

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

 ingress {
 description = "SSH from anywhere"
 from_port = 22
 to_port = 22
 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
 }
}

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 = var.instance_type
 subnet_id = aws_subnet.public[0].id
 vpc_security_group_ids = [aws_security_group.web.id]

 user_data = < /var/www/html/index.html
 EOF

 tags = {
 Name = "${var.project_name}-web"
 }
}

Run terraform plan again and you will see three new resources to add. The data source for the AMI dynamically fetches the latest Amazon Linux 2023 image, so your configuration never hard-codes an AMI ID that could become unavailable. The lifecycle block with create_before_destroy ensures zero-downtime security group updates – Terraform creates the replacement before deleting the old one. After applying, the instance_public_ip output shows the IP address where your web server is reachable.

Step 7: Manage State with Remote Backends

By default, Terraform stores state in a local file called terraform.tfstate. This works for solo development, but it is a liability for teams. If two engineers run terraform apply simultaneously with local state, they can corrupt the file or create conflicting infrastructure. Remote backends solve this with centralized storage and locking. The most common backend for AWS users is S3 with DynamoDB locking, as described in the state management documentation.

πŸ‘ Step 7: Manage State with Remote Backends
# backend.tf β€” remote state configuration
terraform {
 backend "s3" {
 bucket = "my-terraform-state-2026"
 key = "tutorial/terraform.tfstate"
 region = "us-east-1"
 dynamodb_table = "terraform-locks"
 encrypt = true
 }
}

# Create the S3 bucket and DynamoDB table first (one-time setup)
# Run these AWS CLI commands before terraform init:
#
# aws s3api create-bucket --bucket my-terraform-state-2026 --region us-east-1
# aws s3api put-bucket-versioning --bucket my-terraform-state-2026 
# --versioning-configuration Status=Enabled
# aws dynamodb create-table 
# --table-name terraform-locks 
# --attribute-definitions AttributeName=LockID,AttributeType=S 
# --key-schema AttributeName=LockID,KeyType=HASH 
# --billing-mode PAY_PER_REQUEST

After adding the backend block, run terraform init -migrate-state to move your local state to S3. Terraform will prompt you to confirm the migration. The DynamoDB table provides mutual exclusion – if one team member is running an apply, anyone else who tries gets a clear lock error instead of a corrupted state file. Enable S3 bucket versioning so you can recover from accidental state deletions. In production, also add an S3 bucket policy restricting access to your CI/CD service account and senior engineers only, because the state file contains sensitive values like database passwords and API keys in plaintext.

Step 8: Refactor with Terraform Modules

Once your configuration grows beyond a handful of resources, modules become essential. A module is a reusable package of Terraform configuration – think of it as a function in programming. The Terraform Registry hosts thousands of community and verified modules, but you should also build your own for organization-specific patterns.

# Directory structure for modular project
# terraform-tutorial/
# β”œβ”€β”€ main.tf
# β”œβ”€β”€ variables.tf
# β”œβ”€β”€ outputs.tf
# β”œβ”€β”€ backend.tf
# β”œβ”€β”€ environments/
# β”‚ β”œβ”€β”€ dev.tfvars
# β”‚ β”œβ”€β”€ staging.tfvars
# β”‚ └── prod.tfvars
# └── modules/
# └── web-server/
# β”œβ”€β”€ main.tf
# β”œβ”€β”€ variables.tf
# └── outputs.tf

# modules/web-server/main.tf
resource "aws_security_group" "this" {
 name_prefix = "${var.name}-"
 vpc_id = var.vpc_id

 dynamic "ingress" {
 for_each = var.ingress_rules
 content {
 from_port = ingress.value.port
 to_port = ingress.value.port
 protocol = "tcp"
 cidr_blocks = ingress.value.cidr_blocks
 }
 }

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

resource "aws_instance" "this" {
 ami = var.ami_id
 instance_type = var.instance_type
 subnet_id = var.subnet_id
 vpc_security_group_ids = [aws_security_group.this.id]
 user_data = var.user_data

 tags = {
 Name = var.name
 Environment = var.environment
 }
}

# modules/web-server/variables.tf
variable "name" { type = string }
variable "vpc_id" { type = string }
variable "subnet_id" { type = string }
variable "ami_id" { type = string }
variable "instance_type" { type = string ; default = "t3.micro" }
variable "environment" { type = string }
variable "user_data" { type = string ; default = "" }
variable "ingress_rules" {
 type = list(object({
 port = number
 cidr_blocks = list(string)
 }))
 default = [
 { port = 80, cidr_blocks = ["0.0.0.0/0"] },
 { port = 22, cidr_blocks = ["0.0.0.0/0"] }
 ]
}

# Root main.tf β€” calling the module
module "web" {
 source = "./modules/web-server"
 name = "${var.project_name}-web"
 vpc_id = aws_vpc.main.id
 subnet_id = aws_subnet.public[0].id
 ami_id = data.aws_ami.amazon_linux.id
 instance_type = var.instance_type
 environment = var.environment
}

The dynamic block inside the security group generates ingress rules from a variable list, eliminating repetitive blocks. When you call a module, you pass inputs like function arguments and read outputs from module.web.instance_id. Modules enforce encapsulation – the calling configuration cannot directly reference resources inside the module, only its declared outputs. This separation makes it safe for different teams to own different modules without stepping on each other’s configurations. Run terraform init again after adding modules to let Terraform discover them.

Step 9: Implement Multi-Environment Deployments

Real projects run the same infrastructure in dev, staging, and production with different sizing and configuration. Terraform supports this through .tfvars files and workspaces. The .tfvars approach is simpler and more explicit, so we recommend it for most teams.

Parameterdev.tfvarsstaging.tfvarsprod.tfvars
instance_typet3.microt3.smallt3.large
environmentdevstagingprod
vpc_cidr10.0.0.0/1610.1.0.0/1610.2.0.0/16
aws_regionus-east-1us-east-1us-west-2
Estimated monthly cost$8-12$20-30$60-100
# environments/dev.tfvars
aws_region = "us-east-1"
environment = "dev"
project_name = "tf-tutorial"
vpc_cidr = "10.0.0.0/16"
instance_type = "t3.micro"

# Deploy to dev
terraform plan -var-file=environments/dev.tfvars -out=dev.tfplan
terraform apply dev.tfplan

# Deploy to staging (separate state file)
terraform plan -var-file=environments/staging.tfvars -out=staging.tfplan
terraform apply staging.tfplan

# Each environment should use a different state key:
# backend "s3" { key = "dev/terraform.tfstate" }
# backend "s3" { key = "staging/terraform.tfstate" }
# backend "s3" { key = "prod/terraform.tfstate" }

Each environment uses its own state file in S3, so changes to dev never risk affecting production. The -var-file flag overrides defaults from variables.tf with environment-specific values. For larger organizations, consider separate directories per environment (each with its own backend configuration) rather than shared code with variable files – this provides stronger isolation at the cost of some duplication. Terraform workspaces are an alternative, but they share the same backend configuration, which can be confusing and risky for production workloads.

Step 10: Import Existing Infrastructure

Most teams do not start with a greenfield environment. You likely have existing AWS resources created manually or by other tools. Terraform 1.14 and later provide an improved import workflow with the import block that generates configuration automatically – a significant improvement over the older terraform import CLI command that required you to write the configuration by hand first.

πŸ‘ Step 10: Import Existing Infrastructure
# Import an existing S3 bucket into Terraform management
# Add an import block to your configuration:

import {
 to = aws_s3_bucket.existing
 id = "my-existing-bucket-name"
}

# Generate the configuration:
terraform plan -generate-config-out=generated.tf

# Review generated.tf β€” Terraform writes HCL that matches
# the current state of the imported resource.
# Move the generated config to your main files, then:
terraform apply

The -generate-config-out flag appeared in Terraform 1.5 and has been refined in every release since. In version 1.14, it produces more precise attribute values and handles nested blocks more reliably. The workflow is: add import blocks for each resource, run plan with the generation flag, review the output file, clean it up, and apply. After the apply, Terraform manages the resource going forward – any manual changes outside Terraform will be detected as drift on the next plan. This import capability is essential for brownfield environments where migrating to infrastructure as code is a gradual process.

Step 11: Set Up Drift Detection and Plan Automation

Infrastructure drift – when the actual state diverges from the Terraform configuration – is one of the biggest risks in cloud operations. Someone edits a security group in the console, a script modifies a tag, or an auto-scaling event changes capacity. Terraform detects all of these on the next plan, but you need to run that plan regularly to catch drift before it causes incidents.

# .github/workflows/terraform-drift.yml
name: Terraform Drift Detection
on:
 schedule:
 - cron: '0 8 * * 1-5' # Weekdays at 8 AM UTC
 workflow_dispatch: {}

jobs:
 drift-check:
 runs-on: ubuntu-latest
 steps:
 - uses: actions/checkout@v4
 - uses: hashicorp/setup-terraform@v3
 with:
 terraform_version: 1.14.8

 - name: Terraform Init
 run: terraform init
 env:
 AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
 AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

 - name: Terraform Plan (Drift Check)
 id: plan
 run: |
 terraform plan -detailed-exitcode -var-file=environments/prod.tfvars 2>&1 | tee plan-output.txt
 echo "exitcode=$?" >> $GITHUB_OUTPUT
 continue-on-error: true
 env:
 AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
 AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

 - name: Notify on Drift
 if: steps.plan.outputs.exitcode == '2'
 run: echo "DRIFT DETECTED β€” review plan-output.txt"

The -detailed-exitcode flag makes terraform plan return exit code 2 when changes are detected, enabling automated alerting. This GitHub Actions workflow runs every weekday morning and notifies your team if any resource has drifted. In production, pipe the notification to Slack, PagerDuty, or your incident management system. Terraform Cloud and Terraform Enterprise offer built-in drift detection as a feature, running plans automatically and flagging divergence in the dashboard. Regardless of the tool, regular drift detection is a non-negotiable practice for any team managing cloud infrastructure at scale.

Step 12: Tear Down Infrastructure Safely

The final step in any terraform tutorial is cleanup. Terraform’s destroy command removes every resource tracked in the state file, in the correct dependency order. For this tutorial project, which uses AWS Free Tier resources, destroying prevents unexpected charges.

# Preview what will be destroyed
terraform plan -destroy -var-file=environments/dev.tfvars

# Destroy all resources
terraform destroy -var-file=environments/dev.tfvars

# Example output:
# aws_instance.web: Destroying... [id=i-0abc123def456]
# aws_instance.web: Destruction complete after 32s
# aws_security_group.web: Destroying... [id=sg-0abc123]
# aws_security_group.web: Destruction complete after 1s
# aws_subnet.public[1]: Destroying... [id=subnet-0def456]
# aws_subnet.public[0]: Destroying... [id=subnet-0abc123]
# aws_subnet.public[1]: Destruction complete after 1s
# aws_subnet.public[0]: Destruction complete after 1s
# aws_vpc.main: Destroying... [id=vpc-0a1b2c3d4e5f67890]
# aws_vpc.main: Destruction complete after 1s
#
# Destroy complete! Resources: 5 destroyed.

Always run plan -destroy before the actual destroy to verify which resources will be removed. In production, protect critical resources with the prevent_destroy lifecycle rule – Terraform will refuse to destroy any resource with this flag, even during a full terraform destroy. For databases, S3 buckets with data, and similar stateful resources, this is essential. You can also use terraform state rm to remove a resource from state without destroying it, which is useful when transferring ownership of a resource to a different Terraform configuration.

5 Common Pitfalls and How to Avoid Them

After walking through every step, here are the mistakes that trip up even experienced Terraform users – and how to handle each one before it becomes a production incident.

πŸ‘ 5 Common Pitfalls and How to Avoid Them

Pitfall 1: Committing state files to Git. The terraform.tfstate file contains sensitive data including passwords, API keys, and resource IDs. Never commit it. Add *.tfstate and *.tfstate.backup to your .gitignore from day one, and use a remote backend for all shared projects.

Pitfall 2: Not pinning provider versions. Without a version constraint like ~> 6.39, terraform init downloads the latest provider version, which may include breaking changes. Always pin to a minor version range and upgrade deliberately after reading the changelog.

Pitfall 3: Using terraform apply without a saved plan. Running terraform apply without -out shows a plan and immediately prompts for approval, but the plan can change between the preview and execution if another process modifies the infrastructure. Always use terraform plan -out=tfplan followed by terraform apply tfplan.

Pitfall 4: Hardcoding AMI IDs. AMI IDs are region-specific and change when new images are published. Use a data source like aws_ami to fetch the latest AMI dynamically, as shown in Step 6. This prevents deployment failures when an old AMI is deprecated.

Pitfall 5: Running destroy in the wrong workspace or environment. One misplaced terraform destroy targeting production instead of dev can take down your entire infrastructure. Use separate state files per environment, require manual approval for production applies, and consider Terraform Cloud’s Sentinel policies to enforce guardrails like β€œno destroy on production during business hours.”

Troubleshooting: 8 Issues and Fixes

Even with careful setup, you will hit errors. Here are the eight most common problems in this terraform tutorial and their solutions.

IssueError MessageSolution
Provider download failsFailed to query available provider packagesCheck internet connectivity and verify registry.terraform.io is not blocked by a firewall or proxy
State lock timeoutError acquiring the state lockAnother apply is running. Wait for it to finish, or run terraform force-unlock LOCK_ID if the process crashed
Invalid credentialsNoCredentialProviders: no valid providers in chainRun aws configure again or export AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables
Resource already existsError: creating X: already existsImport the existing resource with terraform import or use a unique name prefix with name_prefix
Cycle in dependency graphError: Cycle: aws_security_group.a, aws_security_group.bBreak the circular reference by using aws_security_group_rule as separate resources instead of inline rules
Backend config changedError: Backend configuration changedRun terraform init -reconfigure to reinitialize with the new backend settings
Module not foundError: Module not installedRun terraform init again after adding or modifying module source paths
Destroy blocked by dependencyError: deleting X: DependencyViolationTerraform usually handles ordering, but manually created dependencies outside Terraform (like ENIs) must be removed first

For state-related issues, the terraform state list and terraform state show RESOURCE commands are your best diagnostic tools. They let you inspect exactly what Terraform knows about each resource without making any changes. If state becomes corrupted, restore from the versioned S3 bucket. For provider errors, enable debug logging with TF_LOG=DEBUG terraform plan to see the raw API calls and responses.

Advanced Tips for Production Terraform

Once you are comfortable with the basics from this terraform tutorial, these advanced practices separate hobby projects from production infrastructure.

Use terraform fmt and terraform validate in CI. The fmt command enforces consistent code style across your team, and validate catches syntax errors before plan runs. Add both as pre-commit hooks or CI steps. Combined with tflint (a third-party linter), you catch issues like unused variables, deprecated syntax, and provider-specific best practice violations before they reach code review.

Use moved blocks for refactoring. When you rename a resource or move it into a module, Terraform would normally destroy the old resource and create a new one. The moved block tells Terraform that a resource has been renamed, preserving the existing infrastructure. This is critical in production – renaming a database resource without a moved block would delete and recreate the database, destroying all data.

Implement cost estimation. Tools like Infracost integrate with Terraform to estimate monthly costs before you apply. Add infracost breakdown --path . to your CI pipeline to catch expensive changes – like accidentally provisioning a p4d.24xlarge instead of a t3.micro – before they hit your AWS bill. This is especially valuable in larger organizations where the person writing Terraform may not own the cloud budget.

Use Terraform Cloud or Spacelift for team workflows. While the open-source CLI is powerful, teams larger than three to five engineers benefit from a management layer that provides remote plan execution, policy enforcement (Sentinel or OPA), cost estimation, and audit logging. Terraform Cloud offers a free tier for up to five users, making it accessible for small teams, while Spacelift and Env0 provide alternatives with different pricing models and feature sets.

Adopt a tagging strategy from day one. Tags are the backbone of cost allocation, security auditing, and resource lifecycle management in AWS. Define a standard set of tags (Name, Environment, Team, ManagedBy, CostCenter) in a local variable block and apply them to every resource. Use default_tags in the AWS provider configuration to enforce tags automatically without repeating them in every resource block.

Terraform vs Other IaC Tools in 2026

Terraform is not the only infrastructure as code tool, and understanding the landscape helps you decide when alternatives might serve you better. Here is how the major options compare as of April 2026.

FeatureTerraformOpenTofuCloudFormationPulumiAnsible
LanguageHCLHCLJSON/YAMLPython/TS/Go/C#YAML
Multi-cloudYes (3,000+ providers)Yes (3,000+ providers)AWS onlyYes (100+ providers)Yes (limited)
State managementBuilt-inBuilt-inManaged by AWSBuilt-in + managedStateless
LicenseBUSL 1.1MPL 2.0 (open source)ProprietaryApache 2.0GPL 3.0
Learning curveMediumMediumLow (AWS users)HighLow
Best forMulti-cloud, large teamsOpen-source requirementAWS-only shopsDevelopers who prefer codeConfiguration management

Terraform’s Business Source License (BUSL 1.1), adopted in 2023, led to the creation of OpenTofu as an open-source fork maintained by the Linux Foundation. OpenTofu maintains compatibility with Terraform’s HCL syntax and providers, making migration straightforward. For AWS-only shops, CloudFormation offers zero-cost, fully managed state with no additional tooling. Pulumi appeals to teams who prefer writing infrastructure in general-purpose programming languages. Ansible is best for configuration management (installing packages, configuring services) rather than infrastructure provisioning. Most large organizations use Terraform for provisioning and Ansible or Chef for configuration, treating them as complementary rather than competing tools.

Related Coverage

Complete Project Structure

Here is the final directory tree for the complete working project built in this terraform tutorial. Every file is shown with its purpose so you can replicate the setup from scratch or adapt it for your own infrastructure.

terraform-tutorial/
β”œβ”€β”€ main.tf # VPC, subnets, data sources, module calls
β”œβ”€β”€ variables.tf # All input variables with types and defaults
β”œβ”€β”€ outputs.tf # Exported values (VPC ID, IPs, subnet IDs)
β”œβ”€β”€ backend.tf # S3 + DynamoDB remote state configuration
β”œβ”€β”€ .gitignore # *.tfstate, *.tfstate.backup, .terraform/
β”œβ”€β”€ .terraform.lock.hcl # Provider dependency lock file (commit this)
β”œβ”€β”€ environments/
β”‚ β”œβ”€β”€ dev.tfvars # Dev overrides (t3.micro, us-east-1)
β”‚ β”œβ”€β”€ staging.tfvars # Staging overrides (t3.small, us-east-1)
β”‚ └── prod.tfvars # Prod overrides (t3.large, us-west-2)
β”œβ”€β”€ modules/
β”‚ └── web-server/
β”‚ β”œβ”€β”€ main.tf # Security group + EC2 instance
β”‚ β”œβ”€β”€ variables.tf # Module inputs
β”‚ └── outputs.tf # Module outputs (instance_id, public_ip)
└── .github/
 └── workflows/
 └── terraform-drift.yml # Scheduled drift detection

Commit the .terraform.lock.hcl file to Git – it records the exact provider versions and checksums, ensuring every team member and CI runner uses identical provider binaries. Do not commit the .terraform/ directory, which contains downloaded provider plugins and is large. The environments directory keeps variable files organized, and the modules directory scales to hold as many reusable components as your organization needs.

Frequently Asked Questions

What is the difference between Terraform and OpenTofu? OpenTofu is an open-source fork of Terraform created after HashiCorp changed Terraform’s license to BUSL 1.1 in 2023. OpenTofu uses the same HCL syntax and is compatible with Terraform providers and modules. The main difference is licensing: OpenTofu is MPL 2.0, while Terraform’s source code is restricted from competitive commercial use. For individual developers and most companies, both tools work identically.

Is Terraform free to use? Yes, the Terraform CLI is free and open-source under the BUSL 1.1 license. You can use it for any purpose including commercial infrastructure management. Terraform Cloud offers a free tier for up to five users. Terraform Enterprise is the paid, self-hosted option for large organizations needing advanced governance, audit, and compliance features.

How do I handle secrets in Terraform? Never store secrets as plaintext in .tf files or .tfvars files committed to Git. Use environment variables, AWS Secrets Manager, HashiCorp Vault, or your CI/CD platform’s secret store. Reference secrets with data sources (e.g., data.aws_secretsmanager_secret_version) instead of variable inputs. Mark sensitive outputs with sensitive = true to prevent them from appearing in plan output.

Can Terraform manage Kubernetes resources? Yes, through the hashicorp/kubernetes and hashicorp/helm providers. Terraform can provision the cluster itself (EKS, GKE, AKS) and deploy workloads into it. However, many teams prefer to use Terraform for cluster provisioning and a dedicated tool like ArgoCD or Flux for application deployment, following the GitOps pattern.

What happens if someone changes infrastructure outside Terraform? The next time you run terraform plan, Terraform refreshes its understanding of the real infrastructure and compares it to your configuration. Any differences appear as planned changes. If you want to keep the manual change, update your configuration to match. If you want to revert it, run terraform apply to bring the real infrastructure back in line with your code. This is drift detection, and it is one of Terraform’s most valuable features.

How do I upgrade Terraform safely? Always read the upgrade guide for your target version. Upgrade one minor version at a time (e.g., 1.13 to 1.14, not 1.10 to 1.14). Test in a non-production workspace first. Run terraform plan after upgrading to verify no unexpected changes. The .terraform.lock.hcl file ensures provider versions stay consistent even when the CLI version changes.

Should I use Terraform workspaces or separate directories for environments? For most teams, separate directories or separate state file keys per environment are safer. Workspaces share the same backend configuration and code, making it easy to accidentally apply dev changes to production. Separate directories provide stronger isolation and allow environment-specific backend configurations, though they require more code duplication or a tool like Terragrunt to manage shared code.

How do I migrate from CloudFormation to Terraform? Use the terraform import workflow described in Step 10. For each resource managed by CloudFormation, add an import block and generate the configuration. Once all resources are imported, delete the CloudFormation stack with the --retain-resources option to remove CloudFormation management without destroying the resources. Then Terraform takes over lifecycle management going forward. The process is manual but reliable for stacks of any size.

πŸ‘ 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
πŸ‘ 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.