Terraform — Practical
Terraform — Practical patterns
Section titled “Terraform — Practical patterns”Repo layout (multi-env)
Section titled “Repo layout (multi-env)”infra/ modules/ vpc/ app/ rds/ live/ dev/ main.tf backend.tf terraform.tfvars prod/ main.tf backend.tf terraform.tfvars .terraform.lock.hclOr with Terragrunt for DRY:
infra/ modules/ live/ terragrunt.hcl dev/ vpc/terragrunt.hcl app/terragrunt.hcl prod/ vpc/terragrunt.hcl app/terragrunt.hclBackend with lock (S3+DynamoDB)
Section titled “Backend with lock (S3+DynamoDB)”terraform { required_version = ">= 1.6" backend "s3" { bucket = "tfstate-prod" key = "platform/network.tfstate" region = "eu-west-1" dynamodb_table = "tflock" encrypt = true }}Bootstrap chicken-and-egg: create bucket+table once with local state, then migrate.
Reusable module
Section titled “Reusable module”variable "name" { type = string }variable "image" { type = string }variable "subnets" { type = list(string) }variable "tags" { type = map(string) default = {} }
resource "aws_ecs_task_definition" "t" { family = var.name ...}resource "aws_ecs_service" "s" { name = var.name ...}
output "service_arn" { value = aws_ecs_service.s.id }module "api" { source = "../../modules/app" name = "api" image = "ghcr.io/org/api:1.2.3" subnets = data.terraform_remote_state.network.outputs.private_subnets tags = { env = "prod", team = "platform" }}for_each over map
Section titled “for_each over map”variable "buckets" { type = map(object({ versioning = bool })) default = { "logs" = { versioning = true }, "assets" = { versioning = false }, }}
resource "aws_s3_bucket" "b" { for_each = var.buckets bucket = "myorg-${each.key}"}
resource "aws_s3_bucket_versioning" "v" { for_each = var.buckets bucket = aws_s3_bucket.b[each.key].id versioning_configuration { status = each.value.versioning ? "Enabled" : "Suspended" }}Cross-stack data via remote state
Section titled “Cross-stack data via remote state”data "terraform_remote_state" "network" { backend = "s3" config = { bucket = "tfstate-prod" key = "platform/network.tfstate" region = "eu-west-1" }}
resource "aws_security_group" "app" { vpc_id = data.terraform_remote_state.network.outputs.vpc_id}Or look up via data source (no remote state coupling):
data "aws_vpc" "default" { tags = { Name = "main" } }Refactor without destroy (TF 1.1+ moved blocks)
Section titled “Refactor without destroy (TF 1.1+ moved blocks)”moved { from = aws_instance.old_name to = aws_instance.new_name}For modules:
moved { from = aws_s3_bucket.b to = module.storage.aws_s3_bucket.b}Import existing resources (TF 1.5+ block syntax)
Section titled “Import existing resources (TF 1.5+ block syntax)”import { to = aws_s3_bucket.logs id = "company-logs-prod"}resource "aws_s3_bucket" "logs" { bucket = "company-logs-prod"}Then terraform plan -generate-config-out=generated.tf to scaffold config.
Sensitive variables
Section titled “Sensitive variables”variable "db_password" { type = string sensitive = true }
output "db_endpoint" { value = aws_db_instance.x.endpoint sensitive = false}Pre-commit hooks
Section titled “Pre-commit hooks”repos: - repo: https://github.com/antonbabenko/pre-commit-terraform rev: v1.96.1 hooks: - id: terraform_fmt - id: terraform_validate - id: terraform_tflint - id: terraform_trivy - id: terraform_docsCI workflow (GitHub Actions)
Section titled “CI workflow (GitHub Actions)”name: terraformon: [pull_request, push]jobs: tf: runs-on: ubuntu-latest permissions: { id-token: write, contents: read, pull-requests: write } steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::ACCT:role/tf-ci aws-region: eu-west-1 - uses: hashicorp/setup-terraform@v3 - run: terraform fmt -check -recursive - run: terraform init - run: terraform validate - run: terraform plan -no-color -out=tfplan if: github.event_name == 'pull_request' - run: terraform apply -auto-approve tfplan if: github.ref == 'refs/heads/main'OIDC-based AWS auth → no long-lived secrets.
Useful CLI tricks
Section titled “Useful CLI tricks”# limit plan/apply to a targetterraform plan -target=module.api
# refresh state onlyterraform apply -refresh-only
# generate dependency graphterraform graph | dot -Tsvg > graph.svg
# inspect provider pluginsterraform providers
# break monolithic state — splitterraform state mv aws_s3_bucket.x other-state.tfstateTools to know
Section titled “Tools to know”- Terragrunt — DRY config, dependency between stacks.
- Atlantis — PR-driven Terraform automation.
- Terraform Cloud / Spacelift / env0 — managed runs.
- tflint — lint rules.
- trivy / checkov / tfsec — security scan.
- infracost — show cost diff per PR.
- terraform-docs — auto-generate README from variables/outputs.
Common pitfalls (revisited)
Section titled “Common pitfalls (revisited)”- Editing
.tfstateby hand. Almost always wrong. applyagainst prod from a dev machine. Use CI.terraform destroyon shared infra. Addprevent_destroy.- Long iteration loop — make modules small.
- Storing secrets in
terraform.tfvarsand committing. count = length(...)where the list isn’t known at plan time → “value depends on resource” cycle.