HCL Syntax and Configuration Files

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Every Terraform configuration is a set of text files written in the Terraform language, whose native syntax is HCL (the HashiCorp Configuration Language). Before looking at resources, variables or modules, it pays to know exactly what those files may contain, which files Terraform reads and in which order, and which conventions every team ends up adopting. This page is the reference for all three. It describes the language as documented for Terraform 1.17 and OpenTofu 1.13, and it is the second step of the reading path after Getting Started: the following pages build on it, starting with Providers, Resources and Data Sources and Variables, Outputs and Locals. For the motivation behind infrastructure as code see Introduction and Infrastructure as Code.

The shape of a configuration

The language has only two syntactic constructs, arguments and blocks. Everything else — expressions, functions, types — appears on the right-hand side of an argument and is covered in Types and Expressions and Functions.

Construct Meaning

Argument

Assigns a value to a name inside a block: image_id = "abc123". The identifier before the equals sign is the argument name and the expression after it is the value. The HCL specification calls the same thing an attribute; Terraform’s documentation says "argument" when it means something you write and "attribute" when it means something you read from a resource.

Block

A container for other content. A block has a type, zero or more labels and a body enclosed in braces. The body holds arguments and further (nested) blocks.

Top-level block

A block that may appear directly in a file rather than inside another block. Terraform recognizes a limited set of top-level block types (see Top-level block types).

Nested block

A block inside the body of another block, such as lifecycle inside a resource. Some nested blocks are defined by Terraform itself, others by a provider’s schema.

# A top-level block: type "resource", two labels, a body.
resource "docker_container" "catalog_api" {
  name  = "catalog-api"                      # argument
  image = docker_image.catalog_api.image_id  # argument with a reference expression

  # A nested block with no labels.
  ports {
    internal = 8080
    external = 18080
  }

  # Another nested block, defined by Terraform itself.
  lifecycle {
    create_before_destroy = true
  }
}

Blocks and labels

The number and meaning of labels depends on the block type. A resource block takes two labels, the provider-defined resource type (docker_container) and a local name (catalog_api) that is unique within the module for that type. A variable block takes one label, the variable name. A terraform or locals block takes none. Each combination of block type and labels identifies one object, so declaring the same resource type and name twice is an error. Labels are written as quoted strings, and the style guide recommends wrapping both the type and the name in double quotes.

Because the labels together form an address, Terraform and its tools talk about objects by address: docker_container.catalog_api, var.environment, module.network, data.docker_network.default. The address of an object is the contract between files, which is why splitting blocks across files never changes behavior.

Arguments and attributes

A single-line argument is name = expression, and each argument sits on its own line. Terraform does not use commas between arguments. Separating arguments with a newline is mandatory inside blocks; the single-line block form (variable "x" { type = string }) accepts exactly one argument. Which arguments are required, optional or computed is defined by the schema of the block, which for resource and data blocks comes from the provider (Providers).

Identifiers

