HCL Syntax Basics

Quote

“A language that doesn’t affect the way you think about programming is not worth knowing.”

Alan Perlis, Epigrams on Programming (1982)

Blocks and Arguments

HCL has two structural elements:

  • Block — a container with a type, optional labels, and a body in braces. Example: resource "google_compute_network" "main" { ... }. This is a resource block with type google_compute_network and Terraform-internal name main.
  • Argument — a key-value pair inside a block. Example: name = "data-pipeline-vpc". Sets one property of the resource.

Blocks can be nested. For example, a resource block may contain a template block, which contains a containers block, which contains env blocks.


flowchart TD
    R["resource 'google_cloud_run_v2_service' 'dashboard'"]
    T["template { }"]
    C["containers { }"]
    E1["env { name = 'DB_HOST' }"]
    E2["env { name = 'DB_PORT' }"]
    V["volumes { }"]

    R --> T
    T --> C
    T --> V
    C --> E1
    C --> E2

File Naming and Organization

Terraform merges all .tf files in a directory into a single configuration. File names have no impact on behavior — you could rename network.tf to dodo.tf and everything would still work. Files are split purely for human readability and organization.

A typical file layout for a GCP project:

FileResourcesPurpose
main.tfterraform, providerProvider version, GCS backend
variables.tf6 variablesInputs: project, region, zone, passwords, labels
network.tfVPC, subnet, router, NAT, 5 firewall rulesNetwork isolation and traffic control
compute.tf2 GCE instancesSQL Server VM + Airflow VM
iam.tf3 service accounts, 8 IAM bindings, 4 conditional Datadog bindingsIdentity and access management
secrets.tf2 secrets, 2 secret versionsDatabase password + Datadog API key
registry.tf1 Artifact Registry repositoryDocker image storage with cleanup
run.tf1 Cloud Run service, 2 Cloud Run jobs, 1 IAM binding, localsDashboard + pipeline + setup
ci.tf1 service account, 4 IAM bindingsGitHub Actions deployment permissions
outputs.tf6 outputsURLs and IPs for provisioned resources

Terraform Name vs GCP Name

Every resource has two names. The first label after the resource type ("allow_sql") is the Terraform-internal name — used only inside .tf files to reference this resource (e.g., google_compute_firewall.allow_sql.id). The name argument ("allow-sql-from-airflow") is the actual name in GCP — what appears in the Console, gcloud commands, and API calls. They don’t have to match.

Naming Example

Declare a firewall resource with Terraform-internal name allow_sql and GCP name allow-sql-from-airflow.

resource "google_compute_firewall" "allow_sql" {
  name = "allow-sql-from-airflow"
}
NameWhere it livesUsed for
"allow_sql"Terraform onlyReferencing this resource in other .tf files (e.g., google_compute_firewall.allow_sql.id)
"allow-sql-from-airflow"GCPWhat appears in the Console, gcloud commands, and API calls

Block Types

The variable and output blocks below are covered in depth in variables-and-outputs, which extends HCL syntax with parameterization, type constraints, and validation rules.

The most common block types in Terraform:

Block TypePurposeExample
terraformTop-level configuration — version constraints, backendterraform { required_version = ">= 1.5" }
providerConfigures a cloud providerprovider "google" { project = var.project_id }
resourceDeclares an infrastructure objectresource "google_compute_network" "main" { ... }
variableDeclares an input parametervariable "project_id" { type = string }
outputExposes a value after applyoutput "url" { value = resource.uri }
localsDefines computed valueslocals { sql_ip = resource.network_interface[0].network_ip }
dataReads existing infrastructure (not created by this config)data "google_project" "current" {}

Declarative vs Imperative

Terraform is declarative: you describe the desired end state, and Terraform computes the steps to reach it. This is fundamentally different from imperative tools like bash scripts or Ansible playbooks, which describe the sequence of actions to perform.

