Skip to content
Codeloom
DevOps

Infrastructure as Code: Terraform Getting Started Guide

Learn Infrastructure as Code with Terraform. This beginner guide covers HCL syntax, providers, state management, modules, and deploying your first cloud resources step by step.

·8 min read · By Codeloom
Beginner 14 min read

What you'll learn

  • What Infrastructure as Code is and why it matters
  • How to write Terraform configurations in HCL
  • Managing state, variables, and outputs
  • Building reusable modules for real infrastructure

Prerequisites

  • Basic command-line experience
  • An AWS, GCP, or Azure free-tier account
  • Terraform CLI installed (v1.5+)

What Is Infrastructure as Code?

Infrastructure as Code (IaC) is the practice of managing and provisioning computing infrastructure through machine-readable configuration files rather than manual processes. Instead of clicking through a cloud console to create a server, you write a file that describes exactly what you need, and a tool builds it for you.

This approach brings software engineering practices to infrastructure: version control, code review, testing, and repeatable deployments. When your infrastructure is code, you can track every change, roll back mistakes, and spin up identical environments in minutes.

Terraform, built by HashiCorp, is the most widely adopted IaC tool. It uses a declarative language called HCL (HashiCorp Configuration Language) and supports hundreds of cloud providers through a plugin system.

Why Terraform Over Other Tools

Several IaC tools exist, but Terraform stands out for a few reasons:

  • Cloud-agnostic: One tool works across AWS, GCP, Azure, and dozens of other providers.
  • Declarative syntax: You describe the desired end state; Terraform figures out how to get there.
  • State management: Terraform tracks what it has created, so it knows what to change or destroy.
  • Large ecosystem: Thousands of community-maintained providers and modules are available.
  • Plan before apply: You can preview every change before it happens.

Other tools like Pulumi (imperative, uses general-purpose languages) and CloudFormation (AWS-only) have their place, but Terraform’s balance of simplicity and power makes it the standard starting point.

Installing Terraform

On macOS with Homebrew:

brew tap hashicorp/tap
brew install hashicorp/tap/terraform
terraform --version

On Linux (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

Verify the installation:

terraform --version
# Terraform v1.9.x

Your First Terraform Configuration

Create a project directory and a main configuration file:

mkdir terraform-demo && cd terraform-demo

Create main.tf with a simple AWS EC2 instance:

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

provider "aws" {
  region = "us-east-1"
}

resource "aws_instance" "web_server" {
  ami           = "ami-0c02fb55956c7d316"  # Amazon Linux 2023
  instance_type = "t2.micro"

  tags = {
    Name        = "terraform-demo-server"
    Environment = "development"
  }
}

This file does three things: declares the AWS provider, configures the region, and defines an EC2 instance resource.

The Terraform Workflow

Terraform follows a predictable four-step workflow:

Step 1: Initialize

terraform init

This downloads the AWS provider plugin and sets up the working directory. You will see output confirming the provider was installed.

Step 2: Plan

terraform plan

Terraform compares your configuration to the current state and shows what it will create, modify, or destroy. Always review the plan before applying.

Plan: 1 to add, 0 to change, 0 to destroy.

Step 3: Apply

terraform apply

Terraform asks for confirmation, then creates the resources. Type yes to proceed.

Step 4: Destroy (when done)

terraform destroy

This tears down everything Terraform created. Essential for cleaning up development resources and avoiding unnecessary costs.

Understanding HCL Syntax

HCL is designed to be both human-readable and machine-friendly. Here are the core building blocks:

Variables

Define input variables in variables.tf:

variable "instance_type" {
  description = "EC2 instance size"
  type        = string
  default     = "t2.micro"
}

variable "environment" {
  description = "Deployment environment"
  type        = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Environment must be dev, staging, or prod."
  }
}

variable "allowed_ports" {
  description = "List of allowed ingress ports"
  type        = list(number)
  default     = [80, 443]
}

Reference variables with var.variable_name:

resource "aws_instance" "web_server" {
  instance_type = var.instance_type
  # ...
}

Outputs

Define outputs in outputs.tf to expose useful information:

output "instance_public_ip" {
  description = "Public IP of the web server"
  value       = aws_instance.web_server.public_ip
}

output "instance_id" {
  description = "EC2 Instance ID"
  value       = aws_instance.web_server.id
}

After terraform apply, outputs are printed to the terminal. You can also retrieve them later with terraform output.

Local Values

Locals let you define computed values to avoid repetition:

