Terraform from Zero: Infrastructure as Code for Beginners
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
- Editing state manually — use
terraform state mvorterraform state rm, never hand-edit the JSON. - No remote backend — local state files get lost. Use S3, Terraform Cloud, or any remote backend from day one.
- Skipping plan — always run
terraform planbefore apply. One typo can delete your production database. - Hardcoding secrets — use
varwith sensitive flag, or better, pull from AWS Secrets Manager / Vault. - No locking — enable state locking (DynamoDB for S3) to prevent concurrent applies from corrupting state.
Essential Commands
| Command | What it does |
|---|---|
terraform init | Initialize workspace, download providers |
terraform plan | Preview changes without applying |
terraform apply | Create/update/destroy resources |
terraform destroy | Remove all managed resources |
terraform fmt | Auto-format .tf files |
terraform validate | Check syntax and configuration |
terraform import | Bring 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.