Declarative (Terraform): “There should be a VM named data-pipeline-sql with these properties.” Imperative (bash): “Run gcloud compute instances create data-pipeline-sql ... with these flags.”

The declarative approach means Terraform can determine whether a resource already exists, needs updating, or needs to be recreated — and it can handle all three cases automatically. See plan-apply-destroy for the workflow that turns declarative config into real infrastructure.

HCL Type System

Every value in HCL has a type. Terraform uses types to validate variable inputs, enforce constraints, and determine how values can be combined in expressions. Understanding the type system is essential for writing correct variable blocks and for expressions. See variables-and-outputs for type constraints in practice.

Primitive Types

TypeDescriptionExample
stringUTF-8 text"us-central1"
numberInteger or float (64-bit)3, 3.14
boolBooleantrue, false

Terraform automatically converts between primitives when unambiguous: "42" becomes 42 in a numeric context, and true becomes "true" in a string context.

Collection Types

TypeDescriptionExample
list(type)Ordered sequence, all elements same type["a", "b", "c"]
set(type)Unordered unique elements, all same typetoset(["a", "b"])
map(type)Key-value pairs, all values same type{ dev = "us-central1", prod = "us-east1" }

Lists are indexed by position (element(list, 0)). Maps are indexed by key (map["dev"]). Sets have no index — iterate with for_each.

Duplicate map keys silently discard earlier values

{ a = 1, a = 2 } evaluates to { a = 2 } with no error. This can cause hard-to-debug issues when merging maps with merge().

Use keys() and length() to verify map integrity after merges in terraform console.

Structural Types

TypeDescriptionExample
object({...})Fixed set of named attributes, each with its own typeobject({ name = string, count = number })
tuple([...])Fixed-length sequence, each element with its own typetuple([string, number, bool])

Objects and tuples are used in variable type constraints when a collection needs mixed types. Objects support optional() attributes (Terraform 1.3+): object({ name = string, tags = optional(map(string), {}) }).

Special Types

TypeDescription
anyAccepts any type — Terraform infers the actual type at runtime
nullAbsence of a value — used to conditionally omit arguments

any is useful for generic module inputs but loses type safety. Prefer explicit types where possible.

Expression Syntax

HCL expressions go anywhere a value is expected — argument values, locals definitions, output blocks, and conditional logic. Expressions are evaluated at terraform plan time.

String Interpolation

Embed expressions inside strings with ${}:

Concatenate variable values into a resource name using ${} interpolation.

name = "${var.project_id}-${var.environment}-vpc"

For directive-based templates (loops and conditionals inside strings), use %{}:

Use %{if} directives to conditionally insert text within a string template.

description = "%{if var.environment == "prod"}Production%{else}Non-production%{endif} VPC"

Conditional Expressions

The ternary operator selects between two values based on a boolean condition:

Select machine type based on the environment variable.

machine_type = var.environment == "prod" ? "n2-standard-4" : "e2-medium"

Combine with null to conditionally omit an argument entirely — Terraform treats null as “use the provider default”:

Return null to let the provider use its default value when the toggle is off.

min_tls_version = var.enforce_tls ? "TLS_1_2" : null

For Expressions

Transform collections by iterating over their elements. Produces a new list or map.

Transform every bucket name to uppercase using a for expression.

upper_names = [for name in var.bucket_names : upper(name)]

Filter with an if clause:

Filter the instances list to include only production environment entries.

prod_instances = [for inst in var.instances : inst if inst.environment == "prod"]

Produce a map by separating key and value with =>:

Build a name-to-zone lookup map from a list of instance objects.

instance_map = { for inst in var.instances : inst.name => inst.zone }

Splat Expressions

Shorthand for extracting a single attribute from every element in a list:

Extract the instance_id attribute from every element of the workers resource list.

instance_ids = google_compute_instance.workers[*].instance_id

Equivalent to [for inst in google_compute_instance.workers : inst.instance_id], but more concise. Works only on lists, not maps — use a for expression for maps.

