A simple, easy-language guide to learn Terraform from scratch and use it in real production projects.
Terraform is a tool made by HashiCorp that lets you create, manage, and destroy cloud infrastructure (servers, databases, networks, etc.) using code instead of clicking buttons in a console.
.tf files describing what you want, and Terraform creates it for you.Simple example: Instead of going to AWS console and manually creating an EC2 server, you write a file saying "I want 1 EC2 server with this configuration" and run one command.
| Old Way (Manual) | Terraform Way (IaC) |
|---|---|
| Click in console | Write code |
| Hard to repeat | Easy to repeat (copy-paste config) |
| No history of changes | Code is in Git, full history |
| Human errors common | Consistent every time |
| Hard to destroy everything | One command destroys everything |
Benefits:
brew install terraform
/usr/local/bin)terraform -version
Terraform always follows these steps:
Write Code → terraform init → terraform plan → terraform apply → terraform destroy
| Command | What it does |
|---|---|
terraform init |
Downloads providers/plugins, sets up working directory |
terraform plan |
Shows what will be created/changed/deleted (dry run) |
terraform apply |
Actually creates/updates the infrastructure |
terraform destroy |
Deletes everything Terraform created |
Mind it: Always run plan before apply to see what's changing. Never skip this in production.
A typical project has these files:
project/
├── main.tf # main resources
├── variables.tf # input variable definitions
├── outputs.tf # output values
├── providers.tf # provider configuration
├── terraform.tfvars # variable values
└── versions.tf # terraform & provider version constraints
All .tf files in the same folder are automatically combined by Terraform — file names don't matter, but this naming convention is standard practice.
A provider is a plugin that lets Terraform talk to a specific cloud (AWS, Azure, GCP, etc.)
# providers.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "ap-south-1"
}
required_providers tells Terraform which provider and version to download.provider "aws" block configures it (region, credentials, etc.)A resource is the actual infrastructure object you want to create — server, database, bucket, etc.
resource "aws_instance" "my_server" {
ami = "ami-0abcdef1234567890"
instance_type = "t2.micro"
tags = {
Name = "MyFirstServer"
}
}
Syntax breakdown:
resource "<PROVIDER_TYPE>" "<LOCAL_NAME>" {
argument = value
}
aws_instance = resource type (defined by AWS provider)my_server = your local name (used to refer to it within Terraform code)aws_instance.my_server.idVariables make your code reusable instead of hardcoding values.
# variables.tf
variable "instance_type" {
description = "Type of EC2 instance"
type = string
default = "t2.micro"
}
variable "region" {
type = string
default = "ap-south-1"
}
Use it in main.tf:
resource "aws_instance" "my_server" {
ami = "ami-0abcdef1234567890"
instance_type = var.instance_type
}
Set values 3 ways:
instance_type = "t3.medium"
terraform apply -var="instance_type=t3.medium"
export TF_VAR_instance_type="t3.medium"
Outputs show useful information after apply — like the IP address of a created server.
# outputs.tf
output "instance_ip" {
description = "Public IP of EC2 instance"
value = aws_instance.my_server.public_ip
}
After apply, you'll see:
Outputs:
instance_ip = "13.234.56.78"
View again anytime:
terraform output
terraform output instance_ip
data blocks let you read/fetch existing infrastructure info without managing it.
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t2.micro"
}
Use case: Getting latest AMI ID, existing VPC ID, existing subnet ID, etc.
Terraform keeps track of everything it creates in a file called terraform.tfstate.
terraform state list # list all resources in state
terraform state show <resource> # show details of one resource
⚠️ Local state (default) is dangerous for teams — only one person can work at a time, and if the file is lost, Terraform "forgets" everything it created. This is why production uses remote backend (next section).
Instead of storing terraform.tfstate on your laptop, store it in a shared remote location like AWS S3.
# backend.tf
terraform {
backend "s3" {
bucket = "my-terraform-state-bucket"
key = "project/terraform.tfstate"
region = "ap-south-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
Why this matters in production:
After adding/changing backend config, run:
terraform init -reconfigure
When someone runs terraform apply, Terraform locks the state so no one else can run apply at the same time (prevents conflicts/corruption).
terraform force-unlock <LOCK_ID>
⚠️ Only use force-unlock if you're 100% sure no one else is running apply.
A module is a reusable, self-contained group of .tf files — like a function in programming.
modules/
└── ec2-instance/
├── main.tf
├── variables.tf
└── outputs.tf
main.tf (root - calls the module)
resource "aws_instance" "this" {
ami = var.ami
instance_type = var.instance_type
tags = {
Name = var.name
}
}
variable "ami" {}
variable "instance_type" {}
variable "name" {}
module "web_server" {
source = "./modules/ec2-instance"
ami = "ami-0abcdef1234567890"
instance_type = "t2.micro"
name = "WebServer"
}
Why use modules:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.0.0"
# ...inputs
}
Workspaces let you use the same code to manage multiple environments with separate state files.
terraform workspace new dev
terraform workspace new prod
terraform workspace list
terraform workspace select dev
In code, reference current workspace:
resource "aws_instance" "server" {
instance_type = terraform.workspace == "prod" ? "t3.large" : "t2.micro"
}
⚠️ Note: Many production teams prefer separate folders per environment (covered in section 24) over workspaces, because workspaces share the same backend config and can be risky if mistakes happen (e.g., applying prod changes while in dev workspace by accident).
Provisioners run scripts/commands on a resource after creation. Use only as last resort — prefer cloud-init, user_data, or configuration management tools (Ansible) instead.
resource "aws_instance" "web" {
ami = "ami-0abcdef1234567890"
instance_type = "t2.micro"
provisioner "remote-exec" {
inline = [
"sudo apt update",
"sudo apt install -y nginx"
]
}
}
Common types:
local-exec – runs command on your machine (where Terraform runs)remote-exec – runs command on the created resource (e.g., EC2 via SSH)Terraform has built-in functions for strings, numbers, lists, maps, etc.
# String functions
upper("hello") # "HELLO"
lower("HELLO") # "hello"
join(",", ["a","b","c"]) # "a,b,c"
split(",", "a,b,c") # ["a","b","c"]
# Numeric
max(5, 10, 3) # 10
min(5, 10, 3) # 3
# Collections
length(["a","b","c"]) # 3
contains(["a","b"], "a") # true
# File
file("user_data.sh") # reads file content as string
# Type conversion
tostring(123)
tonumber("123")
resource "aws_instance" "server" {
count = 3
ami = "ami-0abcdef1234567890"
instance_type = "t2.micro"
tags = {
Name = "Server-${count.index}"
}
}
variable "servers" {
default = {
web = "t2.micro"
db = "t3.medium"
}
}
resource "aws_instance" "server" {
for_each = var.servers
ami = "ami-0abcdef1234567890"
instance_type = each.value
tags = {
Name = each.key
}
}
variable "names" {
default = ["web", "db", "cache"]
}
output "upper_names" {
value = [for n in var.names : upper(n)]
}
# Output: ["WEB", "DB", "CACHE"]
count vs for_each: Use for_each when items can be identified by unique keys (avoids issues when removing items from middle of a list with count).
Terraform's ternary operator: condition ? true_value : false_value
variable "environment" {
default = "dev"
}
resource "aws_instance" "server" {
ami = "ami-0abcdef1234567890"
instance_type = var.environment == "prod" ? "t3.large" : "t2.micro"
}
Conditionally create a resource (using count):
resource "aws_instance" "extra_server" {
count = var.environment == "prod" ? 1 : 0
ami = "ami-0abcdef1234567890"
instance_type = "t2.micro"
}
If a resource was created manually (outside Terraform), you can bring it under Terraform's management.
terraform import aws_instance.my_server i-0abcd1234efgh5678
Steps:
resource block in your .tf file (can be empty initially)terraform import <resource_address> <real_resource_id>terraform plan and adjust your code until plan shows no changes| Command | Purpose |
|---|---|
terraform init |
Initialize working directory, download providers |
terraform validate |
Check syntax errors |
terraform fmt |
Auto-format code nicely |
terraform plan |
Preview changes |
terraform apply |
Apply changes |
terraform apply -auto-approve |
Apply without confirmation prompt |
terraform destroy |
Delete all managed resources |
terraform show |
Show current state in readable format |
terraform state list |
List resources in state |
terraform output |
Show output values |
terraform graph |
Generate dependency graph |
terraform taint <resource> |
Mark resource for recreation on next apply |
terraform refresh |
Sync state with real infrastructure |
terraform workspace list |
List workspaces |
.tfstate or .tfvars with secrets to Git.gitignore:
*.tfstate
*.tfstate.backup
.terraform/
*.tfvars
~> 5.0) — avoid surprise breaking changesterraform plan and review before applyterraform fmt and terraform validate in CI before merging codeterraform-project/
├── modules/
│ ├── vpc/
│ ├── ec2/
│ ├── rds/
│ └── s3/
│
├── environments/
│ ├── dev/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── terraform.tfvars
│ │ └── backend.tf
│ ├── staging/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── terraform.tfvars
│ │ └── backend.tf
│ └── prod/
│ ├── main.tf
│ ├── variables.tf
│ ├── terraform.tfvars
│ └── backend.tf
│
└── README.md
Each environment folder calls the shared modules with different variable values.
module "vpc" {
source = "../../modules/vpc"
cidr = "10.0.0.0/16"
env_name = "dev"
}
module "ec2" {
source = "../../modules/ec2"
instance_type = "t2.micro"
env_name = "dev"
}
module "vpc" {
source = "../../modules/vpc"
cidr = "10.1.0.0/16"
env_name = "prod"
}
module "ec2" {
source = "../../modules/ec2"
instance_type = "t3.large"
env_name = "prod"
}
terraform {
backend "s3" {
bucket = "my-tf-state"
key = "dev/terraform.tfstate"
region = "ap-south-1"
}
}
terraform {
backend "s3" {
bucket = "my-tf-state"
key = "prod/terraform.tfstate"
region = "ap-south-1"
}
}
Result: Same module code, different state files, different sizes/configs per environment. Applying to dev never affects prod.
Never do this:
# BAD - hardcoded password
resource "aws_db_instance" "db" {
password = "MySuperSecret123"
}
Better options:
export TF_VAR_db_password="MySuperSecret123"
variable "db_password" {
type = string
sensitive = true
}
data "aws_secretsmanager_secret_version" "db_password" {
secret_id = "prod/db/password"
}
resource "aws_db_instance" "db" {
password = data.aws_secretsmanager_secret_version.db_password.secret_string
}
variable "db_password" {
type = string
sensitive = true # hides value in plan/apply output
}
Example using GitHub Actions:
# .github/workflows/terraform.yml
name: Terraform CI/CD
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Terraform Init
run: terraform init
working-directory: environments/prod
- name: Terraform Format Check
run: terraform fmt -check
working-directory: environments/prod
- name: Terraform Validate
run: terraform validate
working-directory: environments/prod
- name: Terraform Plan
run: terraform plan
working-directory: environments/prod
- name: Terraform Apply
if: github.ref == 'refs/heads/main'
run: terraform apply -auto-approve
working-directory: environments/prod
Flow in real teams:
fmt, validate, plan automatically — plan output posted as PR commentmain, CI runs apply automatically (or with manual approval gate for prod)Terraform Cloud (by HashiCorp) is a managed service that provides:
terraform {
cloud {
organization = "my-company"
workspaces {
name = "prod-infra"
}
}
}
| Error | Cause | Fix |
|---|---|---|
Error: No valid credential sources found |
AWS credentials not set | Run aws configure or set env vars |
Error acquiring the state lock |
Previous apply crashed, lock stuck | terraform force-unlock <ID> (be careful) |
Resource already exists |
Resource created outside Terraform | Use terraform import |
Error: Provider configuration not present |
Forgot required_providers block |
Add provider config and run init |
| Plan shows changes every time (drift) | Resource changed manually outside Terraform | Run terraform refresh or fix manually then re-apply |
Backend configuration changed |
Modified backend.tf | Run terraform init -reconfigure |
A simple complete project to practice — creates an EC2 instance with a security group:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.region
}
resource "aws_security_group" "web_sg" {
name = "web-sg"
description = "Allow HTTP and SSH"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 80
to_port = 80
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"]
}
}
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.web_sg.id]
tags = {
Name = "web-server-${var.environment}"
Environment = var.environment
}
}
variable "region" {
default = "ap-south-1"
}
variable "ami_id" {
default = "ami-0abcdef1234567890"
}
variable "instance_type" {
default = "t2.micro"
}
variable "environment" {
default = "dev"
}
output "instance_public_ip" {
value = aws_instance.web.public_ip
}
output "security_group_id" {
value = aws_security_group.web_sg.id
}
terraform init
terraform fmt
terraform validate
terraform plan
terraform apply
# ... test it ...
terraform destroy
init/plan/apply/destroycount, for_each) and conditionalsHappy Terraforming! 🚀