Identifiers name arguments, block types and Terraform constructs such as resources, input variables, locals and modules. Valid characters are letters, digits, underscores and hyphens, and the first character must not be a digit. Terraform follows the Unicode identifier rules (UAX #31), extended to also accept the ASCII hyphen, so non-ASCII letters are legal. In practice teams use lowercase words separated by underscores, because hyphens force bracket notation in some contexts and underscores are what the style guide recommends.

Comments

Syntax Behavior

#

Starts a single-line comment that ends at the end of the line. This is the default and recommended style.

//

Also a single-line comment. Formatting tools such as terraform fmt may convert it to #.

/* ... */

Delimits a comment that can span several lines. It cannot be nested.

# Single-line comment (the style the official guide recommends).
// Also valid, but rewritten to # by some formatters.

/*
  Multi-line comment. The style guide prefers repeated # lines
  and treats this form as supported only for backward compatibility.
*/
variable "environment" {
  type        = string # trailing comments are fine
  description = "Deployment environment of the Bookshelf stack."
}

Encoding and line endings

Configuration files must be encoded as UTF-8. Non-ASCII characters are allowed in identifiers, comments and string values, but the language delimiters themselves are ASCII. Both LF and CRLF line endings are accepted; LF is the idiom and formatting tools may convert CRLF to LF, which matters for diffs on mixed Windows and Unix teams.

The JSON syntax

Why a language of its own rather than plain JSON or YAML? The HCL project draws the line between data serialization formats and configuration languages: JSON and YAML serialize data, while HCL sits between a data format and a general-purpose programming language. It stays easy for people to read and write, adds the expressions and blocks that declarative configuration needs, and, because the application declares which arguments and blocks it expects, it can report clearer errors. For interoperability HCL also defines a JSON form of the same model, which is what Terraform’s JSON syntax is.

Every construct of the native syntax also has a JSON representation, for configurations that are generated by programs rather than written by people. Files in this syntax end in .tf.json (and, for OpenTofu only, .tofu.json). Terraform loads them together with the .tf files of the same directory, and the two syntaxes can be mixed freely in one module. The JSON form is not a data format for configuration values — for that use .tfvars.json, covered below — but a different spelling of the same language. The native syntax stays the right choice for anything a human edits; JSON is for generators, for example the output of cdktf synth in the days before CDK for Terraform was deprecated and archived (2025-12-10), or a small script that emits a block per entry of an inventory.

The mapping rules are short.

  • The root of the file is a JSON object whose property names are the top-level block types: resource, variable, output, locals, module, provider, terraform and so on.

  • Each label of a block becomes one more level of nested object. The innermost object is the block body.

  • Property names inside a body are argument names.

  • Booleans, numbers and null are literals. Objects and arrays map recursively.

  • Strings are parsed as templates, so "${var.environment}" is an interpolation. A string consisting of exactly one interpolation sequence returns the referenced value itself, not its string form.

  • A nested block without labels is a plain object. When there are several blocks of the same type, or when order matters, use an array of objects. A single object keyed by unique labels is a shorthand that does not preserve order.

  • A property named // inside a block body (or at the root) is a comment and is ignored. It is not ignored inside an object that is evaluated as an expression, where it would be an ordinary key.

# main.tf -- native syntax
variable "environment" {
  type        = string
  description = "Deployment environment."
  default     = "dev"
}

resource "docker_image" "redis" {
  name = "redis:7"
}

output "redis_image" {
  value = docker_image.redis.name
}
{
  "//": "main.tf.json -- the same configuration in JSON syntax",
  "variable": {
    "environment": {
      "type": "string",
      "description": "Deployment environment.",
      "default": "dev"
    }
  },
  "resource": {
    "docker_image": {
      "redis": {
        "name": "redis:7"
      }
    }
  },
  "output": {
    "redis_image": {
      "value": "${docker_image.redis.name}"
    }
  }
}

A few block types have special rules in JSON. In resource and data blocks provider is a plain string such as aws.alias, depends_on is an array of reference strings, and in lifecycle the ignore_changes value is either the string all or an array of attribute names. In variable, the type is a string such as list(string) and the default is a literal value that is not templated. In module, source and version are literal strings and providers maps child provider addresses to parent addresses as strings. Several configurations of one provider are an array under the provider name. In the terraform block all values are literal, and backend uses a compact form with one key naming the backend type.

{
  "provider": {
    "docker": [
      { "host": "unix:///var/run/docker.sock" },
      { "alias": "remote", "host": "ssh://deploy@build.example.com:22" }
    ]
  },
  "terraform": {
    "required_version": ">= 1.9.0",
    "backend": { "local": { "path": "terraform.tfstate" } }
  }
}

The same idea applies to other file types: variable definition files have a JSON form (.tfvars.json), and test files have one (.tftest.json). JSON is also how programs read Terraform’s output: the machine-readable UI, JSON plans and JSON state are described in the integrations part of this section and are a separate topic from the configuration syntax described here.

File types and how they are loaded

A directory of Terraform files is a module. The directory where you run Terraform is the root module, and calls to other directories or registry sources are child modules (see Modules). Terraform evaluates all configuration files of a module as one single document: splitting blocks across files has no effect on behavior, and there is no per-file load order for ordinary configuration files. Only the files directly in the directory belong to the module; sub-directories are separate modules and are not loaded unless called.

File Purpose Loaded when

.tf

Configuration in the native syntax: resources, variables, outputs, providers and the rest.

Always, with all other .tf and .tf.json files of the module directory.

.tf.json

The same configuration in the JSON syntax.

Always, together with the .tf files.

terraform.tfvars, terraform.tfvars.json

Values for the root module’s input variables.

Automatically, if present in the working directory.

*.auto.tfvars, *.auto.tfvars.json

More variable values, one file per concern.

Automatically, in lexical order.

any other .tfvars file

Variable values for a specific environment, such as prod.tfvars.

Only with -var-file.

override.tf, *_override.tf (and .tf.json)

Surgical modifications of other blocks without editing them.

After all normal files, in lexical order.

.terraform.lock.hcl

Provider versions and checksums selected by terraform init.

Read by init and by every command that needs the providers.

.tftest.hcl, .tftest.json

Test files for terraform test.

Only by terraform test.

.tfquery.hcl

Query files with list blocks for terraform query.

Only by terraform query.

*.tfcomponent.hcl, *.tfdeploy.hcl

Stacks: component and deployment configuration.

Only by HCP Terraform Stacks (a Stack is not a normal module).

.tofu, .tofu.json

OpenTofu-specific variants of .tf and .tf.json.

Only by OpenTofu, which prefers them over a same-named .tf file.

Variable definition files

.tfvars files are written in HCL syntax but contain nothing except assignments of values to variable names. The JSON form, .tfvars.json, is a single JSON object whose root keys are variable names. Terraform loads a definition file automatically when its name is terraform.tfvars, terraform.tfvars.json, or ends in .auto.tfvars or .auto.tfvars.json. All other files are passed explicitly with -var-file.

# terraform.tfvars -- loaded automatically
environment = "dev"

catalog_api = {
  image    = "bookshelf/catalog-api:1.4.0"
  replicas = 2
}
# prod.tfvars -- loaded only with: terraform apply -var-file=prod.tfvars
environment = "prod"

catalog_api = {
  image    = "bookshelf/catalog-api:1.4.0"
  replicas = 6
}

When several sources assign the same variable, the later in the following list wins; the first entry has the highest precedence.

Priority Source

Highest

-var and -var-file options on the command line, in the order given, plus variables set in HCP Terraform.

↓

*.auto.tfvars and *.auto.tfvars.json files, in lexical order.

↓

terraform.tfvars.json.

↓

terraform.tfvars.

↓

Environment variables with the TF_VAR_ prefix.

Lowest

The default argument of the variable block.

The precedence rule is the reason secrets belong in TF_VAR_ environment variables or a secret store and not in a committed .tfvars file; see Variables, Outputs and Locals for validation, sensitivity and ephemeral variables.

Override files

An override file is a .tf or .tf.json file named exactly override.tf or override.tf.json, or ending in _override.tf or _override.tf.json. Terraform skips override files while loading the normal configuration and then processes them in lexicographical order by file name; within a file, blocks are processed by position, and later overrides win. Overrides exist for the rare case where the original files cannot or should not be edited — a generated file, or a local developer tweak that is not committed — and the style guide asks teams to use override.tf sparingly and to document its use in the original definitions.

An override block matches an original block with the same header (block type plus all labels). The merge rules are:

  • An argument in the override replaces the argument with the same name in the original.

  • Nested blocks in the override replace all nested blocks of the same type in the original. Their contents are not merged, and block types the override does not mention stay unchanged.

  • The merged result must still be valid for that block type.

  • In resource and data blocks, lifecycle is merged argument by argument, so an override that only sets create_before_destroy keeps the original ignore_changes. If the override has provisioner blocks, the original’s provisioners are ignored, and a connection block replaces the original’s completely.

  • depends_on is not allowed in override blocks of any type and is an error.

  • In variable blocks, changing type converts the original default to the new type (an error if conversion fails), and a new default must be compatible with the type.

  • In locals blocks, overrides apply per named value, whichever locals block defined it.

  • In the terraform block, settings are merged individually. required_providers merges element by element, while a new required_version or per-provider constraint replaces the original’s constraint entirely. A cloud or backend block in an override takes precedence over the original’s backend configuration, in either direction.

# main.tf
resource "docker_container" "web" {
  name  = "web"
  image = docker_image.web.image_id

  lifecycle {
    ignore_changes        = [env]
    create_before_destroy = false
  }
}
# web_override.tf -- local tweak, not committed
resource "docker_container" "web" {
  image = "bookshelf/web:debug"

  lifecycle {
    create_before_destroy = true  # ignore_changes from main.tf is kept
  }
}

The dependency lock file

.terraform.lock.hcl records which provider versions terraform init selected and their checksums (h1: and zh: hashes), so later runs make the same choices. It uses HCL syntax but is not a configuration file; it tracks providers only, not modules, and lives in the root working directory next to the .terraform cache directory. The file is generated, so you never write it by hand, but you do commit it so that dependency changes go through code review. terraform init -upgrade ignores the existing selections and picks the newest versions matching the constraints, and terraform providers lock -platform=... pre-records checksums for several operating systems and architectures, which matters when a team mixes macOS laptops and Linux CI runners. The full treatment is in Providers.

Test files

Test files use the .tftest.hcl or .tftest.json extension, which is how Terraform discovers them. They are read only by terraform test, and their root-level blocks are test (optional, at most one, for settings such as parallel), run (one or more; each executes apply, the default, or plan, and may contain variables, module, providers, assert and expect_failures), variables (optional, at most one) and provider (zero or more). The test framework needs Terraform 1.6 or later, and provider mocking arrived in 1.7. Test files do not participate in the module’s configuration and cannot be referenced from it; see Testing.

# tests/storage.tftest.hcl
variables {
  environment = "test"
}

run "covers_bucket_name_has_environment" {
  command = plan

  assert {
    condition     = output.covers_bucket_name == "bookshelf-covers-test"
    error_message = "The covers bucket name must include the environment."
  }
}

Query files

.tfquery.hcl files are inputs to the terraform query command: they hold parameters that search for resources in existing infrastructure so that you can import them into state and manage them as code. A query file may contain list, provider, variable and locals blocks, and Terraform loads every query file in the root configuration directory. The list block takes a resource type and a label, the required provider meta-argument, optional count or for_each (mutually exclusive), include_resource (return full objects instead of only identities), limit (default 100) and a provider-specific config block. List resources and terraform query arrived in Terraform 1.14; the workflow is explained in Import and Query.

# discover.tfquery.hcl
list "docker_container" "bookshelf" {
  provider = docker

  include_resource = true
  limit            = 50
}

Stacks files

Terraform Stacks (HCP Terraform) split infrastructure definition from deployment into two file types, both placed at the root of the Stack’s repository:

  • .tfcomponent.hcl files define the Stack’s components and their input values and replace the traditional root module. Their blocks are component, required_providers, provider, removed, variable, output and locals, and a Stack needs at least one component.

  • .tfdeploy.hcl files define how many times the Stack is deployed, with which inputs, and how the deployment runs are orchestrated. Their blocks are deployment, deployment_group, deployment_auto_approve, identity_token, store, publish_output, upstream_input and locals, and a Stack needs at least one deployment.

The suffix determines the file type and the file name before it is free, so a Stack is usually split into files such as components.tfcomponent.hcl, providers.tfcomponent.hcl and deployments.tfdeploy.hcl. HCP Terraform processes every matching file in the root directory. Stacks are not ordinary modules and the CLI’s init, plan and apply do not read these files; they are the subject of Terraform Stacks. A third suffix, .tfstacks.hcl, is not described on the official Stack file reference, so this page does not rely on it.

OpenTofu and .tofu files

OpenTofu stores code in plain text files with the .tf or .tofu extension, and its JSON variant uses .tf.json or .tofu.json. The practical consequence is a precedence rule: when foo.tf and foo.tofu exist in the same directory, OpenTofu loads only the .tofu file and ignores the .tf file, while Terraform does the opposite — it knows nothing about .tofu and reads only the .tf file. A module author can therefore ship foo.tf for Terraform users and foo.tofu for OpenTofu users with OpenTofu-only features (state encryption, early variable evaluation, provider for_each and so on), without the two tools tripping over each other. More on the fork and the other differences is in OpenTofu.

$ ls
main.tf  main.tofu  variables.tf

$ tofu plan     # reads main.tofu and variables.tf; main.tf is ignored
$ terraform plan # reads main.tf and variables.tf; main.tofu is ignored

The loading pipeline

The figure shows which files contribute to a run. Normal configuration files are merged first, override files are applied on top, variable values are collected from the sources in the precedence order above, and the lock file constrains which provider builds are installed. Test, query and Stacks files are not part of this pipeline at all: each is read by its own command.

flowchart TD subgraph Module["Module directory"] TF[".tf and .tf.json files\n(OpenTofu: .tofu files win)"] OV["override.tf and *_override.tf\napplied in lexical order"] end TF --> MERGE["One merged configuration"] OV --> MERGE VARS["Variable values\n-var / -var-file, *.auto.tfvars,\nterraform.tfvars, TF_VAR_, defaults"] --> EVAL["Evaluate and plan"] MERGE --> EVAL LOCK[".terraform.lock.hcl\nproviders and checksums"] --> EVAL TESTS[".tftest.hcl"] -.->|terraform test| EVAL QUERY[".tfquery.hcl"] -.->|terraform query| EVAL STACKS[".tfcomponent.hcl / .tfdeploy.hcl"] -.->|HCP Terraform Stacks| EVAL

The terraform block

The terraform block configures Terraform itself: which CLI version may run the code, which providers it needs, where state is stored, whether the workspace lives in HCP Terraform, and which experimental language features are enabled.

terraform {
  required_version = ">= 1.9.0, < 2.0.0"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.6"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
  }

  backend "local" {
    path = "terraform.tfstate"
  }
}

