Terraform Fundamentals
Terraform lets you define infrastructure in code. Instead of clicking through a cloud console to create a server, a database, and a load balancer, you write a configuration file that describes what you want. Terraform figures out the order of operations, creates everything, and tracks what it built so it can update or destroy it later. The model is declarative: you describe the end state, not the steps to get there.
The Declarative Model
Imperative: "Create a VPC. Then create a subnet in that VPC. Then create a security group. Then launch an instance in that subnet with that security group."
Declarative: "I want a VPC with a subnet, a security group, and an instance. Here are the details." Terraform determines the dependency graph and executes in the correct order.
# You describe what you want
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
subnet_id = aws_subnet.main.id
tags = {
Name = "web-server"
}
}
# Terraform figures out:
# 1. aws_subnet.main must exist before aws_instance.web
# 2. It builds a dependency graph automatically
# 3. Resources without dependencies are created in parallel
Providers
Providers are plugins that let Terraform interact with APIs. Each cloud platform, SaaS tool, or service has a provider. The provider translates your HCL configuration into API calls.
# Configure the AWS provider
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
There are providers for everything: AWS, GCP, Azure, Cloudflare, GitHub, Datadog, PagerDuty, Kubernetes, Helm, and hundreds more. The Terraform Registry at registry.terraform.io lists them all.
# Multiple providers in one configuration
provider "aws" {
region = "us-east-1"
}
provider "cloudflare" {
api_token = var.cloudflare_api_token
}
# Create an EC2 instance in AWS
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
}
# Create a DNS record in Cloudflare
resource "cloudflare_record" "web" {
zone_id = var.cloudflare_zone_id
name = "web"
content = aws_instance.web.public_ip
type = "A"
}
Resources
Resources are the building blocks. Each resource block defines one piece of infrastructure.
resource "aws_s3_bucket" "assets" {
bucket = "my-app-assets-production"
tags = {
Environment = "production"
Team = "platform"
}
}
resource "aws_s3_bucket_versioning" "assets" {
bucket = aws_s3_bucket.assets.id
versioning_configuration {
status = "Enabled"
}
}
The resource type (aws_s3_bucket) determines the provider and the kind of infrastructure. The resource name (assets) is a local identifier used to reference this resource elsewhere in the configuration.
Resources reference each other using the syntax resource_type.resource_name.attribute:
resource "aws_security_group" "web" {
name = "web-sg"
vpc_id = aws_vpc.main.id # References the VPC resource
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
vpc_security_group_ids = [aws_security_group.web.id] # References the SG
}
Data Sources
Data sources let you read information from existing infrastructure that Terraform does not manage. They are read-only.
# Look up the latest Ubuntu AMI
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id # Use the data source
instance_type = "t3.micro"
}
Common uses: looking up AMIs, reading existing VPC IDs, fetching account information, querying DNS records.
Variables
Variables parameterize your configuration. They make configurations reusable across environments.
# variables.tf
variable "environment" {
description = "The deployment environment"
type = string
default = "development"
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "allowed_cidrs" {
description = "CIDR blocks allowed to access the service"
type = list(string)
default = ["10.0.0.0/8"]
}
# main.tf -- using variables
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
tags = {
Environment = var.environment
}
}
Set variable values via:
# Command line
terraform apply -var="environment=production" -var="instance_type=t3.large"
# Variable file
terraform apply -var-file="production.tfvars"
# Environment variables
export TF_VAR_environment=production
terraform apply
# production.tfvars
environment = "production"
instance_type = "t3.large"
allowed_cidrs = ["10.0.0.0/8", "172.16.0.0/12"]
Outputs
Outputs expose values from your configuration. They are useful for passing information between modules or displaying results after terraform apply.
# outputs.tf
output "instance_ip" {
description = "The public IP of the web server"
value = aws_instance.web.public_ip
}
output "bucket_arn" {
description = "The ARN of the S3 bucket"
value = aws_s3_bucket.assets.arn
}
output "db_endpoint" {
description = "The database connection endpoint"
value = aws_db_instance.main.endpoint
sensitive = true
}
# View outputs after apply
terraform output
terraform output instance_ip
The Core Workflow
Four commands cover 95% of Terraform usage:
# 1. Initialize: download providers, set up backend
terraform init
# 2. Plan: preview what will change
terraform plan
# 3. Apply: make the changes
terraform apply
# 4. Destroy: tear everything down
terraform destroy
terraform init
Run once when you start a new configuration, add a new provider, or change the backend. It downloads provider plugins and initializes the state backend.
terraform plan
Shows what Terraform will do without actually doing it. Always run plan before apply.
terraform plan
Terraform will perform the following actions:
# aws_instance.web will be created
+ resource "aws_instance" "web" {
+ ami = "ami-0c55b159cbfafe1f0"
+ instance_type = "t3.micro"
+ public_ip = (known after apply)
+ tags = {
+ "Name" = "web-server"
}
}
Plan: 1 to add, 0 to change, 0 to destroy.
The + means create, ~ means modify, - means destroy.
terraform apply
Executes the plan. By default, it shows the plan and asks for confirmation.
terraform apply # Interactive confirmation
terraform apply -auto-approve # Skip confirmation (CI/CD pipelines)
terraform apply -target=aws_instance.web # Apply only one resource
terraform destroy
Removes all resources managed by this configuration. Use with caution.
State Files
Terraform tracks what it has created in a state file. The state maps your configuration to real-world resources.
Your config says: State file says: AWS says:
resource "aws_instance" "web" aws_instance.web = i-0abc123def456
instance_type = "t3.micro" id: i-0abc123def456 instance_type: t3.micro
instance_type: t3.micro status: running
The state file is how Terraform knows:
- Which real resources correspond to which config blocks
- What the current state of each resource is
- What changes need to be made on the next
apply
By default, state is stored locally in terraform.tfstate. For teams, this does not work -- the state file must be shared and locked to prevent concurrent modifications.
Remote State
Store state in a shared, lockable backend:
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "production/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
The S3 bucket stores the state. The DynamoDB table provides locking so two people cannot run terraform apply simultaneously.
Other backends: Terraform Cloud, Google Cloud Storage, Azure Blob Storage, Consul, PostgreSQL.
A Complete Example
# main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "my-terraform-state"
key = "web/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
}
}
provider "aws" {
region = var.region
}
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_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
tags = {
Name = "web-${var.environment}"
Environment = var.environment
}
}
output "public_ip" {
value = aws_instance.web.public_ip
}
terraform init
terraform plan -var-file="production.tfvars"
terraform apply -var-file="production.tfvars"
Common Pitfalls
- Committing state files to Git -- State files contain sensitive information (resource IDs, sometimes passwords). Use a remote backend and add
*.tfstateto.gitignore. - Not using remote state from the start -- Migrating from local to remote state later is possible but error-prone. Start with remote state.
- Skipping
terraform plan-- Runningapplywithout reviewing the plan can destroy resources you did not intend to touch. Always plan first. - Not pinning provider versions --
version = "~> 5.0"prevents unexpected breaking changes. Without pinning,terraform initcould download a new major version. - Manual changes after Terraform apply -- Changing resources in the console creates drift. Terraform's next plan will try to revert the manual change. All changes should go through Terraform.
- Monolithic configurations -- Putting all infrastructure in one directory means
terraform plantakes minutes and a mistake in any resource affects everything. Split into logical units.
Key Takeaways
- Terraform is declarative: describe the desired end state, and Terraform figures out how to get there
- Providers connect Terraform to cloud platforms and services; resources define individual pieces of infrastructure
- The workflow is
init,plan,apply,destroy-- always review the plan before applying - State files track the mapping between configuration and real resources; always use a remote backend with locking
- Variables and outputs make configurations reusable; use
.tfvarsfiles per environment - Treat Terraform state as the source of truth; never make manual changes to resources Terraform manages