
Terraform has become the standard tool for Infrastructure as Code (IaC): you manage your servers, networks, and databases with code instead of setting them up by hand. This guide covers the basics: what Terraform is, how to install it, how to write a configuration, and how to run the core workflow.
Infrastructure as Code means writing code to set up your infrastructure instead of configuring it by hand. Rather than clicking through console dashboards, you describe what you want in files you can read, version in Git, and run again whenever you need to. Servers, networks, databases, the lot of it lives in code.
Writing it this way pays off in a few ways:
Terraform is a leading open-source Infrastructure as Code tool created by HashiCorp. It allows you to define and provision an entire infrastructure using a declarative configuration language called HCL (HashiCorp Configuration Language). Note that since HashiCorp's license change in 2023, the community-maintained OpenTofu fork has emerged as a drop-in open-source alternative. Everything in this guide applies to both.
Terraform works with the big cloud providers (AWS, Azure, Google Cloud), on-premises setups, and plenty of SaaS services through its provider ecosystem. Working across all of them from one tool is a big part of the appeal.
A few things make Terraform stand out:
A handful of pieces work together to get the job done. Here's what each one does.
Terraform Core is the main executable, the part that does the thinking. It reads your configuration, works out the state you asked for, and talks to providers to make it happen. It also keeps the state file up to date.
Providers are plugins that serve as the bridge between Terraform Core and your infrastructure. Each provider translates your HCL configuration into API calls for a specific cloud platform or service. For example:
That plug-in design is why Terraform can talk to so many different services.
Resources are the fundamental building blocks in Terraform, representing individual infrastructure objects:
Each resource block defines the desired properties and configuration for that specific infrastructure component.
Data sources allow Terraform to fetch information about existing infrastructure or external data not managed by your current configuration. For example, you might use a data source to:
Input Variables are like function arguments that allow you to parameterize your configurations, making them reusable and avoiding hardcoded values. You can pass different values for different environments without modifying core configuration files.
Output Values expose information about your infrastructure after it's created. These are useful for displaying important values (like IP addresses) or for other Terraform configurations to consume.
Modules are self-contained, reusable packages of Terraform configuration. They let you bundle up a common pattern once and use it everywhere. Instead of writing the same resource blocks over and over, you define them once in a module and call it from each project.
The Terraform state file (by default, terraform.tfstate) is crucial. It records the mapping between your configuration and real-world infrastructure, tracking:
Terraform uses the state file to understand what changes are needed during plan and apply operations and to detect configuration drift.
Using Homebrew (Recommended):
# Add the HashiCorp tap
brew tap hashicorp/tap
# Install Terraform
brew install hashicorp/tap/terraformManual Installation:
terraform binary/usr/local/bin)Using Chocolatey:
choco install terraformManual Installation:
C:\Terraform)terraform.exe# Add HashiCorp repository
curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo apt-key add -
sudo apt-add-repository "deb [arch=amd64] https://apt.releases.hashicorp.com $(lsb_release -cs) main"
# Install Terraform
sudo apt-get update
sudo apt-get install terraformAfter installation, verify Terraform is working:
terraform -vThis should display your installed Terraform version:
Terraform v1.8.0
on darwin_amd64
Terraform configurations are written in HCL, a language meant to be easy to read. The main pieces are:
Blocks: Containers for other content with a type, labels, and body:
resource "aws_instance" "web_server" {
# Arguments go here
}Arguments: Assign values to names within a block:
instance_type = "t2.micro"
ami = "ami-0abcdef1234567890"Expressions: Values assigned to arguments (literals, references, or function calls):
instance_type = var.instance_type # Reference to a variable
subnet_id = aws_subnet.main.id # Reference to a resource
count = 3 # Literal numberComments:
# Single-line comment
// Also valid single-line comment
/* Multi-line comment
spanning multiple lines */The terraform block configures Terraform itself rather than your infrastructure. Resource blocks describe what to build; this block sets how Terraform runs.
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}Key elements:
required_version: Specifies acceptable Terraform CLI versionsrequired_providers: Declares which providers your configuration needs and their version constraintsbackend: Configures where state files are storedexperiments: Enables experimental features (advanced use)Providers must be declared in the required_providers block and then configured:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-west-2"
}Resources are the core of your infrastructure:
resource "aws_instance" "app_server" {
ami = "ami-0abcdef1234567890"
instance_type = "t2.micro"
tags = {
Name = "MyApplicationServer"
}
}Structure:
resource: Keywordaws_instance: Resource type (provider_type)app_server: Local name (used to reference this resource in your code)Terraform automatically understands dependencies. For example, if an EC2 instance needs a specific subnet, Terraform creates the subnet before the instance.
Input variables are like function arguments. They parameterize your configurations, making them reusable across different environments and deployments.
Declaration (typically in variables.tf):
variable "instance_type" {
description = "The EC2 instance type to use"
type = string
default = "t2.micro"
}
variable "aws_region" {
description = "The AWS region to deploy resources in"
type = string
# No default, so Terraform will prompt for this value
}
variable "environment_tags" {
description = "Common tags for all resources"
type = map(string)
default = {
Environment = "dev"
ManagedBy = "terraform"
}
}Usage in resources:
resource "aws_instance" "app_server" {
ami = "ami-0abcdef1234567890"
instance_type = var.instance_type
tags = merge(
var.environment_tags,
{ Name = "AppServer" }
)
}Providing values:
Variables can be provided through multiple methods:
terraform apply -var="instance_type=t3.small"export TF_VAR_instance_type="t3.small"Variable files: Create terraform.tfvars:
instance_type = "t3.medium"
aws_region = "eu-central-1"Outputs expose information about your infrastructure after deployment:
output "instance_public_ip" {
description = "The public IP address of the web server instance"
value = aws_instance.app_server.public_ip
}
output "instance_id" {
description = "The ID of the web server instance"
value = aws_instance.app_server.id
sensitive = false
}After terraform apply, outputs are displayed. Query them later with:
terraform output instance_public_ipThe Terraform state file (terraform.tfstate) is a JSON file that maps your configuration to real-world resources. It tracks:
Important: The state file contains sensitive information and should be treated securely.
Local State (default):
Remote State (recommended for teams):
Store state files remotely and securely using backends:
terraform {
backend "s3" {
bucket = "my-terraform-state-bucket"
key = "global/s3/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
}
}Benefits:
The typical Terraform workflow follows four main steps:
terraform initPurpose: Prepares your working directory for Terraform operations.
Actions:
.terraform.lock.hcl) recording exact provider versionsCommand:
terraform initRun this once when starting a new project or after cloning an existing one. Rerun if you change provider versions or backend configuration.
terraform planPurpose: Creates an execution plan showing what changes Terraform will make.
Actions:
Output symbols:
+ (green): Resource will be created~ (yellow): Resource will be updated in-place- (red): Resource will be destroyed-/+: Resource will be destroyed and recreatedCommand:
terraform planSave plans to files for later review:
terraform plan -out=myplan.tfplanterraform applyPurpose: Executes the planned changes to provision or update infrastructure.
Actions:
yes)Command:
terraform applyOr apply a saved plan:
terraform apply myplan.tfplanUse -auto-approve to skip confirmation (use with caution):
terraform apply -auto-approveterraform destroy (When Needed)Purpose: Destroys all infrastructure managed by your configuration.
Caution: This is destructive. Use only when you intend to remove all resources.
terraform destroyHere's a simple but complete first example using the null provider (no credentials needed):
main.tf:
resource "null_resource" "example" {
provisioner "local-exec" {
command = "echo 'Terraform is working!'"
}
}Run the workflow:
# Initialize
terraform init
# Plan
terraform plan
# Apply
terraform apply
# View state
terraform show
# Clean up
terraform destroyResources have a lifecycle that Terraform manages. The basic flow is:
Fine-tune how resources are managed using lifecycle blocks:
resource "aws_instance" "example" {
ami = "ami-0c55b31ad29f52381"
instance_type = "t2.micro"
lifecycle {
create_before_destroy = true
ignore_changes = [ami]
prevent_destroy = true
}
}Common options:
create_before_destroy: Create replacement resource before destroying the old one (useful for zero-downtime updates)prevent_destroy: Prevent accidental destruction of critical resourcesignore_changes: Ignore specific attribute changes after creationreplace_triggered_by: Trigger replacement based on other resource changesThe count meta-argument creates a fixed number of identical resources:
resource "aws_instance" "servers" {
count = 3
ami = "ami-0c55b31ad29f52381"
instance_type = "t2.micro"
tags = {
Name = "server-${count.index}"
}
}When to use:
Reference resources:
aws_instance.servers[0]aws_instance.servers[*].idDrawback: If you remove an item from the middle of a list, Terraform sees all subsequent resources as changed, which can cause unintended destruction.
The for_each meta-argument creates resources based on a map or set of strings:
locals {
vms = {
"web" = {
instance_type = "t2.micro"
},
"db" = {
instance_type = "t2.medium"
},
"app" = {
instance_type = "t2.large"
}
}
}
resource "aws_instance" "servers" {
for_each = local.vms
ami = "ami-0c55b31ad29f52381"
instance_type = each.value.instance_type
tags = {
Name = each.key
}
}When to use:
Reference resources:
aws_instance.servers["web"]values(aws_instance.servers)[*].idAdvantage: Resources maintain identity based on their key, so removing an item only destroys that item, not all subsequent ones.
| Aspect | count | for_each |
|---|---|---|
| Unique identifiers | Numeric index | Map key or set value |
| Best for | Simple, fixed lists | Maps with unique keys |
| Refactoring safety | Risky (index shifts) | Safe (key-based) |
| Dynamic numbers | Good | Better |
| Readability | Simple | More explicit |
General recommendation: Prefer for_each for most use cases due to its stability when refactoring.
Never commit state files to Git: Add to .gitignore:
*.tfstate
*.tfstate.*
Use separate state files for different environments:
Directory-based approach:
terraform/
├── dev/
│ ├── main.tf
│ ├── terraform.tfvars
│ └── backend.tf
├── staging/
│ ├── main.tf
│ ├── terraform.tfvars
│ └── backend.tf
└── prod/
├── main.tf
├── terraform.tfvars
└── backend.tf
Workspace-based approach (simpler for teams):
terraform workspace new dev
terraform workspace new prod
terraform workspace select prod
terraform applyAs you progress, explore these important areas:
.tf files and .terraform.lock.hcl to Gitterraform plan output carefullyterraform validateterraform fmt to maintain code styleTerraform lets you manage infrastructure with code, so deployments are automated, repeatable, and faster than manual provisioning. This guide walked through:
Start simple, run the workflow until it's second nature, then add more advanced features as you need them.
From here, you can explore modules for code reusability, set up remote state for team collaboration, integrate with CI/CD pipelines, or move to a TACO platform for governance at scale. Each builds on the foundations covered here.
The best way to learn Terraform is by doing. Build small projects, make mistakes in safe environments, and iterate.
