Terraform for Beginners: Infrastructure as Code That Makes Sense
Learn Terraform from scratch. HCL basics, providers, resources, modules, state management, and building real AWS infrastructure step by step.
Every developer eventually hits the moment where they need to set up a server. Or a database. Or a load balancer. And they click through the AWS console, configuring things manually, praying they'll remember what they did when they need to do it again.
Terraform fixes this. Instead of clicking through a web UI, you describe your infrastructure in code. Need a server? Write it. Need a database? Write it. Need to tear everything down? One command. Need to set up the exact same thing in another region? Copy-paste the code.
This is Infrastructure as Code (IaC), and Terraform is the most popular tool for it.
What Is Infrastructure as Code?
IaC means managing infrastructure (servers, databases, networks, DNS, etc.) through configuration files instead of manual processes. The benefits are enormous:
- Version control. Your infrastructure is in Git. You can see who changed what and when.
- Reproducibility. Run the same code, get the same infrastructure. Every time.
- Review process. Infrastructure changes go through pull requests, just like code.
- Documentation. The code IS the documentation. No more "I think the security group allows port 443."
Installing Terraform
# macOS
brew install terraform
# Windows (with Chocolatey)
choco install terraform
# Linux (Ubuntu/Debian)
sudo apt-get update && sudo apt-get install -y gnupg software-properties-common
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
terraform --version
HCL: HashiCorp Configuration Language
Terraform uses HCL, which reads like a hybrid of JSON and Python. Here's a taste:
# This is a comment
resource "aws_instance" "web_server" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
tags = {
Name = "WebServer"
Environment = "production"
}
}
The structure is block_type "type" "name" { ... }. Resources are the core building blocks -- each one represents a piece of infrastructure.
Your First Terraform Project
Create a project directory and a main.tf file:
# main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
required_version = ">= 1.0"
}
provider "aws" {
region = "us-east-1"
}
resource "aws_instance" "example" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
tags = {
Name = "TerraformExample"
}
}
Three blocks:
- terraform -- Declares required providers and Terraform version constraints.
- provider -- Configures the cloud provider (AWS, GCP, Azure, etc.). Terraform supports 3000+ providers.
- resource -- Defines a piece of infrastructure. The format is
resource "provider_type" "local_name".
The Terraform Workflow
Four commands. That's the core workflow:
# 1. Initialize -- downloads provider plugins
terraform init
# 2. Plan -- shows what will change (dry run)
terraform plan
# 3. Apply -- creates/updates/deletes resources
terraform apply
# 4. Destroy -- tears everything down
terraform destroy
terraform plan is your best friend. It shows exactly what Terraform will create, modify, or delete before doing anything. Always review the plan.
$ terraform plan
Terraform will perform the following actions:
# aws_instance.example will be created
+ resource "aws_instance" "example" {
+ ami = "ami-0c55b159cbfafe1f0"
+ instance_type = "t3.micro"
+ tags = {
+ "Name" = "TerraformExample"
}
...
}
Plan: 1 to add, 0 to change, 0 to destroy.
Variables
Hardcoding values is bad in application code and it's bad in Terraform too. Use variables:
# variables.tf
variable "aws_region" {
description = "AWS region for resources"
type = string
default = "us-east-1"
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "environment" {
description = "Deployment environment"
type = string
validation {
condition = contains(["dev", "staging", "production"], var.environment)
error_message = "Environment must be dev, staging, or production."
}
}
Use them in resources:
# main.tf
provider "aws" {
region = var.aws_region
}
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = var.instance_type
tags = {
Name = "WebServer"
Environment = var.environment
}
}
Pass values at runtime:
# Command line
terraform apply -var="environment=production"
# From a file (terraform.tfvars)
echo 'environment = "production"' > terraform.tfvars
terraform apply
# Environment variables
export TF_VAR_environment="production"
terraform apply
Outputs
Outputs let you extract information from your infrastructure -- like the public IP of a server you just created:
# outputs.tf
output "instance_public_ip" {
description = "Public IP of the web server"
value = aws_instance.web.public_ip
}
output "instance_id" {
description = "ID of the EC2 instance"
value = aws_instance.web.id
}
After terraform apply, outputs are printed to the terminal. You can also query them:
terraform output instance_public_ip
State Management
Terraform tracks what it manages in a state file (terraform.tfstate). This file maps your configuration to real resources. Without it, Terraform doesn't know what exists.
Critical rules:
- Never edit the state file manually. Use
terraform statecommands if you need to manipulate it. - Never commit
terraform.tfstateto Git. It often contains secrets (database passwords, etc.). - Use remote state for teams. Store state in S3, Terraform Cloud, or another remote backend.
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
The DynamoDB table provides state locking -- it prevents two people from running terraform apply simultaneously and corrupting the state.
Modules: Reusable Infrastructure
Modules are like functions for infrastructure. Write once, reuse everywhere.
modules/
web-server/
main.tf
variables.tf
outputs.tf
# modules/web-server/main.tf
resource "aws_instance" "server" {
ami = var.ami
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.web.id]
tags = {
Name = var.name
}
}
resource "aws_security_group" "web" {
name = "${var.name}-sg"
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
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"]
}
}
Use the module:
# main.tf
module "web_prod" {
source = "./modules/web-server"
name = "web-production"
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.small"
}
module "web_staging" {
source = "./modules/web-server"
name = "web-staging"
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
}
Same module, different configurations. Production gets a bigger instance, staging gets a smaller one.
Building Real Infrastructure: VPC + EC2 + RDS
Let's build something meaningful -- a VPC with public and private subnets, a web server in the public subnet, and a PostgreSQL database in the private subnet.
# networking.tf
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
tags = { Name = "${var.project}-vpc" }
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "${var.aws_region}a"
map_public_ip_on_launch = true
tags = { Name = "${var.project}-public" }
}
resource "aws_subnet" "private_a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.2.0/24"
availability_zone = "${var.aws_region}a"
tags = { Name = "${var.project}-private-a" }
}
resource "aws_subnet" "private_b" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.3.0/24"
availability_zone = "${var.aws_region}b"
tags = { Name = "${var.project}-private-b" }
}
resource "aws_internet_gateway" "gw" {
vpc_id = aws_vpc.main.id
tags = { Name = "${var.project}-igw" }
}
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
}
# compute.tf
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
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 = { Name = "${var.project}-web" }
}
resource "aws_security_group" "web" {
name = "${var.project}-web-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.my_ip]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# database.tf
resource "aws_db_subnet_group" "main" {
name = "${var.project}-db-subnet"
subnet_ids = [aws_subnet.private_a.id, aws_subnet.private_b.id]
}
resource "aws_db_instance" "postgres" {
identifier = "${var.project}-db"
engine = "postgres"
engine_version = "16.1"
instance_class = "db.t3.micro"
allocated_storage = 20
db_name = "appdb"
username = var.db_username
password = var.db_password
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.db.id]
skip_final_snapshot = true
tags = { Name = "${var.project}-db" }
}
resource "aws_security_group" "db" {
name = "${var.project}-db-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.web.id]
}
}
Run terraform plan and you'll see 12+ resources about to be created. Review them. Then terraform apply. In about 5 minutes, you have a full VPC with a web server and database.
And when you're done experimenting: terraform destroy. Everything gone. No leftover resources silently charging you.
Best Practices
Use consistent file structure. Split intomain.tf, variables.tf, outputs.tf, providers.tf. For larger projects, split by resource type (networking.tf, compute.tf, database.tf).
Use .tfvars for environment-specific values. Keep dev.tfvars, staging.tfvars, prod.tfvars and pass them with -var-file.
Pin provider versions. Use version = "~> 5.0" not version = ">= 5.0". You don't want a major version bump breaking your infrastructure unexpectedly.
Use terraform fmt and terraform validate. Format your code consistently and catch errors before applying.
Never store secrets in Terraform files. Use environment variables, AWS Secrets Manager, or HashiCorp Vault for passwords and API keys.
Common Mistakes
Runningterraform apply without reading the plan. Terraform will tell you if it's about to delete your production database. Read the plan.
Modifying resources manually after Terraform creates them. This causes state drift. Terraform thinks the resource looks one way, but it actually looks different. Next apply, chaos.
Not using remote state for team projects. Local state files don't work when multiple people manage infrastructure. Someone's state will be stale and they'll overwrite changes.
Ignoring the dependency graph. Terraform usually figures out dependencies automatically (because you reference aws_vpc.main.id in the subnet). But sometimes you need depends_on for implicit dependencies.
What's Next
You now understand the Terraform fundamentals: HCL, providers, resources, variables, outputs, state, and modules. From here, explore workspaces for managing multiple environments, data sources for referencing existing resources, and provisioners for configuration management.
For cloud infrastructure practice and more DevOps skills, check out CodeUp.