TerraformIV · HCL: The Terraform Configuration LanguageProduction Terraform
HCL Style and Patterns
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
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:
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.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.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
resourceblock, against another resource or module address. - Inside a
moduleblock, 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_attachmenthas finished. - A
null_resourcethat 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
variableand pass it in once. If a value is a constant of the platform (region, project ID, account ID), make it avariableso 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
ignore_changes = [all]. Symptom: drift is invisible. Recovery: remove the rule, fix the attribute.prevent_destroy = trueon a resource being migrated. Symptom:terraform destroyfails with a long diagnostic. Recovery: removeprevent_destroyin the migration PR; restore in the follow-up.- No
lifecycleon a database. Symptom: the database is destroyed and recreated during a routine instance-type change. Recovery: addprevent_destroy = trueand a thoughtfulignore_changesfor the password rotation attribute. depends_oneverywhere. Symptom: plan time grows, the dependency graph loses its real edges. Recovery: prefer attribute references.tflintis optional. Symptom: the same rule trips the same bug every sprint. Recovery: make it a CI gate; make it block PRs.require_providersnot committed. Symptom:terraform initagainst a stale lock file. Recovery: commitversions.tfand the lock file together; reject PRs that omit them.
References
- HashiCorp, “
lifecycleBlock” — https://developer.hashicorp.com/terraform/language/meta-arguments/lifecycle - HashiCorp, “
depends_on” — https://developer.hashicorp.com/terraform/language/meta-arguments/depends_on tflintdocumentation — https://github.com/terraform-linters/tflint
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
Q1. Which is the right placement of `tags` on every taggable resource in a module?
Q2. Which lifecycle setting prevents Terraform from destroying a production database during a routine refactor?
Q3. `ignore_changes = [all]` is an acceptable workaround when an attribute is hard to control.
Q4. Resource A needs to wait for resource B even though A does not reference any B attribute. What is the production rule?
Q5. Which settings belong inside a `lifecycle` block? (Select all that apply.)
Q6. Where should the required Terraform and provider version pins live in a production module?
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.