Terraform Complete Tutorial: From Zero to Production
📅 Published: June 2026
⏱️ Estimated Reading Time: 35 minutes
🏷️ Tags: Terraform, Infrastructure as Code, DevOps, Complete Tutorial, IaC
Introduction: What is Terraform?
Terraform is an open-source Infrastructure as Code (IaC) tool created by HashiCorp. It allows you to define and provision cloud infrastructure using a simple, declarative configuration language called HCL (HashiCorp Configuration Language).
Why Terraform matters:
You write code, not click buttons in cloud consoles
Your infrastructure is versioned in Git
Changes are reviewed via pull requests
You can destroy and recreate everything reliably
Works across AWS, Azure, GCP, and 1000+ providers
Real-world example: Instead of clicking through AWS Console to create a server, you write:
resource "aws_instance" "web" { ami = "ami-0c55b159cbfafe1f0" instance_type = "t2.micro" }
Then run terraform apply and the server appears.
Part 1: Installation and Setup
Install Terraform
macOS:
brew install terraformLinux (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
Windows (Chocolatey):
choco install terraform
Verify installation:
terraform version
Configure AWS Credentials
Create an IAM user in AWS with programmatic access. Then set environment variables:
export AWS_ACCESS_KEY_ID="your-access-key" export AWS_SECRET_ACCESS_KEY="your-secret-key" export AWS_DEFAULT_REGION="us-east-1"
Part 2: Your First Terraform Configuration
The Core Commands
terraform init # Downloads providers, initializes working directory terraform plan # Shows what will change (preview) terraform apply # Creates/modifies infrastructure terraform destroy # Deletes all resources terraform fmt # Formats code consistently terraform validate # Checks syntax
Your First Configuration
Create a file called main.tf:
# main.tf - My first Terraform configuration terraform { required_version = ">= 1.5.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } } } provider "aws" { region = "us-east-1" } # Create an S3 bucket resource "aws_s3_bucket" "my_first_bucket" { bucket = "my-unique-bucket-name-12345" tags = { Name = "My First Terraform Bucket" Environment = "Learning" ManagedBy = "Terraform" } } # Output the bucket name output "bucket_name" { value = aws_s3_bucket.my_first_bucket.bucket }
Run It
terraform init terraform plan terraform apply
Type yes when prompted. Your S3 bucket is now created!
terraform destroy # Clean up when donePart 3: Understanding HCL Syntax
Blocks, Arguments, and Identifiers
Every Terraform file has three building blocks:
resource "aws_instance" "web_server" { # Block type + labels ami = "ami-12345" # Argument (key = value) instance_type = "t2.micro" # Argument tags = { # Map argument Name = "web-server" } }
Data Types
| Type | Example | When to Use |
|---|---|---|
| string | "hello" | Text values |
| number | 42, 3.14 | Numeric values |
| bool | true, false | Yes/no settings |
| list | ["a", "b", "c"] | Ordered collection |
| map | {name = "john", age = 30} | Key-value pairs |
| object | {name = string, age = number} | Structured data |
Expressions
# String interpolation bucket = "my-app-${var.environment}-data" # Conditional instance_type = var.env == "prod" ? "t3.large" : "t2.micro" # Functions subnet_ids = aws_subnet.public[*].id # Splat expression
Part 4: Terraform State
What is State?
State is Terraform's memory. It maps your configuration to real infrastructure.
// terraform.tfstate (simplified) { "resources": [ { "type": "aws_instance", "name": "web", "instances": [{ "attributes": { "id": "i-1234567890abcdef0", "public_ip": "54.123.45.67" } }] } ] }
Remote State (ALWAYS use this for teams)
# backend.tf terraform { backend "s3" { bucket = "my-company-terraform-state" key = "prod/network/terraform.tfstate" region = "us-east-1" dynamodb_table = "terraform-state-locks" encrypt = true } }
Why remote state matters:
Team members share the same state
State locking prevents corruption
Encrypted and backed up
Auditable via CloudTrail
State Commands
terraform state list # List all resources terraform state show aws_instance.web # Show resource details terraform state mv old_name new_name # Rename resource in state terraform state rm aws_s3_bucket.old # Remove from state (doesn't delete)
Part 5: Variables and Outputs
Input Variables
# variables.tf variable "environment" { description = "Deployment environment" type = string default = "dev" } variable "instance_type" { description = "EC2 instance type" type = string # No default = required } variable "tags" { description = "Resource tags" type = map(string) default = { Environment = "dev" ManagedBy = "Terraform" } } # With validation variable "environment" { type = string validation { condition = contains(["dev", "staging", "prod"], var.environment) error_message = "Environment must be dev, staging, or prod." } } # Sensitive variable (for secrets) variable "db_password" { type = string sensitive = true }
Setting variable values:
# Command line terraform apply -var="instance_type=t3.micro" # Environment variable export TF_VAR_instance_type="t3.micro" # terraform.tfvars file (gitignored) instance_type = "t3.micro" environment = "production" # terraform.tfvars.example (committed) instance_type = "t2.micro" # Example value environment = "dev"
Output Values
# outputs.tf output "instance_ip" { description = "Public IP of EC2 instance" value = aws_instance.web.public_ip } output "bucket_arn" { value = aws_s3_bucket.data.arn } output "instance_ids" { value = aws_instance.web[*].id # List of IDs } # Sensitive output output "db_password" { value = random_password.db.result sensitive = true }
Local Values (Internal Calculations)
locals { # Derived values name_prefix = "${var.project}-${var.environment}" common_tags = { Environment = var.environment ManagedBy = "Terraform" Project = var.project } # Conditional logic instance_type = var.environment == "prod" ? "t3.large" : "t2.micro" instance_count = var.environment == "prod" ? 3 : 1 } # Usage resource "aws_instance" "web" { count = local.instance_count instance_type = local.instance_type tags = local.common_tags }
Part 6: Loops and Conditionals
Count (Simple Loop)
# Create multiple instances resource "aws_instance" "web" { count = 3 ami = "ami-0c55b159cbfafe1f0" instance_type = "t2.micro" tags = { Name = "web-server-${count.index + 1}" } } # Access instances # aws_instance.web[0], aws_instance.web[1], aws_instance.web[2] # Conditional resource (create if true) resource "aws_instance" "bastion" { count = var.create_bastion ? 1 : 0 ami = "ami-0c55b159cbfafe1f0" instance_type = "t2.micro" }
For_Each (Stable, Recommended)
# Create from a map variable "users" { type = map(object({ groups = list(string) path = string })) default = { "alice" = { groups = ["developers", "ops"] path = "/" } "bob" = { groups = ["developers"] path = "/" } } } resource "aws_iam_user" "this" { for_each = var.users name = each.key path = each.value.path }
Dynamic Blocks (For Nested Configurations)
variable "ingress_rules" { type = list(object({ port = number cidr_blocks = list(string) })) } resource "aws_security_group" "web" { name = "web-sg" 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 } } }
Part 7: Modules (Reusable Infrastructure)
What is a Module?
A module is a container for multiple resources. It's how you share and reuse infrastructure code.
Module Structure
modules/aws-vpc/ ├── main.tf # Resource definitions ├── variables.tf # Input variables ├── outputs.tf # Return values └── README.md # Documentation
Creating a Module
modules/aws-vpc/variables.tf:
variable "name" { description = "VPC name" type = string } variable "vpc_cidr" { description = "CIDR block for VPC" type = string default = "10.0.0.0/16" } variable "enable_nat_gateway" { description = "Enable NAT gateway" type = bool default = false }
modules/aws-vpc/main.tf:
resource "aws_vpc" "this" { cidr_block = var.vpc_cidr enable_dns_hostnames = true enable_dns_support = true tags = { Name = var.name } } resource "aws_subnet" "public" { count = length(var.public_subnet_cidrs) vpc_id = aws_vpc.this.id cidr_block = var.public_subnet_cidrs[count.index] tags = { Name = "${var.name}-public-${count.index + 1}" } }
modules/aws-vpc/outputs.tf:
output "vpc_id" { description = "ID of the VPC" value = aws_vpc.this.id } output "subnet_ids" { description = "IDs of public subnets" value = aws_subnet.public[*].id }
Using the Module
module "vpc" { source = "./modules/aws-vpc" name = "production" vpc_cidr = "10.0.0.0/16" } # Reference outputs resource "aws_instance" "web" { subnet_id = module.vpc.subnet_ids[0] # ... }
Remote Modules
# From Terraform Registry module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "5.0.0" name = "my-vpc" cidr = "10.0.0.0/16" } # From Git module "vpc" { source = "git::https://github.com/company/terraform-aws-vpc.git?ref=v1.2.0" }
Part 8: Data Sources
What are Data Sources?
Data sources read information from providers without creating anything.
# Get the latest Ubuntu AMI data "aws_ami" "ubuntu" { most_recent = true filter { name = "name" values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"] } owners = ["099720109477"] } # Use it resource "aws_instance" "web" { ami = data.aws_ami.ubuntu.id instance_type = "t2.micro" } # Get existing VPC data "aws_vpc" "existing" { filter { name = "tag:Name" values = ["production-vpc"] } } # Get availability zones data "aws_availability_zones" "available" { state = "available" }
Part 9: Dependencies
Implicit Dependencies (Automatic)
# Terraform knows: subnet depends on VPC resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" } resource "aws_subnet" "public" { vpc_id = aws_vpc.main.id # ← Implicit dependency cidr_block = "10.0.1.0/24" }
Explicit Dependencies (depends_on)
resource "aws_s3_bucket" "data" { bucket = "my-app-data" # Policy references the bucket in its ARN # But the policy doesn't reference the bucket directly depends_on = [aws_s3_bucket_policy.data_policy] }
Part 10: Terraform Functions
Common Functions
# String functions lower("HELLO") # "hello" upper("hello") # "HELLO" format("instance-%03d", 5) # "instance-005" join("-", ["a", "b", "c"]) # "a-b-c" # List functions length(["a", "b", "c"]) # 3 element(["a", "b", "c"], 1) # "b" slice(["a", "b", "c"], 0, 2) # ["a", "b"] concat([1, 2], [3, 4]) # [1, 2, 3, 4] # Map functions keys({name = "John", age = 30}) # ["name", "age"] values({name = "John", age = 30}) # ["John", 30] merge({a = 1}, {b = 2}) # {a = 1, b = 2} # File functions file("${path.module}/user_data.sh") fileexists("${path.module}/config.json") # JSON/YAML jsonencode({name = "John", age = 30}) yamlencode({name = "John"}) # Network cidrsubnet("10.0.0.0/16", 8, 2) # "10.0.2.0/24"
Part 11: Terraform Workspaces
Managing Multiple Environments
# Create workspaces terraform workspace new dev terraform workspace new staging terraform workspace new prod # Switch workspaces terraform workspace select prod # List workspaces terraform workspace list # Use workspace name in configuration resource "aws_s3_bucket" "data" { bucket = "my-app-${terraform.workspace}" }
⚠️ Warning: Workspaces share provider configuration. For different AWS accounts, use directory structure instead.
Part 12: Terraform in CI/CD
GitHub Actions Example
# .github/workflows/terraform.yml name: Terraform on: pull_request: branches: [ main ] push: branches: [ main ] jobs: terraform: runs-on: ubuntu-latest environment: ${{ github.ref == 'refs/heads/main' && 'prod' || 'dev' }} steps: - uses: actions/checkout@v4 - name: Setup Terraform uses: hashicorp/setup-terraform@v3 with: terraform_version: 1.6.0 - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/terraform aws-region: us-west-2 - name: Terraform Init run: terraform init - name: Terraform Plan id: plan run: terraform plan -no-color continue-on-error: true - name: Comment Plan on PR uses: actions/github-script@v6 if: github.event_name == 'pull_request' with: script: | github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: `#### Terraform Plan 📖\n\`\`\`\n${process.env.PLAN}\n\`\`\`` }) env: PLAN: ${{ steps.plan.outputs.stdout }} - name: Terraform Apply if: github.ref == 'refs/heads/main' && github.event_name == 'push' run: terraform apply -auto-approve
Part 13: Security Best Practices
Never Commit Secrets
# ❌ NEVER variable "db_password" { default = "SuperSecret123" } # ✅ DO THIS variable "db_password" { sensitive = true } # Pass via TF_VAR_db_password environment variable
Use Secrets Manager
data "aws_secretsmanager_secret" "db_password" { name = "prod/database/password" } data "aws_secretsmanager_secret_version" "db_password" { secret_id = data.aws_secretsmanager_secret.db_password.id } resource "aws_db_instance" "main" { password = data.aws_secretsmanager_secret_version.db_password.secret_string }
Static Analysis
# Checkov - security scanning checkov -d . # tfsec - focused security scanner tfsec . # tflint - Terraform linter tflint
Part 14: Common Commands Cheat Sheet
# Setup terraform init # Initialize directory terraform init -reconfigure # Reconfigure backend terraform init -upgrade # Upgrade providers # Validation terraform fmt # Format code terraform fmt -check # Check formatting terraform validate # Validate syntax # Planning terraform plan # Show changes terraform plan -out=tfplan # Save plan to file terraform plan -var="key=value" # Set variable # Apply terraform apply # Apply changes (prompts) terraform apply -auto-approve # Apply without prompt terraform apply tfplan # Apply saved plan # Destroy terraform destroy # Delete all resources terraform destroy -target=aws_instance.web # Delete specific resource # State terraform state list # List resources terraform state show aws_instance.web # Show resource terraform state mv old new # Move resource terraform state rm aws_s3_bucket.old # Remove from state # Workspaces terraform workspace new dev # Create workspace terraform workspace select dev # Switch workspace terraform workspace list # List workspaces # Output terraform output # Show outputs terraform output -json # JSON format terraform output instance_ip # Specific output
Part 15: Real-World Examples
Example 1: Complete Web Application
# variables.tf variable "environment" { type = string default = "dev" } variable "instance_count" { type = number default = 2 } # main.tf terraform { required_version = ">= 1.5" required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } } } provider "aws" { region = "us-west-2" } # VPC resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" tags = { Name = "${var.environment}-vpc" } } # Subnets resource "aws_subnet" "public" { count = 2 vpc_id = aws_vpc.main.id cidr_block = "10.0.${count.index}.0/24" tags = { Name = "${var.environment}-public-${count.index + 1}" } } # Security Group resource "aws_security_group" "web" { name = "${var.environment}-web-sg" vpc_id = aws_vpc.main.id 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"] } } # EC2 Instances resource "aws_instance" "web" { count = var.instance_count ami = data.aws_ami.amazon_linux.id instance_type = "t2.micro" subnet_id = aws_subnet.public[count.index % 2].id vpc_security_group_ids = [aws_security_group.web.id] tags = { Name = "${var.environment}-web-${count.index + 1}" } } # Data source for AMI data "aws_ami" "amazon_linux" { most_recent = true owners = ["amazon"] filter { name = "name" values = ["amzn2-ami-hvm-*-x86_64-gp2"] } } # Load Balancer resource "aws_lb" "web" { name = "${var.environment}-lb" internal = false load_balancer_type = "application" security_groups = [aws_security_group.web.id] subnets = aws_subnet.public[*].id } resource "aws_lb_target_group" "web" { name = "${var.environment}-tg" port = 80 protocol = "HTTP" vpc_id = aws_vpc.main.id } resource "aws_lb_listener" "web" { load_balancer_arn = aws_lb.web.arn port = 80 protocol = "HTTP" default_action { type = "forward" target_group_arn = aws_lb_target_group.web.arn } } resource "aws_lb_target_group_attachment" "web" { count = var.instance_count target_group_arn = aws_lb_target_group.web.arn target_id = aws_instance.web[count.index].id port = 80 } # Outputs output "load_balancer_dns" { value = aws_lb.web.dns_name } output "instance_ips" { value = aws_instance.web[*].public_ip }
Part 16: Troubleshooting Common Errors
| Error | Likely Cause | Solution |
|---|---|---|
Invalid credentials | AWS keys missing/wrong | Check AWS_ACCESS_KEY_ID env vars |
Resource already exists | Name conflict | Use unique names or random_string |
Error acquiring state lock | Another terraform running | Wait or terraform force-unlock |
Invalid for_each argument | Value is list, not map/set | Use tomap() or toset() |
Cycle: aws_vpc.main, aws_subnet.private | Circular dependency | Restructure or use depends_on |
Backend configuration changed | Backend config updated | terraform init -reconfigure |
Quick Reference Card
# Essential commands terraform init # First command terraform plan # Always before apply terraform apply # Create/update terraform destroy # Delete everything # Key concepts # Block = container with { } # Argument = name = value # Resource = actual infrastructure # Data source = read-only info # Module = reusable component # State = Terraform's memory
Learn More
Practice Terraform with hands-on exercises in our interactive labs:
https://devops.trainwithsky.com/
Comments
Post a Comment