Terraform Fundamentals
Terraform turns infrastructure into reviewable code. This guide covers the workflow, state, variables, modules, loops, lifecycle rules, secrets and multi-provider setups you need to use it confidently.
Core workflow
Terraform is an infrastructure-as-code tool. You describe the resources you want in HCL files, and Terraform compares that description with what exists, then makes the API calls needed to close the gap. It is declarative: you state the end result, not the steps.
The everyday loop has a few commands:
terraform initdownloads providers and modules and configures the backend. Run it first and whenever those change.terraform fmtandterraform validatenormalise formatting and check syntax and internal consistency.terraform planshows what would be created, changed or destroyed. Save it with-out=tfplanto apply exactly what was reviewed.terraform applyexecutes the plan.terraform destroyremoves everything the configuration manages.
Terraform builds a dependency graph from references between resources, creating independent resources in parallel. terraform graph prints that graph.
Providers, resources and variables
A provider is a plugin that talks to an API (a cloud, Kubernetes, a SaaS). A resource block declares one object that provider manages. Pin versions so runs are reproducible.
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.region
}
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = var.instance_type
tags = { Name = "web" }
}
Inputs are declared with variable blocks (type, default, description, optional validation), results are exposed with output blocks, and locals hold computed values reused within a module. Reference attributes of other resources as aws_instance.web.id.
Variable values are resolved in increasing priority: defaults, environment variables named TF_VAR_<name>, terraform.tfvars and *.auto.tfvars, then -var-file and -var on the command line. A common pattern is one .tfvars file per environment.
State and remote backends
Terraform records what it manages in a state file, mapping each resource in your code to a real object. State is how it knows what to update or delete, and it caches attributes for speed. It can contain sensitive values, so treat it as a secret and never commit it to version control.
The default local state does not work for teams. Use a remote backend such as an S3 bucket (with versioning and encryption) plus a locking mechanism, or a managed service, so that state is shared and two people cannot apply at once.
terraform {
backend "s3" {
bucket = "example-terraform-state"
key = "prod/network/terraform.tfstate"
region = "us-east-1"
}
}
Useful state commands: terraform state list, state show, state mv (rename or move a resource without recreating it) and state rm (stop managing something). terraform import brings an existing real resource under management. Prefer these over editing the state file by hand.
Isolating state
Keep separate state per environment and per component to limit blast radius. Two approaches exist: workspaces (several states from one configuration, easy but all in one backend location) and directory layout (a folder per environment with its own backend key, more explicit and usually safer). Components can read each other's outputs using the terraform_remote_state data source.
Modules
A module is a directory of Terraform files. The directory you run commands in is the root module; anything it calls is a child module. Modules take inputs through variables and return values through outputs.
module "network" {
source = "./modules/network"
cidr = "10.0.0.0/16"
}
resource "aws_instance" "app" {
subnet_id = module.network.private_subnet_id
# ...
}
Sources can be local paths, Git URLs or a registry; pin versions for anything remote. Keep modules small and focused, expose only the outputs callers need, and avoid hard-coded file paths (use path.module). Re-run terraform init after adding a module.
Loops and conditionals
| Construct | Use it for |
|---|---|
| count | N copies of a resource, addressed by index |
| for_each | One instance per map key or set member, addressed by key |
| dynamic block | Generate repeated nested blocks (for example security group rules) |
| for expression | Transform lists and maps into new lists and maps |
resource "aws_iam_user" "u" {
for_each = toset(var.user_names)
name = each.value
}
output "arns" {
value = [for u in aws_iam_user.u : u.arn]
}
count has a known weakness: removing an item from the middle of a list shifts indexes and can destroy and recreate later resources. Prefer for_each with stable keys. Conditionals use the ternary form condition ? a : b; combining it with count = var.enabled ? 1 : 0 toggles a resource on or off.
Lifecycle rules and data sources
The lifecycle block adjusts how Terraform treats a resource:
create_before_destroybuilds the replacement before removing the original, which can reduce downtime.prevent_destroymakes any plan that would delete the resource fail.ignore_changesskips drift on chosen attributes that something else manages.replace_triggered_byforces replacement when another object changes.
Data sources (data blocks) read information about things Terraform does not own, such as the latest machine image, an existing VPC or the current account identity. They are read-only and let one configuration build on infrastructure managed elsewhere.
Handling secrets
Do not put credentials in .tf files. Better options:
- Provider credentials from the environment or an assumed role, not from code.
- Sensitive inputs supplied through
TF_VAR_variables, withsensitive = trueon the variable and output to hide them from console output. - Values fetched at run time from a secret store through a data source.
- Encrypted files decrypted outside the repo by your pipeline.
sensitive = true only masks display. Values still land in the state file and in saved plan files, so encrypt the backend, restrict access to it, and treat plan files as confidential.
Multiple providers and regions
Declare every provider under required_providers; terraform init records the chosen versions and checksums in .terraform.lock.hcl, which should be committed. To target several regions or accounts, define extra provider configurations with an alias and select them per resource or per module.
provider "aws" {
alias = "west"
region = "us-west-2"
}
resource "aws_s3_bucket" "replica" {
provider = aws.west
bucket = "example-replica-bucket"
}
module "app_west" {
source = "./modules/app"
providers = { aws = aws.west }
}
Remember that many lookups, such as machine image IDs, differ per region, so resolve them with a data source rather than hard-coding. For multiple accounts, configure an assume_role block in each aliased provider.
Provisioners (local-exec, remote-exec) exist but are a last resort; prefer user data, images or a configuration tool. Terraform also has the templatefile function for rendering scripts and configs from variables.