Skip to main content
RunBook Academy

TerraformIV · HCL: The Terraform Configuration LanguageProduction Terraform

HCL Style and Patterns

Intermediate⏱ ~10 minbash

What you'll learn

  • Place `tags`, `lifecycle`, and `depends_on` in the right location for least friction
  • Choose between variables and literals deliberately, with a production rule
  • Wire `tflint` into CI with a curated ruleset so style is enforced, not debated
  • Use `lifecycle { create_before_destroy }` and `prevent_destroy` correctly
  • Recognise the most common style drift in code review and how to push back on it

Prerequisites

None — start here.

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

terraform fmt and terraform validate are the floor. The next layer is style patterns that make code review faster and incident response more robust. This lesson covers where to put three specific blocks (tags, lifecycle, depends_on), when to expose a variable versus using a literal, and how to enforce the ruleset with tflint.

Where to put tags

tags is a map attribute. Most cloud providers accept it on every taggable resource. The placement rule:

Tag with a local. Always.

locals {
  common_tags = {
    Owner       = "platform@example.com"
    Environment = var.environment
    ManagedBy   = "terraform"
    CostCentre  = var.cost_centre
  }
}

resource "aws_instance" "web" {
  # ... other arguments ...
  tags = merge(local.common_tags, {
    Name = "web-${count.index}"
  })
}

resource "aws_s3_bucket" "logs" {
  # ... other arguments ...
  tags = merge(local.common_tags, {
    Name = "logs-${var.environment}"
  })
}

The merge(local.common_tags, {...}) form gives every resource a consistent baseline plus per-resource Name. The Name ends up first in the merged map because explicit keys win on conflict. Do not list tags twice across files; do not type tags manually per resource.

For modules, accept tags as a variable and merge with module-defined tags:

variable "tags" {
  type        = map(string)
  default     = {}
  description = "Tags applied by the caller. Module-specific tags take precedence on key conflict."
}

# inside each resource:
tags = merge(var.tags, local.common_tags)

Caller-supplied tags lose by default, which is the safer default for a policy-driven environment.

Where to put lifecycle

lifecycle is a meta-argument that controls how Terraform treats a resource during the destroy-and-create cycle. It belongs inside the resource it controls, near the bottom of the block:

resource "aws_db_instance" "primary" {
  identifier        = "primary-${var.environment}"
  engine            = "postgres"
  engine_version    = "16.4"
  instance_class    = "db.t3.medium"
  allocated_storage = 100
  username          = "app"
  password          = data.aws_secretsmanager_secret_version.db_password.secret_string

  tags = merge(local.common_tags, { Name = "primary" })

  lifecycle {
    prevent_destroy = true   # hard constraint for production databases
    ignore_changes  = [password] # secret value is rotated out of band
  }
}

Three settings matter in production:

  1. prevent_destroy = true. Set on resources that must never be destroyed by Terraform: production databases, S3 buckets with retention enabled, KMS keys, certificates with material tied to other systems. The setting is a plan-time check: any plan that includes destruction of the resource fails.
  2. create_before_destroy = true. Set on resources that must roll over without downtime: launch templates, autoscaling groups, ECS services, ELB target groups. Terraform creates the replacement before destroying the old.
  3. ignore_changes = [a, b, ...]. Set sparingly. Acceptable for attributes that mutate out of band (password, current_version, externally-scaled replicas). Unacceptable as a workaround for “I don’t want to think about this attribute”. The latter is always a bug.

Where to put depends_on

depends_on is a meta-argument that explicitly adds an edge to the dependency graph. Two forms:

  • Inside a resource block, against another resource or module address.
  • Inside a module block, against a resource or another module address.
resource "aws_iam_role_policy" "deploy" {
  name = "deploy"
  role = aws_iam_role.deploy.id

  policy = jsonencode({ ... })

  depends_on = [
    aws_iam_role_policy_attachment.s3,
  ]
}

The rule: prefer implicit dependencies over depends_on. If resource A references an attribute of resource B (aws_s3_bucket.logs.id), Terraform already draws the edge. depends_on is for the case when the dependency is real but not expressed in any attribute — typically “resource A uses a value that resource B writes via an external system”. Common examples:

  • An IAM role that should not assume the policy until a aws_iam_policy_attachment has finished.
  • A null_resource that triggers an external runbook before the next resource begins.

If you reach for depends_on to make a plan stop erroring, the right move is almost always to add the missing reference, not the explicit edge.

Variables versus literals

The variable-versus-literal rule:

If the same value is written in two places, make it a variable and pass it in once. If a value is a constant of the platform (region, project ID, account ID), make it a variable so the same module can be reused across platforms. If a value is genuinely local to a resource and never changes, keep it as a literal.

# variable: the value differs across deployments
variable "region" {
  type    = string
  default = "eu-west-1"
}

# variable: the value is a constant of the platform environment
variable "account_id" {
  type = string
  description = "AWS account ID. Provider has a data source that returns this; prefer that."
}

# local: derived value
locals {
  account_id = data.aws_caller_identity.current.account_id
}

