Terraform Tutorial: Learn IaC from Scratch (2026)
I used to create cloud resources through a web console. Click, wait, click again, wait longer. Then someone deleted a production load balancer by accident, and we had no record of what was configured. That is when I adopted Terraform. HashiCorp Terraform treats infrastructure as code: every EC2 instance, DNS record, and database is declared in HCL configuration files, versioned in Git, and applied through a consistent workflow. Plan shows you what will change, apply makes it happen, and state tracks what exists. This tutorial covers Terraform from the first main.tf to production modules and state management strategies that keep multi-team deployments safe in 2026.
Terraform Fundamentals and HCL Syntax
HashiCorp Configuration Language (HCL) is Terraform's declarative language. A configuration consists of blocks: terraform settings, provider definitions, resource declarations, data sources, and variables. The terraform block specifies the required provider versions and the backend for storing state. Providers are plugins that interface with cloud APIs — AWS, Azure, GCP, Kubernetes, GitHub, and hundreds more. Each resource block declares a real infrastructure object with arguments. The first resource I always create is an AWS S3 bucket or a local file to verify the workflow. HCL supports expressions, functions, and dynamic blocks that let you conditionally create resources. The key concept is that Terraform builds a dependency graph from resource references and creates or destroys resources in the correct order.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_s3_bucket" "data" {
bucket = "my-app-data-2026"
tags = {
Environment = "production"
}
}
State Management and Backends
Terraform state is the mapping between your configuration and the real-world infrastructure. It contains resource metadata, dependencies, and sensitive attribute values. Local state on your laptop is fine for learning but dangerous for teams — if you lose the state file, Terraform cannot manage the existing resources. Remote backends solve this. The S3 backend with DynamoDB locking is the standard pattern for AWS. The backend stores the state file in an S3 bucket, and DynamoDB provides locking so two team members cannot apply concurrently. Encryption at rest and in transit is automatic. For multi-environment setups, I use separate state files per environment with workspaces or directory layouts. Never commit state files to Git — they contain secrets and are binary artifacts.
terraform {
backend "s3" {
bucket = "my-org-terraform-state"
key = "prod/network/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
Variables, Outputs, and Data Sources
Hard-coding values makes configurations brittle. Input variables parameterize your configuration. Define variables with type constraints, descriptions, and default values in variables.tf. Use them as var.variable_name throughout. Sensitive variables like database passwords can be marked sensitive=true to prevent display in logs and plan output. Outputs expose resource attributes after apply — useful for passing information between configurations or displaying in CI. Data sources query existing infrastructure that Terraform does not manage. For example, you can fetch the latest Amazon Linux 2 AMI and use it in a launch template without hard-coding the AMI ID. Data sources are how Terraform integrates with the existing environment and enables gradual adoption.
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.medium"
}
variable "allowed_ips" {
type = list(string)
}
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*"]
}
}
output "instance_ip" {
value = aws_instance.web.public_ip
description = "Public IP of web server"
}
Modules for Reusable Infrastructure
Modules are containers for multiple resources that are used together. The root module is your main configuration. Child modules encapsulate reusable patterns like VPC creation, EC2 auto-scaling groups, or RDS databases. I maintain a modules directory with versioned modules shared across projects. Module sources can be local paths, Git URLs, or the Terraform Registry. Use inputs and outputs to define the module interface. The key to good module design is knowing what to parameterize and what to keep internal. A VPC module should expose subnet IDs, NAT gateway IPs, and route table IDs as outputs. Internally, it manages the VPC, subnets, internet gateway, NAT gateways, and route tables. Terraform Registry hosts thousands of community modules that can accelerate your IaC adoption.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.0.0"
name = "myapp-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
enable_nat_gateway = true
enable_vpn_gateway = false
tags = {
Environment = "production"
}
}
Workspaces and Environment Strategy
Managing development, staging, and production environments with Terraform requires an isolation strategy. Workspaces are named state files within the same backend configuration. The default workspace is used if you do not specify one. I create workspaces for dev, staging, and prod, then use terraform.workspace expressions to conditionally configure resources. A production workspace might use larger instance types and multiple availability zones, while dev uses t3.micro and a single AZ. An alternative approach is directory structure: separate directories per environment with their own backend configuration. I find workspaces simpler for small teams and directory structure better for large organizations where different teams manage different environments.
resource "aws_instance" "app" {
ami = data.aws_ami.amazon_linux.id
instance_type = terraform.workspace == "prod" ? "t3.large" : "t3.micro"
tags = {
Name = "app-${terraform.workspace}"
Environment = terraform.workspace
}
}
# Create workspaces:
# terraform workspace new dev
# terraform workspace new staging
# terraform workspace new prod
Provisioners, Lifecycle, and Production Patterns
Provisioners like file, remote-exec, and local-exec run scripts during resource creation or destruction. I use them sparingly — they are a last resort when no Terraform resource exists for a task. The preferred pattern is to separate provisioning (Terraform) from configuration (Ansible or cloud-init). Lifecycle rules control resource creation, update, and deletion behavior. create_before_destroy ensures replacement resources are created before old ones are destroyed, enabling zero-downtime updates for load balancers and databases. prevent_destroy adds a safety lock to critical resources. Preconditions and postconditions validate assumptions before and after resource changes. In production, always run terraform plan in CI and require human approval for apply to production. Use policy as code with Sentinel or OPA to enforce rules like tagging requirements and instance type restrictions.
resource "aws_db_instance" "primary" {
engine = "postgres"
engine_version = "16.3"
instance_class = "db.r6g.large"
allocated_storage = 100
skip_final_snapshot = false
lifecycle {
prevent_destroy = true
create_before_destroy = true
}
}
resource "null_resource" "provisioner_example" {
provisioner "local-exec" {
command = "echo ${aws_instance.web.public_ip} >> ips.txt"
}
}
Frequently Asked Questions
What is the difference between Terraform and Pulumi?
Terraform uses HCL, a domain-specific language, while Pulumi uses general-purpose languages like TypeScript, Python, and Go. Terraform has a larger provider ecosystem and more mature state management. Pulumi offers more programming flexibility with loops and conditionals as native language constructs. Choose Terraform for infrastructure-focused teams and Pulumi for teams that want to use existing programming skills.
How do I handle secrets in Terraform state?
State files contain all resource attributes in plaintext, including secrets like database passwords. Always use a remote backend with encryption at rest. Enable state file encryption with KMS for S3 backends. Mark sensitive variables as sensitive=true. Never commit state files. Use dynamic credentials with providers like AWS IAM Roles Anywhere to avoid storing long-lived credentials.
What happens if someone runs terraform apply outside of CI?
Without locking, two concurrent applies can corrupt state. Use a backend with locking — DynamoDB for S3, Consul, or Terraform Cloud. Configure CI to run plan on pull requests and apply only from the CI pipeline with human approval. Restrict who has credentials to run apply outside CI through cloud IAM policies.
How do I refactor resources without destroying and recreating them?
Use terraform state mv to rename or move resources within state. Use terraform import to bring existing resources under Terraform management. For complex refactors like splitting a module, use moved blocks in Terraform 1.8+ to declare the migration declaratively. Always test refactors in a non-production environment first.
Originally published on Ayodhyyya. Last updated June 1, 2026.