Terraform for Beginners: Infrastructure as Code Complete Guide
Terraform lets you define your entire cloud infrastructure — VPCs, EC2 instances, databases, DNS records — in plain text files that you can version-control, review, and deploy repeatedly. This guide takes you from zero to a working AWS environment with real code examples.
What Is Infrastructure as Code?
Infrastructure as Code (IaC) means managing servers, networks, and cloud resources through machine-readable configuration files instead of clicking through a web console or running ad-hoc scripts. Before IaC, the typical workflow was: log into the AWS console, click "Launch Instance", configure security groups manually, and hope you remembered every setting next time.
That approach has serious problems. It is not reproducible — every environment ends up slightly different. It is not auditable — there is no record of who changed what or why. And it is not scalable — clicking through a console to spin up 50 services takes hours and introduces human error at every step.
IaC solves all three problems. Your infrastructure is defined in files that live in Git alongside your application code. Every change goes through a pull request. You can spin up an identical staging environment in minutes. You can tear it all down and rebuild it exactly the same way.
The two dominant IaC tools are Terraform (by HashiCorp, now the OpenTofu fork exists too) and AWS CloudFormation. Terraform is cloud-agnostic — the same workflow works for AWS, GCP, Azure, Kubernetes, Cloudflare, GitHub, and hundreds of other providers. CloudFormation is AWS-only. For most teams, Terraform is the better default.
Installing Terraform
Terraform is a single binary. Installation is straightforward on all platforms.
macOS (recommended: Homebrew)
# Install via HashiCorp's official tap (required for Terraform >= 1.6)
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
# Verify
terraform version
# Terraform v1.10.x
Linux (Ubuntu / Debian)
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
Windows
# Using winget
winget install --id Hashicorp.Terraform
# Or download the binary from releases.hashicorp.com and add to PATH
Once installed, also install the Terraform VS Code extension (HashiCorp Terraform) for syntax highlighting and auto-complete. It is the single most useful editor integration for HCL (HashiCorp Configuration Language).
Core Concepts: How Terraform Thinks
Before writing any code, you need to understand the mental model Terraform uses:
- Provider: A plugin that knows how to talk to a specific API (AWS, GCP, Azure, etc.). Providers expose resource types.
- Resource: A single infrastructure object — an EC2 instance, an S3 bucket, a DNS record. This is what you define in your
.tffiles. - Data source: A read-only lookup — fetch the ID of an existing VPC, the latest AMI ID, or a secret from Secrets Manager — without managing it.
- Variable: An input value that makes your configuration reusable across environments.
- Output: A value exported from your configuration — like the public IP of a server — that other modules or humans can consume.
- State: A JSON file (local or remote) that maps your
.tfdefinitions to real cloud resources. Terraform compares state against reality to decide what to create, update, or destroy. - Module: A reusable collection of resources grouped together. Think of it as a function in programming.
Your First Terraform Project: Directory Structure
A minimal Terraform project looks like this:
my-infra/
├── main.tf # Primary resources
├── variables.tf # Input variable declarations
├── outputs.tf # Output value declarations
├── terraform.tfvars # Actual variable values (do NOT commit secrets)
├── providers.tf # Provider configuration
└── .gitignore # Exclude .terraform/, *.tfstate, *.tfvars
For larger projects, use modules:
my-infra/
├── environments/
│ ├── prod/
│ │ ├── main.tf
│ │ └── terraform.tfvars
│ └── staging/
│ ├── main.tf
│ └── terraform.tfvars
└── modules/
├── vpc/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── ec2/
├── main.tf
├── variables.tf
└── outputs.tf
Providers
The provider block tells Terraform which cloud to target and which version of the provider plugin to use. Always pin provider versions — a provider major version bump can break your configuration silently.
# providers.tf
terraform {
required_version = ">= 1.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0" # allows 5.x but not 6.x
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Environment = var.environment
ManagedBy = "terraform"
Project = var.project_name
}
}
}
Terraform supports hundreds of providers beyond AWS:
- GCP:
source = "hashicorp/google" - Azure:
source = "hashicorp/azurerm" - Kubernetes:
source = "hashicorp/kubernetes" - Cloudflare:
source = "cloudflare/cloudflare" - GitHub:
source = "integrations/github"
Resources
A resource block declares a single infrastructure object. The syntax is always resource "<type>" "<local_name>". The local name is used to reference this resource elsewhere in your code.
# Create an S3 bucket
resource "aws_s3_bucket" "app_assets" {
bucket = "my-app-assets-${var.environment}"
}
# Enable versioning on that bucket
resource "aws_s3_bucket_versioning" "app_assets" {
bucket = aws_s3_bucket.app_assets.id # reference by <type>.<name>.<attribute>
versioning_configuration {
status = "Enabled"
}
}
# Reference attributes from other resources
output "bucket_arn" {
value = aws_s3_bucket.app_assets.arn
}
Variables and terraform.tfvars
Variables make your configuration reusable. Declare them in variables.tf, then supply values in terraform.tfvars or via environment variables (TF_VAR_name).
# variables.tf
variable "aws_region" {
description = "AWS region to deploy resources into"
type = string
default = "us-east-1"
}
variable "environment" {
description = "Environment name: prod, staging, dev"
type = string
validation {
condition = contains(["prod", "staging", "dev"], var.environment)
error_message = "environment must be prod, staging, or dev."
}
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "allowed_cidr_blocks" {
description = "List of CIDR blocks allowed to reach the application"
type = list(string)
default = ["0.0.0.0/0"]
}
# terraform.tfvars (do NOT commit passwords or secrets here)
aws_region = "us-east-1"
environment = "staging"
instance_type = "t3.small"
allowed_cidr_blocks = ["10.0.0.0/8", "203.0.113.0/24"]
Never put secrets (database passwords, API keys) directly in
.tfvarsfiles. Use AWS Secrets Manager or SSM Parameter Store and fetch them with adatasource. Use our ENV Validator to check that yourterraform.tfvarsvariables are properly formatted before runningterraform apply.
Outputs
Outputs expose values after a deployment — the IP address of your server, the ARN of an IAM role, the endpoint of your database. They are essential for chaining modules together and for humans who need to know "what was just created".
# outputs.tf
output "web_server_public_ip" {
description = "Public IP of the web server"
value = aws_instance.web.public_ip
}
output "web_server_dns" {
description = "Public DNS name of the web server"
value = aws_instance.web.public_dns
}
output "vpc_id" {
description = "ID of the created VPC"
value = aws_vpc.main.id
# Mark sensitive outputs to suppress from terminal output
sensitive = false
}
output "database_endpoint" {
description = "RDS endpoint URL"
value = aws_db_instance.main.endpoint
sensitive = true # won't print to terminal, still accessible
}
State Management
Terraform state is a JSON file that tracks which real-world resources correspond to your configuration. By default it is stored locally as terraform.tfstate. This works fine for solo projects, but breaks down in teams — two engineers running terraform apply simultaneously can corrupt state, and the file may contain sensitive data.
Remote State on S3 (recommended for teams)
# providers.tf — add backend block
terraform {
backend "s3" {
bucket = "my-terraform-state-bucket"
key = "prod/us-east-1/main/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-state-lock" # prevents concurrent applies
}
}
# Create the S3 bucket and DynamoDB table first (bootstrap)
resource "aws_s3_bucket" "tf_state" {
bucket = "my-terraform-state-bucket"
}
resource "aws_s3_bucket_versioning" "tf_state" {
bucket = aws_s3_bucket.tf_state.id
versioning_configuration { status = "Enabled" }
}
resource "aws_dynamodb_table" "tf_lock" {
name = "terraform-state-lock"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
Key state management commands:
terraform state list # list all resources in state
terraform state show aws_instance.web # inspect a specific resource
terraform state mv aws_instance.old aws_instance.new # rename without destroy/recreate
terraform state rm aws_s3_bucket.legacy # remove from state (does NOT delete real resource)
terraform import aws_s3_bucket.existing my-existing-bucket # import unmanaged resource
The Plan / Apply / Destroy Workflow
These three commands form the core Terraform workflow:
# 1. Initialize — download providers, configure backend
terraform init
# 2. Format code (run before every commit)
terraform fmt
# 3. Validate configuration syntax
terraform validate
# 4. Preview changes — ALWAYS run this before apply
terraform plan
terraform plan -out=tfplan # save plan to file
terraform plan -var="environment=prod"
# 5. Apply changes
terraform apply # prompts for confirmation
terraform apply -auto-approve # skip confirmation (use carefully)
terraform apply tfplan # apply a saved plan (safer in CI)
# 6. Destroy all resources (destructive — use with extreme caution)
terraform destroy
terraform destroy -target=aws_instance.web # destroy only one resource
Always run
terraform planbeforeterraform applyand read the output carefully. Look for any lines containing-/+(destroy and recreate) — these are destructive changes that will cause downtime. Lines with~are in-place updates, and+are new additions.
Workspaces
Workspaces let you manage multiple state files from the same configuration directory. They are useful for lightweight environment separation (dev/staging/prod) when you do not want separate directories.
terraform workspace list # list all workspaces
terraform workspace new staging # create a new workspace
terraform workspace select prod # switch to a workspace
terraform workspace show # show current workspace
# Reference workspace name in resources
resource "aws_s3_bucket" "data" {
bucket = "myapp-data-${terraform.workspace}" # myapp-data-prod, myapp-data-staging
}
For complex multi-environment setups, separate directories (with their own terraform.tfvars) are generally cleaner than workspaces. Workspaces shine for ephemeral environments — like spinning up a temporary environment for a feature branch.
Modules
Modules are reusable building blocks. You call a module with module blocks and pass variables as arguments. This is how you avoid copy-pasting the same 50 lines of VPC configuration across every environment.
# Call a local module
module "vpc" {
source = "./modules/vpc"
vpc_cidr = "10.0.0.0/16"
environment = var.environment
aws_region = var.aws_region
}
# Call a public module from the Terraform Registry
module "alb" {
source = "terraform-aws-modules/alb/aws"
version = "~> 9.0"
name = "my-alb-${var.environment}"
vpc_id = module.vpc.vpc_id
subnets = module.vpc.public_subnet_ids
}
After adding a new module source, always re-run terraform init to download it. The Terraform Registry at registry.terraform.io hosts thousands of community modules for common patterns like EKS clusters, RDS databases, and VPCs.
Real Example: EC2 Instance in a VPC
Here is a complete working example that creates a VPC, a public subnet, an internet gateway, a security group, and an EC2 instance — the minimum required to run a web server on AWS.
Validate Your terraform.tfvars
Before running terraform apply, paste your terraform.tfvars or .env file into our ENV Validator to check for syntax errors, missing required keys, and malformed values.
# main.tf — complete EC2 + VPC example
# --- VPC ---
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = { Name = "${var.project_name}-vpc-${var.environment}" }
}
# --- Internet Gateway (required for public subnets) ---
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "${var.project_name}-igw" }
}
# --- Public Subnet ---
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidr
availability_zone = "${var.aws_region}a"
map_public_ip_on_launch = true # EC2s here get a public IP automatically
tags = { Name = "${var.project_name}-public-${var.environment}" }
}
# --- Route Table: public subnet routes 0.0.0.0/0 to 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.main.id
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
# --- Security Group ---
resource "aws_security_group" "web" {
name = "${var.project_name}-web-sg"
description = "Allow HTTP, HTTPS, and SSH"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTPS"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "SSH (restrict to your IP in production)"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = var.allowed_cidr_blocks
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# --- Fetch the latest Ubuntu 22.04 AMI ---
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical (Ubuntu)
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
}
# --- EC2 Instance ---
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]
key_name = var.key_pair_name
root_block_device {
volume_type = "gp3"
volume_size = 20
encrypted = true
}
# Bootstrap the server with a startup script
user_data = <<-EOF
#!/bin/bash
apt-get update -y
apt-get install -y nginx
systemctl enable nginx
systemctl start nginx
echo "<h1>Hello from Terraform on $(hostname)</h1>" > /var/www/html/index.html
EOF
tags = { Name = "${var.project_name}-web-${var.environment}" }
}
Terraform Best Practices
- Pin provider and module versions. Unpinned versions will break your deployments when providers release breaking changes. Use
~> 5.0syntax (allows patch and minor, blocks major). - Store state remotely with locking. Use S3 + DynamoDB for AWS. Never commit
terraform.tfstateto Git — it often contains secrets. - Use
terraform planin CI/CD. Run plan on pull requests so the team can see exactly what changes will be applied before merging. Use Atlantis or Terraform Cloud for this. - Tag every resource. Use the
default_tagsblock in your AWS provider to applyEnvironment,ManagedBy, andProjecttags to everything automatically. - Separate state per environment. Use different state buckets or keys for prod and staging. Never share state between environments.
- Use
terraform fmtbefore committing. Add it as a pre-commit hook:terraform fmt -check -recursivefails if any files need formatting. - Validate with tflint and tfsec.
tflintcatches provider-specific issues (wrong instance types, deprecated arguments).tfseccatches security misconfigurations like open SSH to0.0.0.0/0. - Use
-targetsparingly. Targeting individual resources duringapplycan leave state inconsistent. Prefer applying the whole configuration. - Never hard-code secrets. Fetch secrets from AWS Secrets Manager, SSM Parameter Store, or Vault using data sources. Use our ENV Validator to audit your
.tfvarsfiles for accidentally committed credentials.
Common Terraform Commands Reference
terraform init # initialize, download providers
terraform init -upgrade # upgrade providers to latest matching version
terraform fmt # format all .tf files
terraform fmt -check # check formatting (non-zero exit if unformatted)
terraform validate # validate configuration syntax
terraform plan # preview changes
terraform plan -out=tfplan # save plan
terraform plan -destroy # preview what destroy would do
terraform apply # apply changes (prompts)
terraform apply -auto-approve # skip confirmation prompt
terraform apply tfplan # apply a saved plan
terraform destroy # destroy all resources
terraform destroy -target=TYPE.NAME # destroy one resource
terraform output # show all outputs
terraform output web_server_ip # show a specific output
terraform state list # list tracked resources
terraform state show TYPE.NAME # show resource details
terraform state pull # download and display remote state
terraform refresh # sync state with real infrastructure
terraform graph | dot -Tpng > graph.png # visualize resource graph
Next Steps
Once you are comfortable with the basics, explore these areas:
- Terraform Cloud / HCP Terraform: Free tier includes remote state storage, plan/apply UI, and team collaboration.
- Terragrunt: A wrapper that adds DRY config, automatic remote state configuration, and module dependency management.
- CDK for Terraform (cdktf): Write Terraform configurations in TypeScript, Python, or Go instead of HCL.
- OpenTofu: The open-source fork of Terraform (post-BSL license change). Drop-in compatible with Terraform 1.5.
- Policy as Code: Use Sentinel (HashiCorp) or Open Policy Agent to enforce rules like "all S3 buckets must be encrypted" as part of the apply process.
Related tools: ENV Validator (for .tfvars files), YAML Validator (for Helm values that complement Terraform), JSON Formatter (for Terraform JSON output), and Docker Cheat Sheet for containerizing the applications you deploy with Terraform.