Terraform from Zero: Infrastructure as Code for Beginners

July 29, 2026 · 8 min read

If you're still clicking through cloud consoles to create servers, networks, and databases, you're doing it wrong. Every manual change is a ticking time bomb — undocumented, unreproducible, and impossible to roll back. Terraform fixes this by letting you define infrastructure in code, then apply it predictably.

This guide takes you from zero to your first deployed resource, covering the core concepts you actually need to understand.

What Is Terraform?

Terraform is an open-source tool by HashiCorp that uses a declarative language called HCL (HashiCorp Configuration Language) to describe infrastructure. You write what you want, and Terraform figures out how to create it.

# main.tf — your first Terraform file
resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"

  tags = {
    Name = "my-first-server"
  }
}

This says: "I want an AWS EC2 instance, t2.micro size, tagged as 'my-first-server'." Terraform handles the API calls, waits for provisioning, and reports the result.

Core Concepts

Providers

Providers are plugins that let Terraform talk to different platforms — AWS, Azure, GCP, GitHub, Cloudflare, even local files. You declare which providers you need:

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

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

Run terraform init and Terraform downloads the provider plugins automatically.

Resources

Resources are the building blocks — each one describes a single infrastructure object:

# A VPC
resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
}

# A subnet inside that VPC
resource "aws_subnet" "public" {
  vpc_id     = aws_vpc.main.id
  cidr_block = "10.0.1.0/24"
}

Notice how the subnet references the VPC by its Terraform address (aws_vpc.main.id). Terraform automatically resolves dependencies and creates resources in the right order.

State

Terraform keeps a state file (terraform.tfstate) that maps your code to real-world resources. This is how Terraform knows what exists, what changed, and what to destroy.

Never commit state files to git. They contain secrets (passwords, keys). Use remote state backends like S3 with encryption:

terraform {
  backend "s3" {
    bucket = "my-terraform-state"
    key    = "prod/terraform.tfstate"
    region = "us-east-1"
  }
}

The Terraform Workflow

Three commands is all you need:

# 1. Initialize — download providers
terraform init

# 2. Preview — see what will change
terraform plan

# 3. Apply — create/modify/destroy resources
terraform apply

terraform plan is your safety net. It shows exactly what will happen before anything is touched. Always review the plan before applying.

A Complete Example: AWS Web Server

Here's a minimal but real setup — a VPC, subnet, security group, and EC2 instance with Nginx:

variable "key_name" {
  description = "SSH key pair name"
  type        = string
}

resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true

  tags = { Name = "tf-vpc" }
}

resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  map_public_ip_on_launch = true
}

resource "aws_security_group" "web" {
  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"]
  }
}

resource "aws_instance" "web" {
  ami                    = "ami-0c55b159cbfafe1f0"
  instance_type           = "t2.micro"
  subnet_id               = aws_subnet.public.id
  vpc_security_group_ids  = [aws_security_group.web.id]
  key_name                = var.key_name

  user_data = <<-EOF
    #!/bin/bash
    yum install -y nginx
    systemctl start nginx
  EOF

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

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

Apply this, and you'll get a working Nginx server with its IP printed in the terminal.

Variables and Outputs

Don't hardcode values. Use variables for anything that changes between environments:

# variables.tf
variable "environment" {
  type    = string
  default = "dev"
}

variable "instance_count" {
  type    = number
  default = 1
}

# Override with terraform apply -var="environment=prod"

Outputs expose useful values after apply — IPs, IDs, URLs:

output "server_url" {
  value = "http://${aws_instance.web.public_ip}"
}

Modules: Reusable Infrastructure

When you copy-paste the same resource blocks across projects, it's time for modules. A module is just a directory of .tf files:

# Use a community module
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.0.0"

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

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

The Terraform Registry has thousands of community modules. Don't reinvent the wheel.

Pitfalls to Avoid

Essential Commands

CommandWhat it does
terraform initInitialize workspace, download providers
terraform planPreview changes without applying
terraform applyCreate/update/destroy resources
terraform destroyRemove all managed resources
terraform fmtAuto-format .tf files
terraform validateCheck syntax and configuration
terraform importBring existing resources under management

Summary

Terraform replaces clicking through cloud consoles with a repeatable, version-controlled workflow. Start with a single resource file, use plan before every apply, store state remotely, and modularize when patterns repeat. The initial investment in learning HCL pays off the first time you need to replicate an environment — from minutes of clicking to a single terraform apply.

Next steps: try terraform destroy to clean up, then rebuild from scratch to prove it's reproducible. That's the whole point.