Terraform describes infrastructure in .tf files and creates it through a plan, then apply cycle. The files are reviewed in Git like any other code, so changes to networks, databases and permissions get the same scrutiny as application changes.
Versions in use on this date. Terraform 1.2 (released May 2022; 1.3 is expected in a few months) and the HashiCorp AWS provider 4.x. Version 4.0 (February 2022) split many S3 bucket settings, such as versioning, into separate resources, which is why older tutorials no longer apply as written.
How a run works
flowchart TB
CODE[tf files in Git] --> INIT[terraform init<br/>download providers and modules, configure backend]
INIT --> VAL[terraform fmt and validate]
VAL --> PLAN[terraform plan<br/>compare code, state and real AWS]
STATE[(State file<br/>remote backend)] --> PLAN
AWSR[(AWS APIs read)] --> PLAN
PLAN --> REVIEW{Human or CI review of the plan}
REVIEW -->|approve| APPLY[terraform apply plan file]
REVIEW -->|reject| CODE
APPLY --> AWSW[(AWS resources created, changed, destroyed)]
APPLY --> STATE
A small but real configuration
versions.tf:
terraform {
required_version = ">= 1.2.0"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 4.0" }
}
}
provider "aws" {
region = var.region
default_tags {
tags = { Project = "shop", Env = var.env, ManagedBy = "terraform" }
}
}
variables.tf and outputs.tf:
variable "region" { type = string, default = "ap-south-1" }
variable "env" { type = string }
variable "bucket_name" {
type = string
description = "Globally unique bucket name"
validation {
condition = can(regex("^[a-z0-9.-]{3,63}$", var.bucket_name))
error_message = "Use 3-63 lowercase letters, digits, dots or hyphens."
}
}
output "bucket_arn" { value = aws_s3_bucket.assets.arn }
main.tf:
resource "aws_s3_bucket" "assets" {
bucket = var.bucket_name
}
resource "aws_s3_bucket_versioning" "assets" {
bucket = aws_s3_bucket.assets.id
versioning_configuration { status = "Enabled" }
}
resource "aws_s3_bucket_public_access_block" "assets" {
bucket = aws_s3_bucket.assets.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "assets" {
bucket = aws_s3_bucket.assets.id
rule { apply_server_side_encryption_by_default { sse_algorithm = "AES256" } }
}
Run it:
terraform init
terraform fmt -recursive && terraform validate
terraform plan -var env=dev -var bucket_name=acme-assets-dev-2022 -out tfplan
terraform apply tfplan
terraform output
terraform destroy -var env=dev -var bucket_name=acme-assets-dev-2022 # only for disposable environments
The plan output marks each resource with + create, ~ update in place, -/+ replace (destroy and create) and - destroy. Treat replace on databases, load balancers or anything stateful as a stop sign: read why (usually an immutable argument changed).
Concepts you will use every day
| Concept | Meaning |
|---|---|
| Provider | Plugin that talks to an API (AWS, Kubernetes, GitHub) |
| Resource | A thing Terraform creates and manages |
| Data source | Read-only lookup of something that already exists |
| Variable / output | Inputs and results of a configuration or module |
| Module | Reusable folder of resources, called with module "vpc" { source = "..." } |
| State | Terraform’s record mapping code to real resource IDs |
| Workspace / directory per environment | Ways to separate dev, staging and prod |
Use for_each over a map for several similar resources (stable addresses), and avoid count for things that may be reordered. Reference resources, do not hard-code IDs, so Terraform builds the dependency graph.
Drift, import and refactoring
- Drift: someone changed a resource in the console.
terraform planshows the difference; either apply to fix it or update the code to match. - Import an existing resource into state:
terraform import aws_s3_bucket.assets existing-bucket-name, then write matching code until the plan is clean. - Rename in code without destroying:
terraform state mv aws_s3_bucket.old aws_s3_bucket.new. - Protect critical resources with
lifecycle { prevent_destroy = true }, and useignore_changessparingly for attributes managed elsewhere.
Safe-practice checklist
- Remote state with locking (see the next post); never commit
terraform.tfstate, which can hold secrets in plain text. - Commit
.terraform.lock.hclso everyone uses the same provider builds. - Run
planin pull requests and post the output; apply only frommainthrough CI with a reviewed plan file. - Mark secrets
sensitive = truebut remember they still land in state; better, keep secrets in Secrets Manager and reference them at runtime. - Give the apply role least privilege and use short-lived credentials rather than a shared admin key.
- Pin module and provider versions and upgrade deliberately, reading changelogs.
- Tag everything (the
default_tagsblock above) so costs and owners are visible.
Troubleshooting
Error acquiring the state lock: another run is active, or a crashed run left a lock (see remote state post).BucketAlreadyExists: S3 names are global; add an account or environment suffix.- Provider upgrade changes plans: read the upgrade guide; test in dev first.
- Slow plans on large stacks: split into smaller states by component and use
-targetonly for emergencies.