Two constraints apply to every setting in this block. Only constant values are allowed: arguments cannot refer to named objects such as resources or input variables, and built-in functions cannot be used. That restriction exists because Terraform needs these settings before it has evaluated anything. (OpenTofu relaxes this for some settings through early variable evaluation, a difference covered in OpenTofu.)

Setting Meaning

required_version

A version constraint string for the Terraform CLI. Terraform exits with an error if its own version does not satisfy it; constraints from child modules also apply. It does not govern provider versions.

required_providers

A map from each provider’s local name to an object with source (the global address, such as kreuzwerker/docker) and version (a constraint; if omitted, the newest installed version). Providers and version constraint syntax are explained in Providers.

backend "<TYPE>"

Where state is stored and how operations run; the default is local. It cannot be combined with cloud. See Backends and Remote State.

cloud

Connects the configuration to HCP Terraform or Terraform Enterprise. Only one cloud block is allowed per configuration, it cannot be combined with backend, and it cannot reference input variables, locals or data source attributes.

experiments

A list of experimental language feature names to opt into. Experiments can change or be removed without notice, emit a warning on every plan or apply, and are not recommended for production.

provider_meta "<LABEL>"

Metadata fields that a provider expects from the module using it. Modules populate them independently of provider configuration; very few providers use them.

