Terraform HCL Syntax and Configuration Files

Tested on Ubuntu 26.04 LTS (Resolute Raccoon)
Package terraform 1.15.8-1
Applies to Any host with Terraform installed
Lab environment Single Ubuntu VM with Terraform — Terraform lab environment on Ubuntu
Privilege Normal user; sudo only if Terraform is not installed yet
Scope Terraform configuration language basics — blocks, labels, arguments, expressions, comments, primitive and collection syntax, major block types, multi-file layout, terraform fmt and validate, and common syntax errors. Does not cover variables in depth, provider version locking, modules, or remote state.
Related guides Terraform lab environment on Ubuntu
Install Terraform on Ubuntu
apt command
check Ubuntu version

Terraform configurations are written in HashiCorp Configuration Language (HCL) and saved as .tf files (or .tf.json when JSON is required). Searchers often say Terraform HCL syntax; this guide uses that term while focusing on how Terraform configuration is structured, not on HCL's implementation history.

Here is a complete minimal example you will break down and run later:

hcl
terraform {
  required_version = ">= 1.12.0"
}

resource "terraform_data" "example" {
  input = "Hello Terraform"
}

output "message" {
  value = terraform_data.example.output
}

The sections below explain each construct. Examples use the built-in terraform_data resource so you can practice Terraform syntax without cloud credentials or external providers.

NOTE
Complete the Terraform lab environment on Ubuntu and install Terraform before this guide if you have not already.

Terraform configuration file structure

Create a dedicated directory for these exercises under your lab tree:

bash
mkdir -p ~/terraform-labs/hcl-syntax

Move into that directory — every command in this guide assumes you are here:

bash
cd ~/terraform-labs/hcl-syntax

Create an empty starting file:

bash
touch main.tf

Common filenames teams use in one module directory:

text
main.tf
variables.tf
outputs.tf
providers.tf
versions.tf

These names are conventions only. Terraform does not require main.tf or any particular filename. Terraform combines the .tf and .tf.json configuration files in the directory into one module.


Anatomy of a Terraform block

A typical resource block looks like this:

hcl
resource "terraform_data" "example" {
  input = "hello"
}

Read it left to right:

text
resource                 block type (keyword)
"terraform_data"         first block label (resource type)
"example"                second block label (local name in this module)
input                    argument name
"hello"                  argument value (a string expression)

Every block has:

  • a block type (resource, variable, output, and others)
  • zero or more block labels in quotes (resource blocks require two labels)
  • a body in { } containing arguments as name = value pairs

Argument values can be literals or more complex expressions — covered briefly below and in depth in a dedicated expressions guide later in this course.


Terraform arguments vs attributes

In configuration, you set arguments:

hcl
input = "production"

After Terraform creates a resource, you can read exported attributes in references:

hcl
terraform_data.example.output

Do not call every key = value pair an attribute:

  • Arguments are what you assign in your .tf files
  • Attributes are what Terraform (or the provider) exposes for use in other expressions

Full resource reference rules belong in a separate resource-dependencies article — here the distinction is terminology only.


Terraform identifiers

Local names must start with a letter or underscore and contain letters, digits, underscores, and hyphens. Use descriptive snake_case names:

hcl
resource "terraform_data" "web_server" {
  input = "ok"
}

Avoid spaces, dots in local names, or names that read like provider types (aws_instance as a local name is confusing). The first block label is always the resource type string; the second is your chosen name within the module.


Terraform strings, numbers and booleans

Primitive argument values use familiar literal syntax:

hcl
name    = "web"
count   = 3
enabled = true

Structural types (lists, maps, objects, tuples) have their own rules — see a dedicated data-types guide for constraints and conversions. This article shows only how they look in configuration.


Lists and maps in Terraform syntax

A list uses square brackets:

hcl
ports = [80, 443]

A map uses curly braces with key = value entries:

hcl
tags = {
  environment = "dev"
  owner       = "admin"
}

You are learning Terraform configuration syntax here, not the full type system. Tuple, set, and object semantics are out of scope.


Terraform expressions

Argument values are not limited to literals. They can reference other symbols:

hcl
input = var.environment

String templates combine values inside a quoted string — ${...} embeds an expression:

hcl
input = "${var.environment}-server"

