Skip to main content

Terraform Complete Tutorial:

 

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:

hcl
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:

bash
brew install terraform

Linux (Ubuntu/Debian):

bash
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):

powershell
choco install terraform

Verify installation:

bash
terraform version

Configure AWS Credentials

Create an IAM user in AWS with programmatic access. Then set environment variables:

bash
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

bash
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:

hcl
# 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

bash
terraform init
terraform plan
terraform apply

Type yes when prompted. Your S3 bucket is now created!

bash
terraform destroy  # Clean up when done

Part 3: Understanding HCL Syntax

Blocks, Arguments, and Identifiers

Every Terraform file has three building blocks:

hcl
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

TypeExampleWhen to Use
string"hello"Text values
number42, 3.14Numeric values
booltrue, falseYes/no settings
list["a", "b", "c"]Ordered collection
map{name = "john", age = 30}Key-value pairs
object{name = string, age = number}Structured data

Expressions

hcl
# 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.

json
// 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)

hcl
# 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

bash
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

hcl
# 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:

bash
# 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

hcl
# 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)

hcl
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)

hcl
# 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)

hcl
# 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)

hcl
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

text
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:

hcl
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:

hcl
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:

hcl
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

hcl
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

hcl
# 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.

hcl
# 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)

hcl
# 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)

hcl
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

hcl
# 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

bash
# 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

yaml
# .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

bash
# ❌ NEVER
variable "db_password" {
  default = "SuperSecret123"
}

# ✅ DO THIS
variable "db_password" {
  sensitive = true
}
# Pass via TF_VAR_db_password environment variable

Use Secrets Manager

hcl
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

bash
# Checkov - security scanning
checkov -d .

# tfsec - focused security scanner
tfsec .

# tflint - Terraform linter
tflint

Part 14: Common Commands Cheat Sheet

bash
# 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

hcl
# 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

ErrorLikely CauseSolution
Invalid credentialsAWS keys missing/wrongCheck AWS_ACCESS_KEY_ID env vars
Resource already existsName conflictUse unique names or random_string
Error acquiring state lockAnother terraform runningWait or terraform force-unlock
Invalid for_each argumentValue is list, not map/setUse tomap() or toset()
Cycle: aws_vpc.main, aws_subnet.privateCircular dependencyRestructure or use depends_on
Backend configuration changedBackend config updatedterraform init -reconfigure

Quick Reference Card

bash
# 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