The cloud block

Inside cloud, organization and workspaces are required for HCP Terraform. The workspaces block selects either a single workspace with name or all workspaces carrying a set of tags (a list or map), and the two are mutually exclusive; project selects the HCP Terraform project. hostname defaults to app.terraform.io and is how a Terraform Enterprise instance is addressed. The token argument exists but the documentation advises against writing credentials in configuration: use terraform login or the CLI configuration file instead. Key-only tags need Terraform Enterprise v202411-1 or later, or Terraform 1.10 or later.

Environment variables can supply the same values: TF_CLOUD_ORGANIZATION, TF_CLOUD_HOSTNAME, TF_CLOUD_PROJECT and TF_WORKSPACE. They apply only when the matching attribute is absent from the configuration (the configuration wins if both are set), and to configure cloud entirely through environment variables an empty cloud block must still be present. TF_WORKSPACE cannot create a workspace; it must already exist, and when tags is used it must be one of the tagged workspaces.

terraform {
  required_version = ">= 1.9.0"

  cloud {
    organization = "example-bookshelf"

    workspaces {
      tags = ["bookshelf", "app"]
    }
  }
}

Source: Terraform Docs — terraform block reference. See HCP Terraform and Enterprise for workspaces, runs and projects.

Experiments and OpenTofu differences

