Three formats, one job

Infrastructure tools need a way to write down structured data: a server with a name, a list of addresses, a map of settings. Three formats cover nearly all of it:

  • HCL for Terraform and OpenTofu (and Packer).
  • YAML for Ansible playbooks and inventories, cloud-init, Kubernetes manifests, and CI/CD pipeline files.
  • JSON for machine-generated configuration, API payloads, Azure Resource Manager (ARM) templates, and tool output meant for scripts.

The general shape of JSON and YAML is explained in the Web & APIs topic. This page is about what matters when they describe infrastructure: how the tools read them, where they surprise you, and how to keep them tidy. Here is one setting of the Kestrel customer portal — its listen port, its environment and the network allowed to reach its admin pages — written three ways:

HCL (for example in a .tfvars file):

portal = {
  listen_port      = 8080
  environment      = "production"
  allowed_networks = ["198.51.100.0/24"]
}

YAML (for example in an Ansible group_vars file):

portal:
  listen_port: 8080
  environment: production
  allowed_networks:
    - 198.51.100.0/24

JSON:

{
  "portal": {
    "listen_port": 8080,
    "environment": "production",
    "allowed_networks": ["198.51.100.0/24"]
  }
}

The data is identical: a map with a number, a string and a list of one string. HCL uses = and quotes its strings; YAML uses indentation and guesses types; JSON quotes every key and string and allows no comments.

HCL

HashiCorp Configuration Language (HCL) is the language of Terraform and OpenTofu configuration files. It was designed to be easy for people to read and write, while still allowing expressions: references to other objects, arithmetic, function calls and loops. A configuration consists of two things:

  • Blocks: a type, zero or more labels in quotes, and a body in braces, such as resource "local_file" "upstream" { … }. Blocks can contain other blocks.
  • Arguments: name = expression inside a block. The expression can be a literal value ("0644", 20, false), a reference (var.environment, random_id.deployment.hex) or a computation.

These are the real versions.tf and main.tf files of the portal configuration on Terraform and OpenTofu: First Steps. versions.tf:

terraform {
  required_version = ">= 1.10"

  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "~> 2.5"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.7"
    }
  }
}

main.tf:

resource "random_id" "deployment" {
  byte_length = 4
}

resource "random_password" "db" {
  length  = 20
  special = false
}

resource "local_file" "upstream" {
  filename = "${path.module}/out/portal-upstream.conf"
  content = templatefile("${path.module}/templates/upstream.conf.tftpl", {
    environment = var.environment
    servers     = var.web_servers
    deployment  = random_id.deployment.hex
  })
  file_permission = "0644"
}

Reading them:

  • terraform is a block with no labels; required_providers is a block nested inside it. local = { … } is an argument whose value is an object — note the =, which a nested block does not have.
  • resource "random_id" "deployment" has two labels: the resource type and a local name you choose.
  • "${path.module}/out/portal-upstream.conf" is a string with interpolation: ${…} inserts the value of an expression.
  • templatefile(…) is a function call with two arguments: a path and an object of values for the template.
  • Values have types: strings in double quotes, numbers (20), booleans (false), lists in square brackets, objects and maps in braces, and null for "not set". Variables, Outputs and Expressions covers types and expressions in full.

Comments start with # or //, or span several lines between /* and */; prefer #, the common style. OpenTofu reads every file ending in .tf or .tofu in a folder as one configuration; the file names only help people. If both versions.tf and versions.tofu exist, OpenTofu reads only the .tofu file, so a module can carry OpenTofu-specific settings that Terraform (which does not read .tofu files) ignores.

Canonical style and tofu fmt

HCL has one canonical layout: two spaces of indentation per level, and the = signs of consecutive arguments aligned. Nobody aligns them by hand: tofu fmt (or terraform fmt) rewrites files into that style, and tofu fmt -check only reports which files are not formatted, which makes it a cheap pipeline check. A badly indented billing.tf:

$ tofu fmt -check
$ tofu fmt -diff
billing.tf
billing.tf
--- old/billing.tf
+++ new/billing.tf
@@ -1,5 +1,5 @@
 resource "local_file" "billing_cron" {
-filename = "${path.module}/out/billing-export.cron"
-    content = "30 2 * * * billing /opt/kestrel/bin/export-billing --format csv\n"
-  file_permission="0640"
+  filename        = "${path.module}/out/billing-export.cron"
+  content         = "30 2 * * * billing /opt/kestrel/bin/export-billing --format csv\n"
+  file_permission = "0640"
 }

The first command printed the file name and exited with status 3 (not formatted); the second showed the change and rewrote the file. Run tofu fmt before every commit — most editors can do it on save.

