Terraform fundamentals
In this series (10 parts)
Terraform lets you define infrastructure in declarative config files, track state, and apply changes predictably. It works across cloud providers through a plugin system called providers.
Providers
A provider is a plugin that talks to an API. AWS, Azure, GCP, Cloudflare. Each provider exposes resources and data sources for that platform.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
Running terraform init downloads the provider binary. Pin your versions. Unpinned providers will break builds when a new major version ships.
Resources
Resources are the core of every config. Each block declares one infrastructure object.
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
}
The first string is the resource type. The second is the local name. Together they form the address aws_instance.web.
Data sources
Data sources read existing infrastructure. They never create anything.
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"]
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
}
Reference the result with data.aws_ami.ubuntu.id.
Variables, outputs, and locals
Variables parameterize configs. Outputs expose values after apply. Locals are computed constants you reuse within a module.
variable "environment" {
type = string
default = "dev"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Must be dev, staging, or prod."
}
}
output "instance_ip" {
value = aws_instance.web.public_ip
}
locals {
common_tags = {
Environment = var.environment
ManagedBy = "terraform"
}
}
Pass variable values via CLI flags, .tfvars files, or environment variables prefixed with TF_VAR_.
State
Terraform tracks real infrastructure in a state file. By default it writes terraform.tfstate locally. For teams, use a remote backend like S3 with locking.
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
Never edit state by hand. Never commit it to version control.
The plan/apply/destroy workflow
graph LR A[Write Config] --> B[terraform init] B --> C[terraform plan] C --> D[Review Changes] D --> E[terraform apply] E --> F[terraform destroy]
terraform plan shows what will change without touching anything. terraform apply executes the plan. terraform destroy removes every resource in state. Always run plan first and read the output carefully.
Complete example: VPC, subnet, and EC2
This config creates a VPC with a public subnet and launches an EC2 instance into it.
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
required_version = ">= 1.5.0"
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
type = string
default = "us-east-1"
}
variable "instance_type" {
type = string
default = "t3.micro"
}
variable "project" {
type = string
default = "demo"
}
locals {
tags = { Project = var.project, ManagedBy = "terraform" }
}
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"]
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
tags = merge(local.tags, { Name = "${var.project}-vpc" })
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
map_public_ip_on_launch = true
availability_zone = "${var.aws_region}a"
tags = merge(local.tags, { Name = "${var.project}-public" })
}
resource "aws_internet_gateway" "gw" {
vpc_id = aws_vpc.main.id
tags = merge(local.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
}
tags = merge(local.tags, { Name = "${var.project}-public-rt" })
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
resource "aws_security_group" "web" {
name = "${var.project}-web-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
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"]
}
tags = local.tags
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.web.id]
tags = merge(local.tags, { Name = "${var.project}-web" })
}
output "vpc_id" { value = aws_vpc.main.id }
output "instance_id" { value = aws_instance.web.id }
output "instance_public_ip" { value = aws_instance.web.public_ip }
Save this as main.tf, run terraform init, then terraform plan. Review every line of the plan before applying.
What comes next
This config works but has gaps. State lives on your local disk. The security group allows SSH from everywhere. There is no module structure. Article 3 covers Terraform modules, remote state patterns, and strategies for organizing configs across teams.