The experiments setting is intended for features still being designed in Terraform’s own release cycle. Because of the warning on every run, it is something to enable on a branch for evaluation, never in the baseline of a module that others consume. OpenTofu keeps the terraform block and adds an encryption block inside it for state and plan encryption; see OpenTofu.

Top-level block types

Terraform recognizes a limited set of block types at the top level of a file. Each has a dedicated page in this section; the table lists them with a pointer to where they are covered. Block types that exist in the language but are only valid in one special file type are listed separately.

Block type Purpose Covered in

terraform

Settings for Terraform itself: version constraint, providers, backend, cloud, experiments.

This page

provider

Configures a provider; alias allows several configurations.

Providers

resource

Declares an infrastructure object that Terraform manages.

Resources and Data Sources

data

Reads information about existing objects.

Resources and Data Sources

ephemeral

An ephemeral resource that exists only while one operation runs and is never stored in state or plan (Terraform 1.10).

Resources and Data Sources

variable

An input variable of the module.

Variables, Outputs and Locals

output

A value exposed by the module.

Variables, Outputs and Locals

locals

Named expressions reused inside the module.

Variables, Outputs and Locals

module

Calls another module.

Modules

moved

Records that an object changed address, so state is renamed instead of destroyed and recreated.

Refactoring: moved and removed