HCL Functions Reference

All functions are available in any HCL expression context — inside resource, variable, locals, output, and data blocks.

Interactive testing with terraform console

Run terraform console in any initialized Terraform directory to test expressions against live state and variables. Useful for debugging interpolation, type conversions, and complex for expressions before committing them to config files.

String Functions

Manipulate, search, and format string values. Commonly used for constructing resource names, parsing labels, and formatting output values.

FunctionSignatureExampleResult
formatformat(spec, args…)format("%-10s %d", "id", 42)"id 42"
joinjoin(sep, list)join(", ", ["a","b","c"])"a, b, c"
splitsplit(sep, str)split(",", "a,b,c")["a","b","c"]
replacereplace(str, search, replace)replace("hello world", "world", "HCL")"hello HCL"
trimspacetrimspace(str)trimspace(" hi ")"hi"
lowerlower(str)lower("Hello")"hello"
upperupper(str)upper("hello")"HELLO"
regexregex(pattern, str)regex("[0-9]+", "abc123")"123"
regexallregexall(pattern, str)regexall("[0-9]+", "a1b22")["1","22"]
substrsubstr(str, offset, length)substr("hello", 1, 3)"ell"
startswithstartswith(str, prefix)startswith("terraform", "terra")true
endswithendswith(str, suffix)endswith("main.tf", ".tf")true
titletitle(str)title("hello world")"Hello World"
indentindent(spaces, str)indent(2, "a\nb")"a\n b"
chompchomp(str)chomp("hello\n")"hello"
trimprefixtrimprefix(str, prefix)trimprefix("hello", "hel")"lo"
trimsuffixtrimsuffix(str, suffix)trimsuffix("hello", "lo")"hel"

Collection Functions

Work with lists, maps, sets, and tuples. Essential for for_each iteration, variable transformation, and merging configuration maps across modules.

FunctionSignatureExampleResult
lengthlength(collection)length(["a","b","c"])3
lookuplookup(map, key, default)lookup({a=1}, "b", 0)0
mergemerge(maps…)merge({a=1},{b=2}){a=1,b=2}
keyskeys(map)keys({a=1,b=2})["a","b"]
valuesvalues(map)values({a=1,b=2})[1,2]
flattenflatten(list)flatten([[1,2],[3]])[1,2,3]
distinctdistinct(list)distinct(["a","b","a"])["a","b"]
concatconcat(lists…)concat(["a"],["b","c"])["a","b","c"]
elementelement(list, index)element(["a","b","c"], 1)"b"
containscontains(list, value)contains(["a","b"], "a")true
zipmapzipmap(keys, values)zipmap(["a","b"],[1,2]){a=1,b=2}
tosettoset(list)toset(["a","b","a"]){"a","b"}
tolisttolist(set)tolist(toset(["b","a"]))["a","b"]
tomaptomap(object)tomap({a="x",b="y"}){a="x",b="y"}
indexindex(list, value)index(["a","b","c"],"b")1
sliceslice(list, start, end)slice(["a","b","c"],1,3)["b","c"]
reversereverse(list)reverse([1,2,3])[3,2,1]
sortsort(list)sort(["c","a","b"])["a","b","c"]
chunklistchunklist(list, size)chunklist([1,2,3,4],2)[[1,2],[3,4]]
transposetranspose(map_of_lists)transpose({a=["x","y"]}){x=["a"],y=["a"]}
matchkeysmatchkeys(vals, keys, search)see docsfiltered list
oneone(list)one(["a"])"a"
rangerange(start, limit, step)range(0, 4, 1)[0,1,2,3]
alltruealltrue(list)alltrue([true,true])true
anytrueanytrue(list)anytrue([false,true])true

Numeric Functions

Arithmetic, rounding, and base conversion. Used for calculating resource counts, CIDR math inputs, and parsing non-decimal strings.