Multi-line strings: heredocs

Long or multi-line strings are written as a heredoc: <<EOT on the first line, the text, and the same marker alone on the last line. The form <<-EOT removes the indentation that all lines share, so the text can be indented along with the code. (The marker can be any word; EOT, "end of text", is the convention.)

resource "local_file" "portal_env" {
  filename = "${path.module}/out/portal.env"
  content  = <<-EOT
    DB_HOST=192.0.2.31
    PORTAL_URL=https://portal.example.com
  EOT
}

The file gets two lines, without leading spaces, each ending in a newline. OpenTofu shows multi-line values the same way in plans and in tofu state show. The idea is the same as a heredoc in PHP or a here document in the shell.

YAML for infrastructure

YAML describes the same structures with indentation instead of braces: a key: value line is a map entry, a line starting with - is a list item, and indenting by two more spaces nests one level deeper. Ansible playbooks, inventories and variable files, cloud-init files and pipeline definitions are all YAML. A few features beyond the basics are common in infrastructure repositories:

---
# group_vars/web.yml
portal_port: 8080

maintenance_page: |
  <h1>Kestrel Energy portal is under maintenance</h1>
  <p>Please try again later.</p>

motd: >
  This server is managed by Ansible.
  Manual changes will be overwritten.

defaults: &nginx_defaults
  worker_connections: 1024
  keepalive_timeout: 65

nginx:
  <<: *nginx_defaults
  worker_connections: 2048
  • --- marks the start of a document. It is optional for one document; a file can hold several, separated by ---.
  • | starts a literal block: the following indented lines are kept exactly, newlines included. Good for file contents, scripts and certificates.
  • > starts a folded block: the lines are joined with spaces into one line. Good for long sentences.
  • &nginx_defaults gives a node a name (an anchor), *nginx_defaults refers to it, and the merge key << copies its entries into a map; keys written in the map itself win: nginx ends up with worker_connections: 2048 and keepalive_timeout: 65. Use this sparingly; most readers do not expect it, and Ansible has better tools for sharing values (see Variables, Facts and Templates).

Implicit typing: YAML guesses

HCL and JSON make you mark strings with quotes. YAML does not: when a value is not quoted, the parser looks at it and decides whether it is a number, a boolean, a date, null or a string. That rule is called implicit typing, and the exact rules differ between YAML versions. The current specification is YAML 1.2.2 (2021), whose core schema is strict: only true and false (also written True, TRUE, False, FALSE) are booleans. But Ansible reads YAML with PyYAML, which follows the older YAML 1.1 rules, and those are generous. This file of Kestrel settings was loaded with PyYAML, the way Ansible loads it:

pitfalls.yml:

country: NO
billing_enabled: no
backup: on
file_mode: 0755
file_mode_quoted: "0755"
file_mode_new_octal: 0o755
retry_after: 1e3
retry_after_float: 1.0e3
retry_after_signed: 1.0e+3
app_version: 1.10
backup_time: 22:22
backup_time_quoted: "22:22"
zip_code: 01234
empty_value:
tilde: ~
$ python3 -c 'import yaml, pprint; pprint.pprint(yaml.safe_load(open("pitfalls.yml")), sort_dicts=False)'
{'country': False,
 'billing_enabled': False,
 'backup': True,
 'file_mode': 493,
 'file_mode_quoted': '0755',
 'file_mode_new_octal': '0o755',
 'retry_after': '1e3',
 'retry_after_float': '1.0e3',
 'retry_after_signed': 1000.0,
 'app_version': 1.1,
 'backup_time': 1342,
 'backup_time_quoted': '22:22',
 'zip_code': 668,
 'empty_value': None,
 'tilde': None}

Line by line, the surprises:

  • NO, no and on are booleans. YAML 1.1 also accepts yes, off, true and false in several capitalizations. (The 1.1 specification lists y and n too, but PyYAML does not treat them as booleans.)
  • 0755 and 01234 start with a zero, so they are octal numbers: 493 and 668. A US ZIP code such as 01234 or an account number with a leading zero silently becomes a different number.
  • 0o755, the octal form of YAML 1.2, is a plain string in YAML 1.1.
  • 22:22 is a base-60 (sexagesimal) number, a YAML 1.1 feature meant for times: 22 × 60 + 22 = 1342. A backup time or a port mapping written this way is no longer what you wrote. (PyYAML only does this when the first part does not start with 0, so 02:00 stays a string — another reason not to rely on it.)
  • 1.10 is the float 1.1: a version number loses its last digit.
  • 1e3 and even 1.0e3 stay strings; PyYAML only reads an exponent with a dot and a sign, like 1.0e+3. Different parsers disagree here.
  • An empty value and ~ are both null (None in Python).