removed

Removes an object from state, with lifecycle { destroy = false } to keep the real object.

Refactoring: moved and removed

import

Brings an existing object under management; takes to and either id or identity, plus provider and for_each.

Import and Query

check

Assertions evaluated at the end of plan or apply that produce warnings rather than errors, optionally with a scoped data block.

Lifecycle, Validation and Checks

action

Declares a provider-defined action, invoked from the CLI or from a resource’s action_trigger; referenced as action.<TYPE>.<LABEL>, with config, count, for_each and provider arguments (Terraform 1.14).

Lifecycle, Validation and Checks

Some block types are valid only in a specific kind of file:

File Blocks

.tfquery.hcl

list, provider, variable, locals

.tftest.hcl

test, run, variables, provider, and mocks (mock_provider, override_resource and friends, introduced with mocking in 1.7)

.tfcomponent.hcl

component, required_providers, provider, removed, variable, output, locals

.tfdeploy.hcl

deployment, deployment_group, deployment_auto_approve, identity_token, store, publish_output, upstream_input, locals

The language also has nested blocks defined by Terraform itself and valid only inside a parent: lifecycle, provisioner, connection, precondition and postcondition, validation (inside variable), required_providers and backend (inside terraform), and dynamic for generating nested blocks. They are introduced where their parent is, in Meta-Arguments and Lifecycle, Validation and Checks.

Placing the same block type in another file is harmless in the module sense, but a block that is valid only in one file type is rejected elsewhere — a run block in main.tf, or a list block in a normal .tf file, is an error, because list is only allowed in .tfquery.hcl files.

A complete small module

The following Bookshelf fragment uses the conventions described in the style guide below. It runs locally with the Docker provider and needs no cloud credentials.

# terraform.tf
terraform {
  required_version = ">= 1.9.0"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.6"
    }
  }
}
# variables.tf
variable "catalog_api_image" {
  type        = string
  description = "Container image of the catalog API."
  default     = "bookshelf/catalog-api:1.4.0"
}

variable "environment" {
  type        = string
  description = "Deployment environment."
  default     = "dev"

  validation {
    condition     = contains(["dev", "test", "prod"], var.environment)
    error_message = "The environment must be dev, test or prod."
  }
}
# main.tf
resource "docker_network" "bookshelf" {
  name = "bookshelf-${var.environment}"
}

resource "docker_image" "catalog_api" {
  name = var.catalog_api_image
}

resource "docker_container" "catalog_api" {
  name  = "catalog-api-${var.environment}"
  image = docker_image.catalog_api.image_id

  networks_advanced {
    name = docker_network.bookshelf.name
  }

  ports {
    internal = 8080
    external = 18080
  }

  lifecycle {
    create_before_destroy = true
  }
}
# outputs.tf
output "catalog_api_container_name" {
  description = "Name of the catalog API container."
  value       = docker_container.catalog_api.name
}

The style guide

HashiCorp publishes a style guide for the Terraform language. Parts of it are enforced by the formatter and the rest are conventions to adopt, ideally with a linter such as TFLint (see Code Quality and Security Scanning).

Formatting

  • Indent two spaces per nesting level.

  • Align the equals signs of consecutive single-line arguments.

  • Inside a block, put arguments first, then a blank line, then nested blocks.

  • Put meta-arguments (count, for_each, provider and so on) first, separated from the rest by a blank line, and meta-argument blocks such as lifecycle last.

  • Separate top-level blocks with one blank line. Nested blocks get blank lines too, except when grouping related blocks of the same type, such as several provisioner blocks.

  • Use # for all comments, including multi-line ones, and avoid // and /* */.

resource "docker_container" "reviews_api" {
  count = var.environment == "prod" ? 2 : 1

  name  = "reviews-api-${count.index}"
  image = docker_image.reviews_api.image_id

  ports {
    internal = 8080
  }

  lifecycle {
    create_before_destroy = true
  }
}