FunctionSignatureExampleResult
minmin(numbers…)min(3,1,2)1
maxmax(numbers…)max(3,1,2)3
ceilceil(number)ceil(1.2)2
floorfloor(number)floor(1.9)1
absabs(number)abs(-5)5
signumsignum(number)signum(-3)-1
loglog(number, base)log(8, 2)3
powpow(base, exp)pow(2, 10)1024
parseintparseint(str, base)parseint("ff", 16)255

Date/Time Functions

Generate and compare timestamps. Commonly used for setting expiration dates on resources, computing rotation schedules, and tagging resources with creation time.

FunctionSignatureExampleResult
timestamptimestamp()timestamp()"2026-03-23T00:00:00Z"
formatdateformatdate(spec, timestamp)formatdate("YYYY-MM-DD", timestamp())"2026-03-23"
timeaddtimeadd(timestamp, duration)timeadd(timestamp(), "24h")tomorrow’s timestamp
timecmptimecmp(ts_a, ts_b)timecmp("2026-01-01T00:00:00Z","2025-01-01T00:00:00Z")1

Filesystem Functions

Read files, render templates, and check paths at plan time. templatefile is the primary way to inject variables into startup scripts, Cloud Init configs, and SQL migration files.

FunctionSignatureExampleResult
filefile(path)file("${path.module}/script.sh")file contents as string
filebase64filebase64(path)filebase64("cert.pem")base64-encoded file
templatefiletemplatefile(path, vars)templatefile("startup.sh.tpl", {project=var.project})rendered template string
filesetfileset(base, pattern)fileset("${path.module}/sql", "*.sql")set of matching filenames
fileexistsfileexists(path)fileexists("optional.tf")bool
pathexpandpathexpand("~/.kube/config")expanded path string

Encoding Functions

Serialize and deserialize between HCL objects and JSON, YAML, CSV, and base64 formats. jsonencode and yamldecode are the workhorses for passing structured data to GCP metadata fields and reading external config files.

FunctionSignatureExampleResult
jsonencodejsonencode(value)jsonencode({a=1})"{\"a\":1}"
jsondecodejsondecode(str)jsondecode("{\"a\":1}")object {a=1}
yamlencodeyamlencode(value)yamlencode({a=1,b="x"})YAML string
yamldecodeyamldecode(str)yamldecode(file("config.yaml"))HCL object
base64encodebase64encode(str)base64encode("hello")"aGVsbG8="
base64decodebase64decode(str)base64decode("aGVsbG8=")"hello"
base64gzipbase64gzip(str)base64gzip(file("big.txt"))gzip+base64
csvdecodecsvdecode(str)csvdecode(file("data.csv"))list of maps
textencodebase64textencodebase64(str, enc)textencodebase64("hi","UTF-16LE")base64 of re-encoded string
urlencodeurlencode(str)urlencode("hello world")"hello+world"

IP / CIDR Functions

Calculate subnet ranges, host addresses, and netmasks from CIDR notation. Used heavily in network.tf to carve a VPC CIDR into subnets without manual math.

FunctionSignatureExampleResult
cidrsubnetcidrsubnet(prefix, newbits, netnum)cidrsubnet("10.0.0.0/16", 8, 1)"10.0.1.0/24"
cidrhostcidrhost(prefix, hostnum)cidrhost("10.0.1.0/24", 5)"10.0.1.5"
cidrnetmaskcidrnetmask(prefix)cidrnetmask("10.0.0.0/16")"255.255.0.0"
cidrsubnetscidrsubnets(prefix, newbits…)cidrsubnets("10.0.0.0/8",8,8,8)list of 3 subnets
cidrcontainscidrcontains(cidr, ip)cidrcontains("10.0.0.0/8","10.1.2.3")true

Crypto / Hash Functions

Generate hashes, UUIDs, and checksums. filesha256 is commonly used to trigger redeployment when a source artifact (ZIP, JAR) changes. uuid generates a new value on every plan — use it only in random_id alternatives, not in resource arguments.

