TL;DR#
| Topic | Short answer |
|---|---|
| Syntax | Declarative HCL: resource, variable, output, module |
| Providers | Plugins that translate HCL into API calls (AWS, GCP, Azure, etc.) |
| Modules | Code reuse — your own or from the Terraform Registry |
| tfvars | .tfvars files separate environment values from infrastructure logic |
| Environments | Use workspaces (simple) or file structure (recommended for teams) |
| Trunk-based | Frequent commits to main, feature flags to control environments |
| Branch-based | Branches per environment (staging, prod), risk of merge hell |
| State | .tfstate + remote backend (S3+GCS+Azure) with locking |
| Drift | terraform plan detects; terraform apply -refresh-only reconciles |
1. Terraform Syntax in 5 Minutes#
Terraform uses HCL (HashiCorp Configuration Language), a declarative language where you describe the desired state of your infrastructure, not the steps to get there. If you’ve never seen HCL before, relax — it was designed to be read by humans, not robots.
The four blocks you’ll use every single day:
# 1. Provider — who Terraform talks to
provider "aws" {
region = "us-east-1"
}
# 2. Variable — parameterizable values
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t2.micro"
}
# 3. Resource — what you want to create
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = var.instance_type
tags = {
Name = "web-server"
}
}
# 4. Output — values exported after apply
output "instance_ip" {
value = aws_instance.web.public_ip
}A few expressions that save time:
# Variable interpolation
name = "app-${var.environment}"
# Ternary conditional (yes, HCL has them)
instance_type = var.env == "prod" ? "t2.large" : "t2.micro"
# Loop with for_each
resource "aws_s3_bucket" "buckets" {
for_each = toset(["logs", "backups", "media"])
bucket = "${each.key}-${var.project}"
}And the commands you’ll type constantly:
terraform init # Downloads providers and modules
terraform plan # Preview what will change (doesn't apply)
terraform apply # Applies the changes
terraform destroy # Removes everything
terraform fmt # Formats HCL code
terraform validate # Validates syntaxThe basic flow is init → plan → apply. Never apply without seeing the plan first. I’ve seen people nuke production because they trusted -auto-approve. Don’t be that person.
2. Providers: The Bridge Between Code and Cloud#
Terraform doesn’t create anything by itself. It delegates execution to providers — plugins that translate HCL into real API calls.
Think of it this way: when you declare provider "aws", Terraform downloads the AWS provider binary and uses it to authenticate and call the AWS API. Each resource belongs to a provider (aws_instance → aws provider).
The providers you’ll see most often:
| Provider | Use case |
|---|---|
aws | EC2, S3, RDS, Lambda, VPC, IAM |
azurerm | Azure VMs, Storage, AKS, CosmosDB |
google | GCP Compute, GKE, Cloud Storage |
kubernetes | Deployments, Services, ConfigMaps on K8s |
helm | Helm charts managed as code |
cloudflare | DNS, Workers, Pages |
github | Repositories, teams, branch protection |
datadog | Dashboards, monitors, alerts |
The Terraform Registry has over 3,000 providers. You’ll rarely need something that isn’t there.
Something that took me a while to learn and is genuinely useful: provider aliases. When you need to manage multiple regions or AWS accounts in the same code:
provider "aws" {
region = "us-east-1"
}
provider "aws" {
alias = "west"
region = "us-west-2"
}
resource "aws_instance" "west_server" {
provider = aws.west # Uses the west coast provider
# ...
}3. Modules: Stop Copying and Pasting#
Modules are like functions for infrastructure. Instead of repeating 50 lines of VPC config in every project, you call a module.
Registry Modules#
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.0.0"
name = "my-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
}Three precautions I’d take with third-party modules:
- Always pin the version (
version = "5.0.0"), never uselatest. Breaking changes in popular modules are more common than you’d think. - Read the module’s source code before using it in production. Popular doesn’t mean well-written.
- If the module is critical, fork it. Better to depend on your own repo than wake up on a Sunday to a deprecated module.
Your Own Modules#
A convention that works well:
modules/
├── networking/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── compute/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── database/
├── main.tf
├── variables.tf
└── outputs.tfmodule "database" {
source = "./modules/database"
engine = "postgres"
instance_class = "db.t3.medium"
environment = var.environment
}The main.tf (resources), variables.tf (inputs), and outputs.tf (returns) split isn’t mandatory, but teams with more than two people will thank you.
4. tfvars: Code is Logic, tfvars is Configuration#
.tfvars files assign values to variables without touching the code. This separates infrastructure logic from environment-specific values.
# variables.tf — declares variables and defaults
variable "environment" {
type = string
default = "dev"
}
variable "instance_count" {
type = number
default = 1
}# staging.tfvars
environment = "staging"
instance_count = 2# production.tfvars
environment = "prod"
instance_count = 5Usage:
terraform plan -var-file="staging.tfvars"
terraform apply -var-file="staging.tfvars"
terraform plan -var-file="production.tfvars"
terraform apply -var-file="production.tfvars"Never commit secrets in .tfvars. Use sensitive = true on the variable and pass values via environment variables (TF_VAR_db_password) or a secrets manager. Create a terraform.tfvars.example with dummy values for team onboarding. And add *.tfvars (except .example) to .gitignore.
5. Environments in Terraform: Which Strategy to Use?#
Managing dev, staging, and production with Terraform has three main approaches.
Workspaces (the simplest)#
terraform workspace new staging
terraform workspace new production
terraform workspace select staging
terraform apply -var-file="staging.tfvars"One backend, multiple state files. Fast and native, but all environments share the same backend and credentials. One terraform destroy on the wrong workspace and you’ve deleted production. I’ve seen it happen.
File Structure (my recommendation for teams)#
terraform/
├── environments/
│ ├── dev/
│ │ ├── main.tf
│ │ ├── terraform.tfvars
│ │ └── backend.tf
│ ├── staging/
│ │ ├── main.tf
│ │ ├── terraform.tfvars
│ │ └── backend.tf
│ └── prod/
│ ├── main.tf
│ ├── terraform.tfvars
│ └── backend.tf
└── modules/
├── networking/
├── compute/
└── database/Each environment gets its own directory, its own state, and its own credentials. Real isolation. The cost is a bit of code duplication, but for teams of 2–10 people it’s well worth it.
Terragrunt (for larger teams)#
Terragrunt is a wrapper that eliminates duplication while maintaining isolation:
# terragrunt.hcl
terraform {
source = "git::git@github.com:org/modules.git//networking?ref=v1.0.0"
}
inputs = {
environment = "staging"
cidr_block = "10.1.0.0/16"
}Quick summary:
| Strategy | Team size | Risk | Complexity |
|---|---|---|---|
| Workspaces | 1-2 people | Medium | Low |
| File Structure | 2-10 people | Low | Medium |
| Terragrunt | 10+ people | Low | High |
My suggestion: start with file structure. If the team grows to the point where duplication hurts, migrate to Terragrunt.
6. Trunk-Based Development with Terraform#
Trunk-based development (TBD) means committing directly to main several times a day, using feature flags and branch protection rules to control deploys.
main ──●──●──●──●──●──●──●── (frequent commits)
│ │
▼ ▼
[plan] [plan]
│ │
▼ ▼
staging productionIn practice:
# 1. Change staging tfvars
vim environments/staging/terraform.tfvars
# 2. Commit directly to main
git add . && git commit -m "staging: bump instance_count to 3"
git push origin main
# 3. CI detects the push, runs plan on staging
# 4. Clean plan → CI applies to staging
# 5. Validated in staging → promote to prod (same code, different tfvars)A sample pipeline with GitHub Actions:
name: Terraform CI
on:
push:
branches: [main]
jobs:
terraform:
strategy:
matrix:
environment: [staging, production]
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Terraform Plan
working-directory: environments/${{ matrix.environment }}
run: terraform plan -input=false
- name: Terraform Apply (prod only with manual approval)
if: matrix.environment == 'staging' || github.event_name == 'workflow_dispatch'
working-directory: environments/${{ matrix.environment }}
run: terraform apply -auto-approve -input=falseThe advantages of TBD are clear: no long-lived branches, no merge hell, main always reflects what is (or will be) in production, and every small change gets reviewed and deployed fast.
Just watch out for three things: feature flags — if a feature is incomplete, control it with a conditional variable, not a separate branch. Production protection — require manual approval before applying to prod. And tests in CI — terraform validate and terraform fmt -check on EVERY commit.
7. Branch-Based Development: The Traditional Model#
In the branch-based model, each environment gets a long-lived branch:
main ──────●────────────●────────────●── (production)
\\ \\ \\
staging ────●───●──●────●────────────●── (staging)
\\
dev ──────────────●──●──●──●──●──●──●── (development)Typical flow:
git checkout dev
# ... change resources ...
git commit -m "add redis cache"
git checkout staging
git merge dev
terraform plan && terraform apply
git checkout main
git merge staging
terraform plan && terraform applyLooks organized, but it brings some real problems:
| Problem | Why it hurts |
|---|---|
| Merge hell | dev and staging branches diverge. Resolving conflicts in HCL isn’t like resolving them in Python. |
| State vs Branch | The state file doesn’t know branches exist. If dev and staging point to the same backend, chaos is guaranteed. |
| Manual promotion | Relies on someone remembering to merge. Fragile and error-prone automation. |
| Illusion of isolation | The branch is separate but the state isn’t. Without file structure, branches are cosmetic. |
Branch-based still makes sense for small teams (1–3 people) without mature CI/CD, or in monorepos where Terraform is just one piece. But honestly, most teams are migrating to trunk-based with file structure — real isolation and continuous deployment.
8. State and Drift: Where the Magic Happens (and the Problems Too)#
The State File#
The .tfstate is a JSON file that maps declared resources in .tf to actual cloud resources. Without it, Terraform wouldn’t know what to destroy or modify.
main.tf ──► terraform apply ──► aws_instance.web (i-0a1b2c3d4e5f)
│
└── terraform.tfstate
resource "aws_instance" "web" {
id = "i-0a1b2c3d4e5f"
...
}Remote Backend (mandatory if you’re more than one person)#
State should never be local. Use a remote backend with locking:
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
encrypt = true
}
}| Backend | Locking | Cost |
|---|---|---|
| S3 | Yes (native) | Low |
| GCS | Yes (native) | Low |
| Azure Storage | Yes (lease) | Low |
| HCP Terraform | Yes (native) | Free tier |
Golden rule: two people should never apply to the same state at the same time. Locking prevents this, but only if you configure it properly.
Drift: When Reality Diverges from Code#
Drift happens when someone changes a resource outside Terraform (console, CLI, another team). The state gets stale and you only find out when something breaks.
terraform plan -detailed-exitcode
# exit 0 = everything in sync
# exit 1 = error
# exit 2 = drift detectedTo fix it:
Reconcile (import the change):
terraform apply -refresh-only # Updates state without touching infra
terraform plan # Check if things match — if not, adjust .tf and applyOverride (force the code):
terraform apply # Changes infra back to what's in codeFor prevention, the combo that works: block write access on cloud consoles (IAM read-only for humans), every change goes through Terraform, and run plan in CI periodically — a daily cron job that alerts on drift. Simple and effective.
9. A Structure That Scales#
Pulling everything together, this structure works from 1 to 50 people:
terraform/
├── environments/
│ ├── dev/
│ │ ├── backend.tf
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ └── terraform.tfvars
│ ├── staging/
│ │ ├── backend.tf
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ └── terraform.tfvars
│ └── prod/
│ ├── backend.tf
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── terraform.tfvars
├── modules/
│ ├── networking/
│ ├── compute/
│ ├── database/
│ └── monitoring/
├── .github/
│ └── workflows/
│ ├── terraform-plan.yml
│ ├── terraform-apply.yml
│ └── drift-detection.yml
├── .gitignore
├── .terraform-version
└── README.mdWrapping Up#
Terraform is massive, but the essence comes down to six concepts:
- HCL describes — you declare what you want, Terraform figures out how to get there
- Providers execute — the engine that turns code into real resources
- Modules organize — code reuse, yours or from the Registry
- tfvars separate — code is logic, tfvars is environment configuration
- Environments isolate — file structure > workspaces for serious teams
- State is truth — protect with remote backend + locking, monitor drift
If there’s one tip I’d give to beginners: start small and iterate. Create a simple EC2 instance, then add a module, then separate environments. Don’t try to build the perfect structure on day 1 — it will evolve as your team grows.
This article is part of the IaC Tools series. The previous one was Terraform vs Pulumi: Which One to Choose in 2026?.
Questions or suggestions? Find me on LinkedIn.
