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.
| Tool | Required Version | Purpose | Install Command |
|---|---|---|---|
| Terraform CLI | 1.14.8+ | Core IaC engine | brew install hashicorp/tap/terraform |
| AWS CLI | 2.x | Cloud credential management | brew install awscli |
| Git | 2.40+ | Version control for .tf files | brew install git |
| VS Code + HashiCorp Extension | Latest | HCL syntax highlighting and autocomplete | VS Code marketplace |
| An AWS account | Free Tier eligible | Cloud provider for deployments | aws.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.
# 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.
# 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.
# 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.
| Parameter | dev.tfvars | staging.tfvars | prod.tfvars |
|---|---|---|---|
| instance_type | t3.micro | t3.small | t3.large |
| environment | dev | staging | prod |
| vpc_cidr | 10.0.0.0/16 | 10.1.0.0/16 | 10.2.0.0/16 |
| aws_region | us-east-1 | us-east-1 | us-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.
# 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.
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.
| Issue | Error Message | Solution |
|---|---|---|
| Provider download fails | Failed to query available provider packages | Check internet connectivity and verify registry.terraform.io is not blocked by a firewall or proxy |
| State lock timeout | Error acquiring the state lock | Another apply is running. Wait for it to finish, or run terraform force-unlock LOCK_ID if the process crashed |
| Invalid credentials | NoCredentialProviders: no valid providers in chain | Run aws configure again or export AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables |
| Resource already exists | Error: creating X: already exists | Import the existing resource with terraform import or use a unique name prefix with name_prefix |
| Cycle in dependency graph | Error: Cycle: aws_security_group.a, aws_security_group.b | Break the circular reference by using aws_security_group_rule as separate resources instead of inline rules |
| Backend config changed | Error: Backend configuration changed | Run terraform init -reconfigure to reinitialize with the new backend settings |
| Module not found | Error: Module not installed | Run terraform init again after adding or modifying module source paths |
| Destroy blocked by dependency | Error: deleting X: DependencyViolation | Terraform 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.
| Feature | Terraform | OpenTofu | CloudFormation | Pulumi | Ansible |
|---|---|---|---|---|---|
| Language | HCL | HCL | JSON/YAML | Python/TS/Go/C# | YAML |
| Multi-cloud | Yes (3,000+ providers) | Yes (3,000+ providers) | AWS only | Yes (100+ providers) | Yes (limited) |
| State management | Built-in | Built-in | Managed by AWS | Built-in + managed | Stateless |
| License | BUSL 1.1 | MPL 2.0 (open source) | Proprietary | Apache 2.0 | GPL 3.0 |
| Learning curve | Medium | Medium | Low (AWS users) | High | Low |
| Best for | Multi-cloud, large teams | Open-source requirement | AWS-only shops | Developers who prefer code | Configuration 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
- Terraform vs CloudFormation 2026: 3,000 Providers vs Zero-Cost and a 3x Job Demand Gap [Tested]
- Pulumi vs Terraform 2026: 4,800 vs 1,800 Providers and a 76% Market Share Gap [Tested]
- Terraform vs Ansible 2026: The Leading Infrastructure as Code Comparison
- How to Get Started with Docker: Complete Beginner Tutorial (2026)
- How to Deploy Applications with Kubernetes and Helm: Complete Tutorial (2026)
- How to Build a CI/CD Pipeline with GitHub Actions: Complete Tutorial (2026)
- Cloud Computing in 2026: The Guide
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 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