Naming and ordering

  • Name objects with descriptive nouns separated by underscores, and do not repeat the resource type in the name (docker_container.catalog_api, not docker_container.catalog_api_container).

  • Define data sources and dependencies before the resources that reference them. Terraform builds its graph from references, so ordering is for readers only.

  • The suggested order of arguments inside a resource is: count or for_each; other arguments; nested blocks; lifecycle; depends_on.

  • Give every variable a type and a description and every output a description. Order variable parameters as type, description, default, sensitive, then validation; order outputs as description, value, sensitive.

  • Use variables, locals, count and for_each sparingly, and add a comment when a meta-argument’s effect is not obvious. Use count for nearly identical instances (including a ternary for conditional creation, such as var.enabled ? 1 : 0) and for_each when instances need distinct values.

  • Always include a default (alias-free) provider configuration, define all providers together in one file with the default first, and put alias first in aliased providers.

File layout

File Content

terraform.tf

A single terraform block with required_version and required_providers.

backend.tf

Backend configuration.

providers.tf

All provider blocks, the default first.

main.tf

Resources and data sources.

variables.tf

variable blocks, alphabetical.

outputs.tf

output blocks, alphabetical.

locals.tf

Local values used across files (file-specific locals go at the top of that file).

override.tf

Overrides, used sparingly and documented in the original definitions.

As a module grows, split main.tf by concern — network.tf, storage.tf, compute.tf — so that a maintainer can find any resource immediately.

Version control and pinning

Do not commit state files and their backups, the state lock info file, the .terraform directory, saved plan files or .tfvars files containing sensitive data. Do commit all code, .terraform.lock.hcl, a .gitignore and a README.md describing inputs and outputs. Pin provider versions in required_providers, set required_version to a minimum Terraform version, and pin registry modules with the version argument to a specific major and minor version (Terraform ignores version for local modules). Registry module repositories are named terraform-<PROVIDER>-<NAME>.

$ cat .gitignore
.terraform/
*.tfstate
*.tfstate.*
*.tfplan
crash.log
prod.secret.tfvars

terraform fmt

terraform fmt rewrites configuration files to the canonical format. It applies a subset of the style conventions plus minor readability adjustments, has no customization options ("intentionally opinionated"), and may change its rules slightly between Terraform versions, which is why CI should pin the Terraform version. It minimizes changes to configurations that already follow the documented style, and files generated by Terraform already conform.

Flag Behavior

-list=false

Stops printing the names of files with formatting inconsistencies.

-write=false

Does not overwrite files. Implied by -check and when reading from standard input.

-diff

Shows the formatting changes as diffs.

-check

Checks whether the input is formatted. Exits 0 if so, otherwise exits non-zero and lists the improperly formatted files. Implies -write=false.

-recursive

Also processes sub-directories. By default only the given or current directory is processed.

-no-color

Removes color from the output.

The target is a directory (the current one by default), a single file, or - to read from standard input and write the formatted result to standard output.

$ terraform fmt                      # rewrite files in the current directory
main.tf

$ terraform fmt -recursive           # include sub-directories such as modules/
modules/network/main.tf

$ terraform fmt -diff -write=false   # preview the changes only
$ terraform fmt -check -recursive    # CI gate: non-zero exit if anything is unformatted
$ terraform fmt -list=false          # rewrite quietly
$ cat main.tf | terraform fmt -      # format standard input

terraform fmt only normalizes whitespace and layout; it does not check semantics. The companion command, terraform validate, checks that the configuration is syntactically valid and internally consistent without accessing remote services, and it is run after init. A common pre-commit and pipeline sequence is terraform fmt -check -recursive, then terraform init -backend=false, then terraform validate; see CLI Essentials. OpenTofu has the identical tofu fmt and tofu validate.

What changed recently

  • Terraform 1.6 introduced the .tftest.hcl testing framework and 1.7 added mocking.

  • Terraform 1.10 introduced ephemeral resources (the ephemeral block) and write-only arguments.

  • Terraform 1.14 introduced list resources, .tfquery.hcl files with terraform query, and the action block.

  • CDK for Terraform, the best-known producer of .tf.json, was deprecated and archived on 2025-12-10; the migration path is cdktf synth --hcl, which turns the generated JSON into native HCL.

  • Older tutorials show required_providers entries without source addresses (the pre-0.13 style) and // or slash-star comments; they still parse, but current guidance is an explicit source and # comments.

  • The action, list and ephemeral blocks and the Stacks files postdate books written for Terraform 1.9 and OpenTofu 1.8, so those books do not list them; the official block reference is the authority for the list above.