FunctionSignatureExampleResult
sha256sha256(str)sha256("hello")hex SHA-256 digest
sha512sha512(str)sha512("hello")hex SHA-512 digest
sha1sha1(str)sha1("hello")hex SHA-1 digest
md5md5(str)md5("hello")hex MD5 digest
uuiduuid()uuid()random UUID v4 string
uuidv5uuidv5(namespace, name)uuidv5("dns","example.com")deterministic UUID v5
bcryptbcrypt(str, cost?)bcrypt("pass",10)bcrypt hash
filesha256filesha256(path)filesha256("lambda.zip")SHA-256 of file
filemd5filemd5(path)filemd5("object.bin")MD5 of file

bcrypt produces a different hash on every terraform plan

Because bcrypt includes a random salt, Terraform sees the output as changed on every run, causing perpetual diffs. The hash also ends up stored in plaintext in the state file.

Use a random_password resource with the bcrypt function only in a local-exec provisioner, or hash outside Terraform and pass the value as a variable.

Type Conversion and Safety Functions

Convert between HCL types and handle nullable or error-prone expressions safely. try and can are especially useful when working with optional object attributes or data sources that may not exist.

FunctionSignatureExampleResult
trytry(exprs…)try(var.opt.field, "default")first non-erroring expression
cancan(expr)can(tonumber(var.x))true if expr succeeds
nonsensitivenonsensitive(value)nonsensitive(var.password)strips sensitive marking
sensitivesensitive(value)sensitive(local.token)marks value as sensitive
tostringtostring(value)tostring(42)"42"
tonumbertonumber(value)tonumber("3.14")3.14
tobooltobool(value)tobool("true")true
typetype(value)(console only)prints type of value

Meta-Arguments

Meta-arguments are built into the HCL language itself and available on every resource and data block regardless of provider. They instruct Terraform how to process a block — how many instances to create, what order to follow, or how to handle lifecycle events. Because meta-arguments affect plan computation, many of their values must be known at plan time and cannot depend on attributes that are computed during apply.

count

The count meta-argument creates multiple instances of a resource from a single block. Each instance is identified by its numeric index (count.index), starting at 0.

Create four identical compute instances, each tagged with its index number.

resource "google_compute_instance" "worker" {
  count        = 4
  name         = "worker-${count.index}"
  machine_type = "e2-medium"
  zone         = var.zone
}

Use count with a conditional to toggle a resource on or off:

Create the firewall rule only when admin_ip is provided.

resource "google_compute_firewall" "allow_admin" {
  count   = var.admin_ip != "" ? 1 : 0
  name    = "allow-admin-access"
  network = google_compute_network.main.id
  # ...
}

Index shifting destroys and recreates resources

Resources created with count are keyed by numeric index. If you remove an item from the middle of a list that drives count, all subsequent indexes shift — Terraform sees them as different resources and will destroy and recreate them. For stable identity, use for_each with a map or set instead.

Use count only for conditional creation or identical copies

Reserve count for two patterns: toggling a resource on/off (count = var.enabled ? 1 : 0) and creating N identical copies. For resources that differ by key (environments, regions, team names), for_each is safer because keys are stable regardless of ordering.

for_each

The for_each meta-argument creates one instance per element of a map or set. Each instance is keyed by the map key (or set element), not by a numeric index — keys remain stable when elements are added or removed.

Create one subnet per entry in the subnets map, keyed by subnet name.

resource "google_compute_subnetwork" "regional" {
  for_each      = var.subnets
  name          = each.key
  ip_cidr_range = each.value.cidr
  region        = each.value.region
  network       = google_compute_network.main.id
}

Inside the block, each.key is the current map key and each.value is the corresponding value. For sets, both each.key and each.value are the element itself.

Convert a list to a set for for_each

for_each requires a map or set, not a list. Convert with toset(): for_each = toset(var.zone_list). If you need both index and value, build a map first using a for expression: { for idx, z in var.zone_list : z => idx }.

