Infrastructure as Code with Terraform: Beginner Guide

What Is Infrastructure as Code?

Infrastructure as Code (IaC) means managing servers, networks, and cloud services through configuration files instead of manual console clicks. Terraform, created by HashiCorp, is the most widely adopted IaC tool because it works across every major cloud provider: AWS, Azure, GCP, and dozens of others using the same language and workflow.

Terraform uses a declarative language called HCL (HashiCorp Configuration Language). You describe the desired end state of your infrastructure, and Terraform figures out the steps to get there. It tracks the current state in a state file, computes the difference between desired and actual, and applies only the necessary changes. This guide will take you from installing Terraform to deploying a complete VPC with EC2 instances on AWS.

Installation and Setup

Install Terraform and configure AWS credentials before writing any code.

ADVERTISEMENT
# Install Terraform (Linux/macOS)
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

# Configure AWS CLI (Terraform uses these credentials)
aws configure
# Enter: AWS Access Key ID, Secret Key, Region (ap-south-1 for Mumbai)

Project Structure

Organize your Terraform files by concern. Here’s the structure we’ll build:

infra/
  main.tf          # Provider and backend configuration
  variables.tf     # Input variable declarations
  vpc.tf           # VPC, subnets, route tables
  security.tf      # Security groups
  compute.tf       # EC2 instances
  outputs.tf       # Output values
  terraform.tfvars # Variable values (DO NOT commit secrets)

Provider and Backend Configuration

Start with main.tf. This defines which cloud provider to use and where to store state:

terraform {
  required_version = ">= 1.7.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }

  # Remote state storage (recommended for teams)
  backend "s3" {
    bucket         = "my-terraform-state-bucket"
    key            = "prod/infrastructure.tfstate"
    region         = "ap-south-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Environment = var.environment
      ManagedBy   = "terraform"
      Project     = var.project_name
    }
  }
}

Variables and Configuration

Define all configurable values in variables.tf:

variable "aws_region" {
  description = "AWS region for resources"
  type        = string
  default     = "ap-south-1"
}

variable "environment" {
  description = "Environment name (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 "project_name" {
  description = "Project name used for resource naming"
  type        = string
  default     = "byteyogi"
}

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"
}

variable "key_pair_name" {
  description = "Name of the SSH key pair"
  type        = string
}

Set values in terraform.tfvars:

aws_region    = "ap-south-1"
environment   = "dev"
project_name  = "byteyogi"
key_pair_name = "my-ssh-key"
instance_type = "t3.micro"

Building the VPC

A VPC is the foundation of your AWS network. Save this as vpc.tf:

data "aws_availability_zones" "available" {
  state = "available"
}

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "${var.project_name}-${var.environment}-vpc"
  }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${var.project_name}-${var.environment}-igw"
  }
}

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}"
  }
}

resource "aws_subnet" "private" {
  count             = 2
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
  availability_zone = data.aws_availability_zones.available.names[count.index]

  tags = {
    Name = "${var.project_name}-private-${count.index + 1}"
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }

  tags = {
    Name = "${var.project_name}-public-rt"
  }
}

resource "aws_route_table_association" "public" {
  count          = 2
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

Security Groups and EC2 Instances

Define firewall rules in security.tf and launch an instance in compute.tf:

# security.tf
resource "aws_security_group" "web" {
  name        = "${var.project_name}-web-sg"
  description = "Allow HTTP, HTTPS, and SSH"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "HTTP"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "HTTPS"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "SSH"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["YOUR_IP/32"]  # Replace with your IP
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

# compute.tf
data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"]  # Canonical

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-*-24.04-amd64-server-*"]
  }
}

resource "aws_instance" "web" {
  ami                    = data.aws_ami.ubuntu.id
  instance_type          = var.instance_type
  key_name               = var.key_pair_name
  subnet_id              = aws_subnet.public[0].id
  vpc_security_group_ids = [aws_security_group.web.id]

  root_block_device {
    volume_size = 20
    volume_type = "gp3"
    encrypted   = true
  }

  user_data = <<-EOF
    #!/bin/bash
    apt-get update -y
    apt-get install -y nginx
    systemctl start nginx
    systemctl enable nginx
  EOF

  tags = {
    Name = "${var.project_name}-web-server"
  }
}

Outputs and the Terraform Workflow

Define useful outputs in outputs.tf, then run the standard Terraform workflow:

# outputs.tf
output "vpc_id" {
  value = aws_vpc.main.id
}

output "public_subnet_ids" {
  value = aws_subnet.public[*].id
}

output "web_server_public_ip" {
  value = aws_instance.web.public_ip
}

output "web_server_public_dns" {
  value = aws_instance.web.public_dns
}
# Initialize Terraform (downloads providers, sets up backend)
terraform init

# Format code consistently
terraform fmt -recursive

# Validate syntax
terraform validate

# Preview changes (ALWAYS do this before apply)
terraform plan -out=tfplan

# Apply the plan
terraform apply tfplan

# View current state
terraform state list

# Destroy everything when done
terraform destroy

Conclusion

You’ve built a complete AWS infrastructure with Terraform: VPC with public and private subnets, an internet gateway, security groups, and an EC2 instance running Nginx. Every resource is version-controlled, reproducible, and can be torn down with a single command. The key habits to build are: always run plan before apply, never modify infrastructure manually, use remote state with locking for team collaboration, and organize code into focused files. From here, explore Terraform modules for reusable components, workspaces for managing multiple environments, and the terraform import command for adopting existing infrastructure.

ADVERTISEMENT

Leave a Comment

Your email address will not be published. Required fields are marked with an asterisk.