When the entire value is only a reference, use the reference directly:

hcl
input = var.environment

Expression operators, functions, and precedence are covered in a separate expressions article.


Terraform comments

HCL supports line and block comments:

hcl
# line comment
// also a line comment

/*
  block comment
  across lines
*/

Use comments to document non-obvious intent — not to restate every argument name.


Terraform block types

Major block types you will see across the course:

Block type Role
terraform Terraform version and provider requirements
provider Provider configuration
resource Managed infrastructure objects
data Read-only data sources
variable Input variables
locals Named local values
output Exported values after apply
module Child module calls

Each block type has its own tutorial in this course. This page introduces the vocabulary only.


The terraform block

Pin the Terraform CLI version your configuration expects:

hcl
terraform {
  required_version = ">= 1.12.0"
}

Declare which providers the module may use (provider configuration itself is a separate article):

hcl
terraform {
  required_providers {
    local = {
      source = "hashicorp/local"
    }
  }
}

The top-level terraform block controls Terraform and provider requirements — not individual resources. Provider version resolution and .terraform.lock.hcl belong in a dependency-lock guide.


Terraform .tf vs .tf.json files

Extension Format
*.tf Native HCL syntax (default for hand-written config)
*.tf.json JSON representation of the same structure

A minimal JSON equivalent of a terraform_data resource:

json
{
  "resource": {
    "terraform_data": {
      "json_example": {
        "input": "from json"
      }
    }
  }
}

Most teams author .tf files. JSON is common for:

  • Generated configuration
  • Tools that emit Terraform language in JSON form

Does Terraform process files in order?

Terraform evaluates a module as a whole, not as a procedural script that runs main.tf before outputs.tf.

Split the example across files in your lab directory. Save the version constraint:

bash
cat > versions.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}
EOF

Put the resource and a local value in main.tf:

bash
cat > main.tf <<'EOF'
resource "terraform_data" "example" {
  input = local.greeting
}

locals {
  greeting = "${var.environment}-server"
}
EOF

Define the variable in variables.tf:

bash
cat > variables.tf <<'EOF'
variable "environment" {
  type    = string
  default = "dev"
}
EOF

Reference the resource output from outputs.tf:

bash
cat > outputs.tf <<'EOF'
output "message" {
  value = terraform_data.example.output
}
EOF

outputs.tf can reference terraform_data.example even though the resource is declared in main.tf. For ordinary .tf files, filename order does not control resource evaluation — Terraform evaluates the combined module configuration and dependency graph.


Format Terraform configuration

terraform fmt rewrites .tf files to canonical spacing and alignment. Create a deliberately messy main.tf fragment to see the diff:

bash
cat > /tmp/fmt-demo.tf <<'EOF'
resource "terraform_data" "fmt_demo" {
input = "messy"
}
EOF

Preview what fmt would change:

bash
terraform fmt -diff /tmp/fmt-demo.tf

Sample output:

output
fmt-demo.tf
--- old/fmt-demo.tf
+++ new/fmt-demo.tf
@@ -1,3 +1,3 @@
 resource "terraform_data" "fmt_demo" {
-input = "messy"
+  input = "messy"
 }

Apply formatting in place:

bash
terraform fmt /tmp/fmt-demo.tf

terraform fmt exits silently on success. Flag details and CI usage belong in a dedicated terraform fmt command article.


Validate the example configuration

Return to the multi-file lab directory:

bash
cd ~/terraform-labs/hcl-syntax

Initialize the working directory (required before validate when providers or modules are involved; harmless for built-in resources):

bash
terraform init

Sample output:

output
Initializing the backend...

Initializing provider plugins...
- terraform.io/builtin/terraform is built in to Terraform

Terraform has been successfully initialized!

Check syntax and internal references:

bash
terraform validate

Sample output:

output
Success! The configuration is valid.

terraform validate catches many language errors before you run plan. Deeper validation rules (preconditions, custom conditions) are separate topics.


Run the example

Review what Terraform would change:

bash
terraform plan

Sample output (trimmed):