# literal: genuinely constant value that the configuration owns
resource "aws_s3_bucket" "static" {
  bucket = "acme-static-content"
  acl    = "private"  # never public for any deployment
}

The mistake to avoid is the opposite: a variable for every literal. Variables are an interface boundary; turning every literal into one adds review time for no gain.

Why your module needs a versions.tf

The version pin block is conventionally separated:

# versions.tf
terraform {
  required_version = ">= 1.9.0, < 2.0.0"

  required_providers {
    aws    = { source = "hashicorp/aws",    version = "~> 5.0" }
    random = { source = "hashicorp/random", version = "~> 3.0" }
  }
}

A single file holds the pins; every change to a pin is a deliberate event. Pair with a CI gate that fails if a contributor pushes without updating required_providers.

tflint — the ruleset enforcer

tflint is a third-party linter for Terraform. It catches issues that terraform validate does not, including:

  • AWS/GCP/Azure-specific rule violations (wrong instance type, deprecated argument).
  • Naming-convention enforcement via terraform_naming_convention.
  • Unused variable and unused output detection.
  • Required-tag presence enforcement via aws_required_tags.

Installation

# Ubuntu / Debian
curl -fsSL https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh | bash

# macOS
brew install tflint

Configuration

.tflint.hcl at the repository root:

plugin "terraform" {
  enabled = true
  preset  = "recommended"
}

rule "terraform_unused_declarations" {
  enabled = true
}

rule "terraform_naming_convention" {
  enabled = true
  format  = "snake_case"
}

rule "terraform_required_providers" {
  enabled = true
}

The provider ruleset (e.g. terraform-aws) checks against the actual provider schema.

CI gate

tflint --init
tflint --recursive --format compact

tflint exits non-zero on any rule violation. Wire it into the same CI step as terraform fmt -check and terraform validate.

Style review patterns

Three patterns to push back on in code review:

“Move the depends_on to an attribute reference.”

Bad: depends_on = [aws_iam_role.deploy] because the configuration reads aws_iam_role.deploy.arn but the timing is not quite right. Good: confirm the configuration actually needs the timing edge; if it does, keep depends_on with a comment. If it does not, remove the edge.

“The literal should be a local.”

Bad: instance_type = var.primary_env == "prod" ? "m7i.large" : "t3.small" written five times across the file. Good: instance_type = local.primary_instance_type lifted into a single locals block.

“The dynamic block is doing what a for_each should do.”

Bad: a dynamic "resource"-shaped pattern (the dynamic block sits inside another block, generating nested resources that actually drive new APIs). Good: extract the nested block to a top-level resource with for_each.

Production failure modes

  1. ignore_changes = [all]. Symptom: drift is invisible. Recovery: remove the rule, fix the attribute.
  2. prevent_destroy = true on a resource being migrated. Symptom: terraform destroy fails with a long diagnostic. Recovery: remove prevent_destroy in the migration PR; restore in the follow-up.
  3. No lifecycle on a database. Symptom: the database is destroyed and recreated during a routine instance-type change. Recovery: add prevent_destroy = true and a thoughtful ignore_changes for the password rotation attribute.
  4. depends_on everywhere. Symptom: plan time grows, the dependency graph loses its real edges. Recovery: prefer attribute references.
  5. tflint is optional. Symptom: the same rule trips the same bug every sprint. Recovery: make it a CI gate; make it block PRs.
  6. require_providers not committed. Symptom: terraform init against a stale lock file. Recovery: commit versions.tf and the lock file together; reject PRs that omit them.

References

What comes next

After this lesson the course moves to the resource model: how Terraform tracks managed objects in state, how drift is detected, and how the plan/run/apply cycle keeps the real world in sync with the declared configuration.

Verification

tflint --init
tflint --recursive
terraform fmt -check -recursive
terraform init -backend=false
terraform validate

Expected:

$ tflint --recursive
$ echo $?
0

$ terraform fmt -check -recursive
$ terraform validate
Success! The configuration is valid.

If tflint --recursive is non-zero, fix the violations. Do not cat .tflint.hcl | sed 's/enabled = true/enabled = false/' to silence the rule. If a rule is genuinely wrong for a specific module, use a per-file .tflint.hcl with the rule set to disabled = true and write a comment explaining why.

Knowledge check · 7 questions

  1. Q1. Which is the right placement of `tags` on every taggable resource in a module?

  2. Q2. Which lifecycle setting prevents Terraform from destroying a production database during a routine refactor?

  3. Q3. `ignore_changes = [all]` is an acceptable workaround when an attribute is hard to control.

  4. Q4. Resource A needs to wait for resource B even though A does not reference any B attribute. What is the production rule?

  5. Q5. Which settings belong inside a `lifecycle` block? (Select all that apply.)

  6. Q6. Where should the required Terraform and provider version pins live in a production module?

  7. Q7. A pull request adds `lifecycle { ignore_changes = [all] }` to a database resource because the password is rotated outside Terraform. What is the right reviewer feedback?

Passing score: 75%. Answers are checked in this browser.