locals {
  common_tags = {
    Project     = "terraform-demo"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_instance" "web_server" {
  ami           = "ami-0c02fb55956c7d316"
  instance_type = var.instance_type
  tags          = local.common_tags
}

Managing Terraform State

Terraform state is a JSON file that maps your configuration to real-world resources. By default, it is stored locally in terraform.tfstate. For team use, store state remotely.

Remote State with S3

terraform {
  backend "s3" {
    bucket         = "my-terraform-state-bucket"
    key            = "demo/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

The DynamoDB table provides state locking, preventing two people from modifying state simultaneously. Create these resources manually (or with a separate Terraform config) before using them as a backend.

State Commands

# List all resources in state
terraform state list

# Show details of a specific resource
terraform state show aws_instance.web_server

# Remove a resource from state (without destroying it)
terraform state rm aws_instance.web_server

# Move a resource to a new address (after refactoring)
terraform state mv aws_instance.web_server aws_instance.app_server

Building a Real Example

Let us build a more complete setup: a VPC with a public subnet, security group, and an EC2 instance.

# networking.tf
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  tags                 = merge(local.common_tags, { Name = "main-vpc" })
}

resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = "us-east-1a"
  map_public_ip_on_launch = true
  tags                    = merge(local.common_tags, { Name = "public-subnet" })
}

resource "aws_internet_gateway" "gw" {
  vpc_id = aws_vpc.main.id
  tags   = local.common_tags
}

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

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

resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}
# security.tf
resource "aws_security_group" "web" {
  name   = "web-sg"
  vpc_id = aws_vpc.main.id

  dynamic "ingress" {
    for_each = var.allowed_ports
    content {
      from_port   = ingress.value
      to_port     = ingress.value
      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"]
  }
}
# compute.tf
resource "aws_instance" "web_server" {
  ami                    = "ami-0c02fb55956c7d316"
  instance_type          = var.instance_type
  subnet_id              = aws_subnet.public.id
  vpc_security_group_ids = [aws_security_group.web.id]

  user_data = <<-EOF
    #!/bin/bash
    yum update -y
    yum install -y httpd
    systemctl start httpd
    systemctl enable httpd
    echo "<h1>Hello from Terraform</h1>" > /var/www/html/index.html
  EOF

  tags = merge(local.common_tags, { Name = "web-server" })
}

Creating Reusable Modules

Modules let you package and reuse configurations. Create a module directory structure:

modules/
  web-server/
    main.tf
    variables.tf
    outputs.tf
# modules/web-server/variables.tf
variable "instance_type" {
  type    = string
  default = "t2.micro"
}

variable "subnet_id" {
  type = string
}

variable "security_group_ids" {
  type = list(string)
}
# modules/web-server/main.tf
resource "aws_instance" "this" {
  ami                    = "ami-0c02fb55956c7d316"
  instance_type          = var.instance_type
  subnet_id              = var.subnet_id
  vpc_security_group_ids = var.security_group_ids

  tags = {
    Name = "web-server"
  }
}

Use the module from your root configuration:

module "web" {
  source             = "./modules/web-server"
  instance_type      = "t3.small"
  subnet_id          = aws_subnet.public.id
  security_group_ids = [aws_security_group.web.id]
}

You can also use modules from the Terraform Registry:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.1.0"

  name = "my-vpc"
  cidr = "10.0.0.0/16"

  azs             = ["us-east-1a", "us-east-1b"]
  public_subnets  = ["10.0.1.0/24", "10.0.2.0/24"]
  private_subnets = ["10.0.3.0/24", "10.0.4.0/24"]
}

Best Practices

File organization: Split configurations into logical files (main.tf, variables.tf, outputs.tf, providers.tf). Terraform loads all .tf files in a directory.

Use .tfvars files for environment-specific values:

# prod.tfvars
instance_type = "t3.large"
environment   = "prod"
terraform apply -var-file="prod.tfvars"

Pin provider versions to avoid unexpected changes. Use the ~> constraint for minor version flexibility.

Never commit state files to version control. Add *.tfstate and .terraform/ to .gitignore.

Use terraform fmt to keep formatting consistent, and terraform validate to catch syntax errors early.

Tag everything with a consistent tagging strategy so you can track costs and ownership.

Wrapping Up

Infrastructure as Code with Terraform transforms how you manage cloud resources. You started with a single EC2 instance and progressed to a full VPC setup with reusable modules. The key concepts to remember are the init-plan-apply workflow, state management with remote backends, and modular design for reusability. From here, explore Terraform workspaces for managing multiple environments, terraform import for bringing existing resources under management, and CI/CD integration for automated infrastructure deployments.