Ansible sees exactly the same types. A playbook that loads pitfalls.yml and prints each variable with its type (shortened):

$ ansible-playbook show.yml
…
ok: [db01] => (item=country) => {
    "msg": "country = False (bool)"
}
…
ok: [db01] => (item=file_mode) => {
    "msg": "file_mode = 493 (int)"
}
ok: [db01] => (item=file_mode_quoted) => {
    "msg": "file_mode_quoted = 0755 (str)"
}
…
ok: [db01] => (item=app_version) => {
    "msg": "app_version = 1.1 (float)"
}
ok: [db01] => (item=backup_time) => {
    "msg": "backup_time = 1342 (int)"
}
…

The Norway problem

The most famous case has its own name. A list of country codes for Kestrel's meter-reading regions contains NL, BE, DE and NO; the first three load as strings, and Norway loads as false. This is the Norway problem, explained in general on the JSON and other data formats page. In infrastructure files its relatives are everywhere: a feature flag enabled: off that you meant as a string, a country or language code, a host name such as on, a setting copied from an INI file where yes was meant literally. When the value reaches a configuration file, a template or an API, False or True appears where the text belonged.

meter_regions:
  - "NL"
  - "BE"
  - "DE"
  - "NO"
app_version: "1.10"
backup_time: "22:22"

The fix is the same every time: quote strings that could be read as something else. Linters help: ansible-lint reports unquoted booleans other than true and false (its yaml[truthy] rule) and unsafe octal values (yaml[octal-values]); see Testing and Policy as Code.

File modes: the costliest case

File permissions are written in octal (see octal notation), and YAML has its own opinion about octal. This playbook writes the same billing export file with the mode written three ways:

- name: File modes written three ways
  hosts: db01
  gather_facts: false

  tasks:
    - name: Create the export directory
      ansible.builtin.file:
        path: "{{ playbook_dir }}/out"
        state: directory
        mode: "0755"

    - name: Write test files
      ansible.builtin.copy:
        content: "billing export\n"
        dest: "{{ playbook_dir }}/out/{{ item.name }}"
        mode: "{{ item.mode }}"
      loop:
        - { name: quoted.csv, mode: "0640" }
        - { name: leading-zero.csv, mode: 0640 }
        - { name: no-leading-zero.csv, mode: 640 }
