Terraform for Beginners: Infrastructure as Code in 2026
Infrastructure should not be something you click together and hope to remember later. Terraform lets you write your cloud resources in plain text files: servers, databases, load balancers, DNS records, all of it. Those files go into git. They get reviewed in pull requests. They run in CI pipelines. They create identical environments every time.
When something breaks, you read the file. When you need a second environment, you copy the file. When someone asks "what's running in prod?", you show them the file.
This guide teaches Terraform from zero: how it thinks, how to write your first configuration, how to run it safely, and how to handle the one thing that trips up nearly every beginner: the state file. By the end you will have a mental model that makes every Terraform error message readable, and a working template you can adapt for AWS or Azure today.
-
✓A terminal (PowerShell, bash, or zsh) and basic command-line comfort
-
✓A cloud account: AWS free tier, Azure free, or GCP free trial works fine
-
✓Terraform CLI installed:
terraform -versionshould return 1.x (download at developer.hashicorp.com/terraform/install) -
✓Cloud credentials configured (AWS:
~/.aws/credentials; Azure:az login) -
✓VS Code with the HashiCorp Terraform extension (recommended for HCL syntax highlighting)
What Is Infrastructure as Code and Why Does It Matter?
Infrastructure as Code (IaC) means your servers, databases, networks, and all the cloud plumbing that keeps your application alive are described in text files: not clicks, not bash scripts, not tribal knowledge. Every change is a commit. Every environment is reproducible. Every engineer on the team can read exactly what is deployed without logging into any console.
Govern the data behind your AI. The AI Data Governance & Quality Assessment: a checklist to keep your data trustworthy.
Your purchase helps keep our hubs free to read.
Before IaC, the dominant pattern was ClickOps: log into the AWS console, click through wizard screens, make guesses about settings, document the result in a wiki that would be out of date within a week. When the engineer who built it left, so did the knowledge of what was actually running. When the environment needed to be recreated for staging, disaster recovery, or after an accidental deletion, the process took days and produced something subtly different every time.
IaC solves this through declarative configuration: you describe the desired end-state, and the tool figures out how to get there. You do not say "run this API call to create a bucket, then run this other call to enable versioning." You say "I want a bucket named my-app-assets with versioning enabled," and Terraform handles the rest, including creating it if it does not exist, updating it if settings have changed, or leaving it alone if it already matches.
IaC vs Configuration Management: Two Different Problems
Terraform is often compared to tools like Ansible, Chef, or Puppet. They solve different problems. Terraform provisions infrastructure: it creates and manages cloud resources. Ansible configures software on existing machines: it installs packages, writes config files, starts services. Most real-world teams use both: Terraform to stand up the servers, then cloud-native tools or Ansible to configure what runs on them.
Terraform also differs from AWS CloudFormation or Azure Bicep. Those are cloud-specific. Terraform is multi-cloud by design: the same workflow provisions AWS resources, Azure resources, Cloudflare DNS records, GitHub repositories, and Datadog monitors. That breadth is why 6,683 providers exist on the Terraform Registry as of June 2026.
Declarative IaC (Terraform, CloudFormation) says what you want. Imperative IaC (bash scripts, Ansible with complex conditionals) says how to get there. Declarative is safer for infrastructure because the tool handles idempotency: running it twice produces the same result as running it once.
How Terraform Works: Providers, Resources, and State
Three core concepts must click before anything else makes sense: providers, resources, and state.
Providers
A provider is a plugin that knows how to talk to a specific API. The AWS provider knows how to create EC2 instances, S3 buckets, and RDS databases. The Azure provider manages resource groups, VMs, and blob storage. Providers are published to the Terraform Registry. You declare which ones you need, and Terraform downloads them on terraform init:
# required_providers block: Terraform >= 1.7 / OpenTofu >= 1.6
terraform {
required_version = ">= 1.7"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
Resources
A resource is a single infrastructure object. An S3 bucket is a resource. An EC2 instance is a resource. A security group is a resource. You declare resources with a type and a local name:
# resource "TYPE" "NAME" { ... }
resource "aws_s3_bucket" "app_assets" {
bucket = "my-app-assets-2026"
tags = {
Environment = "production"
Team = "platform"
ManagedBy = "terraform"
}
}
The type (aws_s3_bucket) comes from the provider. The local name (app_assets) is how you reference this resource elsewhere in your config. References look like aws_s3_bucket.app_assets.id: type dot name dot attribute.
State: The Most Important Concept
Terraform maintains a state file (terraform.tfstate) that records what it knows about your infrastructure. When you run terraform plan, Terraform compares three things: your configuration (what you want), the state (what it last saw), and the actual cloud resources (what currently exists). The plan shows exactly what changes it will make to close the gap.
State is what makes Terraform effective at managing change over time. It is also what makes it dangerous if mishandled. We cover state security in its own dedicated section.
Writing Your First Terraform Configuration
Terraform configurations are written in HashiCorp Configuration Language (HCL): a human-readable format designed specifically for infrastructure. Files use the .tf extension. Terraform loads all .tf files in a directory as a single configuration.
A minimal starter project has three files:
my-project/
├── main.tf # Resource declarations
├── variables.tf # Input variable definitions
└── outputs.tf # Output value definitions
Here is a real-world starter: an AWS S3 bucket with versioning enabled and public access blocked. This is the kind of resource every cloud project needs, and a secure pattern to build from.
# main.tf: Terraform >= 1.7 / OpenTofu >= 1.6
terraform {
required_version = ">= 1.7"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
# Primary S3 bucket
resource "aws_s3_bucket" "app" {
bucket = "${var.project_name}-${var.environment}-assets"
tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}
}
# Block all public access: secure default
resource "aws_s3_bucket_public_access_block" "app" {
bucket = aws_s3_bucket.app.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Enable versioning
resource "aws_s3_bucket_versioning" "app" {
bucket = aws_s3_bucket.app.id
versioning_configuration {
status = "Enabled"
}
}
Notice how aws_s3_bucket_public_access_block references aws_s3_bucket.app.id. This is a dependency reference. Terraform uses these to build a dependency graph and create resources in the correct order automatically: you never need to specify order manually.
# variables.tf: Terraform >= 1.7 / OpenTofu >= 1.6
variable "project_name" {
description = "Project name prefix for all resources"
type = string
}
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 "aws_region" {
description = "AWS region to deploy into"
type = string
default = "us-east-1"
}
# outputs.tf: Terraform >= 1.7 / OpenTofu >= 1.6
output "bucket_name" {
description = "S3 bucket name"
value = aws_s3_bucket.app.bucket
}
output "bucket_arn" {
description = "S3 bucket ARN for IAM policy attachment"
value = aws_s3_bucket.app.arn
}
String interpolation: "${var.project_name}-suffix"
Resource references: TYPE.NAME.ATTRIBUTE – e.g., aws_s3_bucket.app.id
Comments: # or // (single-line); /* ... */ (multi-line)
Auto-format: Run terraform fmt to normalize whitespace to the canonical style
Running Terraform: Plan, Apply, and Destroy
Every Terraform workflow uses the same four commands in sequence. Learn these deeply: they are the core loop you will run hundreds of times.
terraform init
Run this once per project, and again whenever you change provider versions or add modules. It downloads providers from the registry and initializes your backend:
terraform init
# Output:
# Initializing provider plugins...
# - Finding hashicorp/aws versions matching "~> 5.0"...
# - Installing hashicorp/aws v5.x.x...
# Terraform has been successfully initialized!
terraform plan
This is your dry run. Terraform reads your config, compares it to state, and shows exactly what it will create, update, or destroy before touching anything. Read the plan carefully before every apply:
terraform plan -var="project_name=myapp"
# Symbols in the diff:
# + resource will be CREATED
# ~ resource will be UPDATED in-place
# - resource will be DESTROYED
# -+ resource will be DESTROYED and recreated (replacement)
Save the plan to a file for exact reproducibility in CI:
terraform plan -var="project_name=myapp" -out=plan.tfplan
terraform apply plan.tfplan # applies exactly what was planned
terraform apply
This executes the plan. Without -auto-approve, it shows the plan and prompts for confirmation. In production pipelines, save a plan file and apply it rather than using -auto-approve on ad-hoc applies:
terraform apply -var="project_name=myapp"
# After reviewing the plan, Terraform prompts:
# Do you want to perform these actions?
# Enter a value: yes
# Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
terraform destroy
Destroys all resources managed by the configuration. This is non-reversible: S3 buckets with content, databases with data, VMs with their disks. Always confirm you are targeting the right environment:
terraform destroy -var="project_name=myapp"
# Always prompts for confirmation before destroying anything
Other Essential Commands
terraform validate # Check syntax without connecting to cloud APIs
terraform fmt # Auto-format all .tf files to canonical HCL style
terraform state list # List all resources tracked in current state
terraform state show aws_s3_bucket.app # Show full attributes of one resource
terraform import aws_s3_bucket.app existing-bucket-name # Import existing resource
terraform output # Print all output values from state
Use separate state backends per environment (separate S3 buckets or Terraform Cloud workspaces for dev, staging, prod), not just different variable values. If all environments share one state file, a terraform destroy on "dev" can delete prod resources. Backend-level isolation is the only safe pattern.
Managing State Files Safely (Critical Security Note)
The terraform.tfstate file stores sensitive data in plaintext. Every attribute of every managed resource is recorded: database passwords, private keys, IAM access key secrets, and any other sensitive value your resources expose. This is a documented and recurring security incident pattern across the industry. Never commit terraform.tfstate to any repository. For team use: always use an encrypted remote backend.
By default, Terraform writes state to a local terraform.tfstate file. This is acceptable for learning on a local machine, but creates two problems in team environments: anyone with file access can read your secrets, and two engineers running terraform apply simultaneously will corrupt the state file.
Remote State with S3 and DynamoDB
The most common production setup on AWS uses S3 for storage and DynamoDB for state locking:
# backend.tf: Terraform >= 1.7 / OpenTofu >= 1.6
terraform {
backend "s3" {
bucket = "my-terraform-state-prod"
key = "myapp/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
}
}
This stores state in S3 with server-side encryption via encrypt = true, and uses DynamoDB to prevent concurrent applies. The DynamoDB table stores a lock record with the key LockID, preventing two engineers from applying simultaneously.
One-Time Backend Bootstrap via AWS CLI
The S3 bucket and DynamoDB table must exist before Terraform can use them as a backend. Create them once with the AWS CLI v2:
# Create encrypted S3 state bucket
aws s3api create-bucket \
--bucket my-terraform-state-prod \
--region us-east-1
aws s3api put-bucket-encryption \
--bucket my-terraform-state-prod \
--server-side-encryption-configuration \
'{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"aws:kms"}}]}'
# Create DynamoDB lock table (LockID string key: required by Terraform)
aws dynamodb create-table \
--table-name terraform-state-lock \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--region us-east-1
State Security Best Practices
*.tfstate and *.tfstate.* to your .gitignore immediately. State contains plaintext secrets. A state file in a public repo has caused real credential compromises.sensitive = true hides them in terminal output but they remain in state as plaintext. For true secret protection, avoid putting secrets in state: use AWS Secrets Manager or HashiCorp Vault and reference ARNs instead.Variables, Outputs, and Modules: Scaling Your Config
A flat main.tf file works for one environment. When you need to deploy the same infrastructure to dev, staging, and production, or share patterns across multiple projects, you need variables, outputs, and modules working together.
Variable Files for Multi-Environment Deployments
# environments/prod.tfvars
environment = "prod"
aws_region = "us-east-1"
project_name = "myapp"
# Apply with environment-specific values:
terraform apply -var-file="environments/prod.tfvars"
For production secrets (database passwords, API keys), use environment variables injected by your CI/CD system rather than .tfvars files. Terraform reads TF_VAR_* environment variables automatically: TF_VAR_database_password=... sets the database_password variable without writing it to any file.
Outputs for Passing Data Between Stacks
Outputs expose resource attributes to other Terraform configurations or to your deployment pipeline. A common pattern: the "network" stack outputs VPC IDs and subnet IDs that the "app" stack reads as inputs. The data block (different from resource: it reads existing state rather than creating anything) is how you access another stack's outputs:
# In the network stack's outputs.tf:
output "vpc_id" {
description = "VPC ID for dependent stacks"
value = aws_vpc.main.id
}
# In the app stack's data.tf, reads the network stack's state:
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "my-terraform-state-prod"
key = "network/terraform.tfstate"
region = "us-east-1"
}
}
# Usage: data.terraform_remote_state.network.outputs.vpc_id
Modules: Reusable Infrastructure Blocks
A module is a directory of .tf files called like a function. Once you build a working S3 bucket configuration, VPC layout, or ECS cluster pattern, package it as a module and reuse it across projects:
# Calling a local module (in ./modules/s3-versioned-bucket/)
module "data_lake" {
source = "./modules/s3-versioned-bucket"
bucket_name = "my-data-lake-2026"
environment = var.environment
}
# Calling a public module from the Terraform Registry
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "my-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
enable_nat_gateway = true
}
Public registry modules like the AWS VPC module are battle-tested by thousands of teams. Check the registry before building custom modules from scratch: the solution very likely exists. Terraform integrates naturally with Kubernetes infrastructure: the kubernetes and helm providers manage cluster resources alongside cloud infra. See Terraform vs Pulumi if you want to compare IaC tool options before committing.
Terraform vs OpenTofu: Which Should You Learn?
On August 10, 2023, HashiCorp changed Terraform's license from the permissive Mozilla Public License 2.0 (MPL 2.0) to the Business Source License 1.1 (BSL/BUSL). The change restricts commercial use by entities offering "competitive services" to HashiCorp, primarily cloud platforms reselling Terraform as a managed service.
For individual engineers, startups, and companies not selling competing IaC platforms, the practical impact is minimal. Terraform remains free to use. But the license change alarmed the open-source community, and within weeks a fork was announced.
OpenTofu: The Open-Source Fork
OpenTofu launched as "OpenTF" in August 2023, was renamed OpenTofu, and was donated to the Linux Foundation (not CNCF: this is a persistent misconception). It forked from Terraform 1.5.6, the last version released under MPL 2.0. OpenTofu 1.12.0 was released May 14, 2026, fully open-source under MPL 2.0.
OpenTofu is syntax-compatible with Terraform for the vast majority of configurations. CLI commands are identical with a different binary name: tofu init, tofu plan, tofu apply. State files are compatible. Most providers work with both.
Decision Guide: Terraform or OpenTofu?
For learning: Terraform. Documentation is more mature, tutorials are more abundant, and employers ask for Terraform experience by name. The BSL does not affect individual learning use.
For teams with compliance requirements: OpenTofu. If your legal or procurement process requires all software to be under an OSI-approved open-source license, OpenTofu (MPL 2.0) is the compliant path. BSL is not an OSI-approved license.
For teams already on Terraform: Stay unless you have a specific reason to migrate. The migration is low-risk but adds maintenance overhead without immediate benefit for most teams.
The HashiCorp Terraform Associate certification tests Terraform specifically: relevant if certification is on your path. Build your cloud networking foundation first at cloud networking basics if you are provisioning VPCs for the first time.
The BSL change date of August 10, 2023 is verified against official sources. Under BSL default terms, code transitions back to open-source 4 years after each release: versions from 2023 become open-source in 2027. New releases carry their own 4-year clock. Verify current terms at developer.hashicorp.com/terraform.
Terraform cannot find any .tf files in the current working directory. You are either in the wrong folder or have not created any configuration files yet.
Fix: Run ls *.tf to confirm you are in the right directory. Create at least one .tf file before running terraform init.
Terraform cannot authenticate with your cloud provider. Credentials are not configured or have expired. Never hardcode secrets in .tf files.
Fix for AWS: Run aws configure or export AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. For Azure, run az login.
Terraform is trying to create a resource that already exists but is not tracked in state. This happens when infrastructure was created manually outside of Terraform.
Fix: Use terraform import to bring the existing resource into state management, then run terraform plan to confirm.
You changed your backend configuration and Terraform requires re-initialization. This happens when switching from local state to a remote backend such as S3.
Fix: Run terraform init -reconfigure. To migrate existing state to the new backend: terraform init -migrate-state.
Another Terraform process holds the state lock, or a previous run crashed without releasing it. Terraform uses state locking to prevent concurrent applies from corrupting infrastructure state.
Fix: Confirm no other apply is running. If the lock is genuinely stale, use Terraform's built-in state lock release command with the lock ID printed in the error message. Reference: developer.hashicorp.com/terraform/language/state/locking.