depends_on

The depends_on meta-argument declares an explicit ordering dependency between resources when Terraform cannot infer one from attribute references. Terraform completes all actions on the dependency (including any read actions) before processing the dependent resource.

Ensure the IAM binding is fully applied before creating the Cloud Run service.

resource "google_cloud_run_v2_service" "dashboard" {
  depends_on = [google_project_iam_member.run_invoker]
  name       = "dashboard"
  location   = var.region
  # ...
}

Prefer implicit dependencies over depends_on

Whenever possible, express dependencies through attribute references (e.g., network = google_compute_network.main.id). Terraform automatically infers the ordering. Reserve depends_on for cases where a dependency exists due to side effects not captured in attributes — for example, an IAM binding that must propagate before a service can start.

lifecycle

The lifecycle block is a nested meta-argument that controls how Terraform manages resource changes. It accepts several arguments that override default plan behavior.

ArgumentPurposeExample
create_before_destroyCreate the replacement before destroying the original — reduces downtime for stateless resourcescreate_before_destroy = true
prevent_destroyReject any plan that would destroy the resource — safety net for stateful resources like databasesprevent_destroy = true
ignore_changesExclude specific attributes from drift detection — useful when an external process manages those attributesignore_changes = [labels, metadata]
replace_triggered_byForce replacement when a referenced resource changes — signals implicit dependencies that Terraform cannot detectreplace_triggered_by = [google_compute_disk.boot.id]
preconditionValidate inputs before creating or updating the resource (Terraform 1.2+)See example below
postconditionValidate resource attributes after creation (Terraform 1.2+)See example below

Prevent accidental destruction of the SQL Server VM and ignore externally managed labels.

resource "google_compute_instance" "sql" {
  name         = "data-pipeline-sql"
  machine_type = "n2-standard-4"
  zone         = var.zone
 
  lifecycle {
    prevent_destroy = true
    ignore_changes  = [labels, metadata["startup-script"]]
  }
}

prevent_destroy does not protect against block removal

If you remove the entire resource block from the configuration, Terraform no longer sees the prevent_destroy argument and will plan a destroy. The protection only works while the resource block exists in the configuration.

Combine prevent_destroy with deletion_protection

For GCP resources that support it (Cloud SQL, BigQuery, Compute Engine), set both prevent_destroy = true in the lifecycle block and deletion_protection = true in the resource arguments. The lifecycle argument catches Terraform-initiated destroys; the GCP argument catches API-level deletes from any source.

Preconditions and postconditions (Terraform 1.2+)

Preconditions validate assumptions before Terraform creates or updates a resource. Postconditions validate the result after the resource is created. Both use the condition + error_message pattern and live inside the lifecycle block.

Validate that the selected region is in Europe before creating the resource.

resource "google_compute_network" "main" {
  name = "data-pipeline-vpc"
 
  lifecycle {
    precondition {
      condition     = startswith(var.region, "europe-")
      error_message = "This project must deploy to a European region for data residency compliance."
    }
  }
}

replace_triggered_by (Terraform 1.2+)

The replace_triggered_by lifecycle argument forces Terraform to replace a resource whenever one or more referenced managed resources change. This is useful when a resource depends on another resource’s identity in a way that requires full replacement rather than in-place update.

Replace the Cloud Run service whenever the Docker image digest changes.

resource "terraform_data" "image_tag" {
  input = var.image_digest
}
 
resource "google_cloud_run_v2_service" "dashboard" {
  name     = "dashboard"
  location = var.region
 
  lifecycle {
    replace_triggered_by = [terraform_data.image_tag]
  }
  # ...
}

Use terraform_data as a change signal

The terraform_data resource (replacement for the deprecated null_resource) stores an arbitrary value in state. When that value changes, any resource with replace_triggered_by pointing to it will be replaced. This pattern decouples the trigger from the resource’s own arguments.