$ ansible-playbook modes.yml
$ stat -c '%A %a %n' out/*.csv

PLAY [File modes written three ways] *******************************************

TASK [Create the export directory] *********************************************
changed: [db01]

TASK [Write test files] ********************************************************
changed: [db01] => (item={'name': 'quoted.csv', 'mode': '0640'})
changed: [db01] => (item={'name': 'leading-zero.csv', 'mode': 416})
changed: [db01] => (item={'name': 'no-leading-zero.csv', 'mode': 640})

PLAY RECAP *********************************************************************
db01                       : ok=2    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

-rw-r----- 640 out/leading-zero.csv
--w------T 1200 out/no-leading-zero.csv
-rw-r----- 640 out/quoted.csv

The quoted "0640" is a string that Ansible reads as octal: correct. The unquoted 0640 is read by YAML as the octal number 416, which happens to be the same mode: correct by luck. The unquoted 640 is the decimal number 640, which in octal is 1200: the owner may only write, nobody may read, and the sticky bit is set. The playbook reported success. The arithmetic behind it:

import stat

# How a YAML 1.1 parser (PyYAML, used by Ansible) reads some unquoted values
print("file_mode: 0755   ->", int("0755", 8))      # leading 0: octal
print("zip_code: 01234   ->", int("01234", 8))     # octal again
hours, minutes = "22:22".split(":")
print("backup_time: 22:22 ->", int(hours) * 60 + int(minutes))  # base 60
print("app_version: 1.10 ->", float("1.10"))        # a float, not a version

# What happens when a decimal 640 is used as a file mode
for mode in (0o640, 640):
    print(f"mode {mode:>4} = octal {mode:o}: {stat.filemode(stat.S_IFREG | mode)}")
file_mode: 0755   -> 493
zip_code: 01234   -> 668
backup_time: 22:22 -> 1342
app_version: 1.10 -> 1.1
mode  416 = octal 640: -rw-r-----
mode  640 = octal 1200: --w------T

JSON as configuration

JSON is strict where YAML is generous: every string is quoted, there are no comments, no trailing commas and no guessing. That makes it awkward for people and ideal for programs. In infrastructure work you meet it in three places:

  • Generated configuration. OpenTofu and Terraform read files ending in .tf.json (and OpenTofu .tofu.json) next to the HCL files. A script that produces configuration can write JSON instead of trying to produce correctly formatted HCL.
  • Templates of the cloud providers. AWS CloudFormation accepts JSON or YAML, and Azure Resource Manager templates are JSON (see Other Infrastructure as Code Tools).
  • Machine-readable output. tofu show -json and tofu output -json give scripts and pipelines something they can parse reliably, instead of scraping text meant for humans.

The portal's upstream file, written in JSON syntax (upstream.tf.json). Blocks become nested objects, and expressions live inside strings:

{
  "variable": {
    "web_servers": {
      "type": "list(string)",
      "default": ["192.0.2.11", "192.0.2.12"]
    }
  },
  "resource": {
    "local_file": {
      "upstream": {
        "filename": "${path.module}/out/portal-upstream.conf",
        "content": "upstream kestrel_portal {\n%{ for s in var.web_servers ~}\n    server ${s}:8080;\n%{ endfor ~}\n}\n"
      }
    }
  }
}
$ tofu validate
$ tofu apply -auto-approve | tail -3
$ cat out/portal-upstream.conf
Success! The configuration is valid.

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
upstream kestrel_portal {
    server 192.0.2.11:8080;
    server 192.0.2.12:8080;
}

It works, but compare the content line with the HCL version on the next page: one long string with \n escapes is hard to review. Write HCL by hand and JSON from programs. The same goes for reading: tofu show -json prints the state as JSON, which a script can query instead of parsing the human-readable text.

Templating

Infrastructure tools often have to produce a text file in some other program's format: an nginx configuration, a pg_hba.conf, an environment file. Writing it out in full for every server defeats the purpose; instead you write a template once and fill it with data. A templating engine is the component that does this: it takes a template — text with placeholders, conditions and loops — and a set of values, and renders the final text. The configuration templates used for network devices work the same way.

Templates in HCL

Terraform and OpenTofu have a template syntax built into their strings: ${ … } inserts a value, and %{ if … } or %{ for … } … %{ endfor } are directives. A ~ next to the braces strips the whitespace and newline on that side, so the directive lines leave no empty lines behind. Longer templates go in their own file, rendered with templatefile(). The portal's templates/upstream.conf.tftpl:

# Kestrel Energy customer portal (${environment}), deployment ${deployment}
upstream kestrel_portal {
%{ for s in servers ~}
    server ${s}:8080;
%{ endfor ~}
}
$ cat out/portal-upstream.conf
# Kestrel Energy customer portal (dev), deployment 0a80e8ff
upstream kestrel_portal {
    server 192.0.2.11:8080;
    server 192.0.2.12:8080;
}

Jinja2 in Ansible

Ansible uses Jinja2, a templating engine for Python (version 3.1.6 as of October 2026). It has three kinds of markup:

  • {{ expression }} inserts a value: {{ portal_port }}.
  • {% statement %} controls the template: {% for … %} … {% endfor %}, {% if … %} … {% endif %}.
  • {# comment #} is left out of the result.

Values can pass through filters with a pipe — {{ backup_window | default('01:00-03:00') }}, {{ name | upper }} — and be checked with tests such as is defined. The same upstream file as an Ansible template, templates/portal-upstream.conf.j2, which takes the server addresses from the inventory:

# {{ ansible_managed }}
upstream kestrel_portal {
{% for host in groups['web'] %}
    server {{ hostvars[host].ansible_host }}:{{ portal_port }};
{% endfor %}
}
$ cat hosts/web02/etc/nginx/conf.d/portal-upstream.conf
# Ansible managed
upstream kestrel_portal {
    server 192.0.2.11:8080;
    server 192.0.2.12:8080;
}

The result is the same file, from two different engines. Ansible renders more than template files with Jinja2: almost every task argument can contain {{ … }}. Variables, Facts and Templates covers filters, facts and where values come from.

# Broken: YAML reads { as the start of an inline map
- ansible.builtin.debug:
    msg: {{ portal_port }}

Habits that prevent trouble

  • Let the formatter decide. tofu fmt for HCL, a YAML formatter or at least an editor that inserts spaces for YAML. Formatting discussions disappear from code reviews.
  • Quote YAML strings that look like something else — file modes, versions, times, postcodes, country codes, yes/no/on/off meant as text. When in doubt, quote.
  • Lint in the pipeline. tofu fmt -check and tofu validate for HCL; yamllint and ansible-lint for YAML. They catch most of this page before it reaches a server.
  • Generate JSON, write HCL. Scripts produce JSON; people write HCL and YAML.
  • Keep logic out of templates. A template with five nested conditions is a program nobody tests. Prepare the data in variables, and keep the template close to the file it produces.