output
# terraform_data.example will be created
  + resource "terraform_data" "example" {
      + input  = "dev-server"
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + message = (known after apply)

Apply the plan:

bash
terraform apply

Type yes when prompted.

Read the exported output:

bash
terraform output

Sample output:

output
message = "dev-server"

The value shows your expression ${var.environment}-server resolved correctly across files.

Clean up:

bash
terraform destroy

Approve with yes. This short run confirms your Terraform configuration syntax forms a valid, runnable module — command flag details live in dedicated init, plan, apply, and destroy articles.


Common Terraform syntax errors

Create that directory once and move into it for every error example below:

bash
mkdir -p ~/terraform-labs/hcl-syntax-errors && cd ~/terraform-labs/hcl-syntax-errors

Initialize the working directory once before you start overwriting main.tf with broken examples:

bash
terraform init

Missing closing brace

Save a block without }:

bash
cat > main.tf <<'EOF'
resource "terraform_data" "example" {
  input = "test"
EOF

Ask Terraform to parse the broken file:

bash
terraform validate

Sample output:

output
Error: Unclosed configuration block

  on main.tf line 1, in resource "terraform_data" "example":
   1: resource "terraform_data" "example" {

There is no closing brace for this block before the end of the file.

Add the missing brace and re-run terraform validate until you see Success!.

Unquoted string value

Use a bare word where Terraform expects a string:

bash
cat > main.tf <<'EOF'
resource "terraform_data" "example" {
  input = hello
}
EOF

Run validate again on the updated file:

bash
terraform validate

Sample output:

output
Error: Invalid reference

  on main.tf line 2, in resource "terraform_data" "example":
   2:   input = hello

A reference to a resource type must be followed by at least one attribute
access, specifying the resource name.

Wrap the value in quotes: input = "hello".

Invalid block labels

A resource block needs two labels (type and name):

bash
cat > main.tf <<'EOF'
resource "terraform_data" {
  input = "test"
}
EOF

Validate to see the label error:

bash
terraform validate

Sample output:

output
Error: Missing name for resource

  on main.tf line 1, in resource "terraform_data":
   1: resource "terraform_data" {

All resource blocks must have 2 labels (type, name).

Add the local name: resource "terraform_data" "example".

Invalid reference

Point at a resource that does not exist:

bash
cat > main.tf <<'EOF'
resource "terraform_data" "example" {
  input = terraform_data.missing.output
}
EOF

Check whether the reference resolves:

bash
terraform validate

Sample output:

output
Error: Reference to undeclared resource

  on main.tf line 2, in resource "terraform_data" "example":
   2:   input = terraform_data.missing.output

A managed resource "terraform_data" "missing" has not been declared in the
root module.

Fix the resource name or declare terraform_data.missing.

Unsupported argument

Add an argument the resource type does not accept:

bash
cat > main.tf <<'EOF'
resource "terraform_data" "example" {
  input      = "test"
  not_a_real = "value"
}
EOF

Validate to confirm Terraform rejects the unknown argument:

bash
terraform validate

Sample output:

output
Error: Unsupported argument

  on main.tf line 3, in resource "terraform_data" "example":
   3:   not_a_real = "value"

An argument named "not_a_real" is not expected here.

Remove the unknown argument or switch to a resource type that supports it.


Terraform file organization example

A small realistic layout for one module:

text
example/
├── versions.tf    # terraform { required_version ... }
├── main.tf        # primary resources and locals
├── variables.tf   # input variables
├── locals.tf      # optional: locals-only file
└── outputs.tf     # output values

Separation is for human readability. Terraform still combines the configuration files in that directory into a single module.

  • You can merge everything into one main.tf for tiny labs
  • Split files as the configuration grows

References


Summary

You now know how Terraform HCL syntax fits together:

  • Blocks with labels
  • Arguments with expressions
  • Comments, primitive and collection literals
  • The major block types you will meet in later lessons

Multiple .tf files in one directory form one module — Terraform does not execute them top to bottom like a shell script.

You formatted configuration with terraform fmt, caught mistakes with terraform validate, and ran a multi-file terraform_data example through plan, apply, output, and destroy on Ubuntu.

The most common beginner slip is confusing arguments you set in configuration with attributes you read from resources — keep that distinction in mind when you move on to variables, providers, and resource references.

Next steps in this course track: provider configuration, the variable and output system, and expression depth — each in its own article so this page stays focused on language shape.

Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)