Dynamic Blocks

Dynamic blocks generate repeated nested blocks programmatically, acting like a for expression that produces block structures instead of values. They are supported inside resource, data, provider, and provisioner blocks.

A dynamic block has four components:

ComponentRequiredPurpose
labelYesThe type of nested block to generate (e.g., ingress, env, setting)
for_eachYesThe collection to iterate over — must be a map or set
contentYesThe body of each generated block — references the iterator to access current element values
iteratorNoCustom name for the iteration variable. Defaults to the block label if omitted

Dynamically generate one env block per entry in the env_vars map.

resource "google_cloud_run_v2_service" "dashboard" {
  name     = "dashboard"
  location = var.region
 
  template {
    containers {
      image = "${local.registry}/dashboard:latest"
 
      dynamic "env" {
        for_each = var.env_vars
        content {
          name  = env.key
          value = env.value
        }
      }
    }
  }
}

Inside the content block, env.key and env.value refer to the current map entry (where env is the default iterator name matching the block label). To use a custom iterator name, set iterator = custom_name and reference custom_name.key / custom_name.value.

Nested Dynamic Blocks

Some resource types require multiple levels of nested blocks. You can nest dynamic blocks inside the content of other dynamic blocks to generate these structures.

Generate origin_group blocks, each containing a dynamic set of origin entries.

dynamic "origin_group" {
  for_each = var.origin_groups
  content {
    name = origin_group.value.name
 
    dynamic "origin" {
      for_each = origin_group.value.origins
      content {
        hostname = origin.value.hostname
        weight   = origin.value.weight
      }
    }
  }
}

Deep nesting reduces readability

Dynamic blocks beyond two levels of nesting become difficult to read and maintain. If you find yourself nesting three or more levels, consider restructuring the data or extracting the inner logic into a local variable.

Flatten complex structures into locals first

Use flatten() and for expressions in a locals block to pre-compute the nested structure as a flat map, then iterate over it with a single dynamic block. This moves complexity out of the resource block and into a testable expression.

File Organization Reference

Standard file layout for a Terraform project targeting GCP. File names are a convention — Terraform merges all .tf files in a directory regardless of name — but consistent naming helps teams navigate projects quickly.

FilePurpose
main.tfProvider config, backend block
variables.tfInput variable declarations
outputs.tfOutput value declarations
locals.tfLocal value definitions
versions.tfterraform {} block with required_version and required_providers
network.tfVPC, subnets, firewall rules, Cloud NAT
compute.tfCompute Engine VMs, instance templates, managed groups
iam.tfService accounts, IAM bindings and members
run.tfCloud Run V2 services and jobs
storage.tfGCS buckets, lifecycle rules
data.tfdata {} blocks — lookups for existing resources
import.tfimport {} blocks (TF 1.5+)
*.tfvarsVariable value files (do not commit secrets)
*.tfvars.jsonJSON format variable value files
override.tfLocal overrides (do not commit — add to .gitignore)
.terraform.lock.hclProvider lock file — always commit to version control
.terraform/Local cache — add to .gitignore

Never commit *.tfvars files containing secrets (database passwords, API keys, service account keys)

These files are often the source of credential leaks in version control. Even private repos are not safe — credentials in git history persist after deletion.

Store secrets outside version control

Use environment variables (TF_VAR_*), a secrets manager (GCP Secret Manager, HashiCorp Vault), or an encrypted backend. Add *.tfvars to .gitignore and use *.tfvars.example files with placeholder values.

Always commit .terraform.lock.hcl

This file pins the exact provider versions and hashes used by your project. Without it, terraform init may download a different provider version on another machine, causing inconsistent behavior. Treat it like a package-lock.json.

Run terraform fmt before every commit

terraform fmt rewrites .tf files to the canonical HCL style (2-space indent, aligned = signs, sorted arguments). Enforcing it in CI prevents style drift across team members.

HCL Syntax Basics References