<<EOT ... EOT: Multi-line Literals With Marker Discipline
When a string must span lines — a cloud-init script, an nginx snippet, a policy document — quoted strings run out of road (a real newline is the parse error "Unfinished multi-line string"). Heredocs carry the load:
resource "local_file" "motd" {
filename = "${path.module}/motd.txt"
content = <<EOT
Welcome to ${var.environment}.
Contact: ${var.owner_email}
EOT
}
The anatomy, precisely: the introducer is << followed immediately by your marker identifier and a newline. Terraform then treats every following line as string content until it finds a line consisting of exactly that marker. The marker is yours to choose — convention is all-caps starting with EO, like EOT ("end of text") or EC2 ("end config") — and it is a delimiter, not decoration: choose one that cannot collide with document content, or the string terminates early and the remainder parses as configuration, producing baffling errors. A line containing the marker plus trailing spaces does not close a plain heredoc; only the marker alone does.
Three value-level details separate people who have debugged a heredoc from people who have not. The final newline is part of the string: <<EOT\nhello\nEOT evaluates to "hello\n", not "hello" — which is why generated config files end with exactly one newline instead of zero or three. Backslashes are literal: unlike quoted strings, heredocs process no backslash escapes, so \n in a heredoc is a backslash followed by n, and writing a Windows path or a regex needs no doubling. The only escapes recognized are the two template introducers: $${ for a literal ${ and %%{ for a literal %{ — the same doubling rule as quoted strings, and the only escaping a heredoc knows.
Everything the template language offers works inside heredocs, which is the point. ${var.name} interpolation, full ternaries, and %{if}/%{for} directives all function, and the multi-line form is where directives earn their keep — HashiCorp's own guidance is to use directives in heredocs rather than cramming them into quoted strings:
content = <<EOT
%{ for host in var.hosts ~}
server ${host};
%{ endfor ~}
EOT
One operational note: heredocs cannot be used where the grammar demands a literal string — block labels, for example, must be quoted strings, so resource <<EOT ... is impossible. Heredocs are expression-level strings for attribute values, and that scope keeps configuration headers machine-checkable.
Heredocs also compose with the rest of the expression library, which is where they stop being a curiosity and start being infrastructure. A heredoc is just an expression, so it may appear anywhere one may: as an attribute value (the common case), as an element of a list, as a value inside jsonencode, or as the return value passed into another function. The embedded ${} sequences evaluate with full access to references, function calls, and nested conditionals, which means a policy document heredoc can interpolate aws_s3_bucket.data.arn directly — and because the reference appears inside the string, the dependency edge forms exactly as it would in a plain attribute assignment. Nothing about being multi-line changes the graph behavior; heredoc content is ordinary configuration text with the same reference semantics.
The sibling form — the one you will use most — tunes this same construct for readable indentation.
The <<- Form: Embedded Documents That Do Not Break HCL Indentation
A heredoc nested inside a block faces an aesthetic problem: JSON and scripts want to start at the left margin, but the block they live in is indented two, four, six levels deep. The plain form forces you to choose between readable HCL and readable content. The indented variant resolves it:
resource "aws_iam_role_policy" "lambda" {
name = "lambda-policy"
role = aws_iam_role.lambda.id
policy = <<-EOT
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::${var.bucket_name}/*"
}]
}
EOT
}
The mechanism: with <<-, Terraform scans every content line, finds the smallest number of leading spaces among them, and strips exactly that many characters from the start of each line — including the closing marker's line, which may therefore be indented. In the example, every line carries at least two leading spaces, so two are removed and the value is flush-left, properly indented JSON. Crucially it is the minimum across lines that is stripped, not a fixed count, so lines more deeply indented than the shallowest keep their relative indentation — the JSON above keeps its internal structure.
Two caveats, both learned cheaply by breaking them once. The calculation counts spaces, not tabs: mixed tab/space indentation can strip an unexpected amount or mangle an embedded document that indented with tabs — configure your editor's "insert spaces" for .tf files and this never surfaces. And the closing marker must be on its own line (indentation allowed); trailing content after it is not part of the string and will parse as configuration.
Where indented heredocs genuinely shine in interviews and production alike: IAM policy documents (though the aws_iam_policy_document data source often beats raw JSON), cloud-init and user_data scripts, nginx/haproxy configuration snippets, and anything a reviewer would rather read as a real document than as an escaped one-line string. One caution from HashiCorp's docs worth repeating: for generated JSON or YAML, prefer jsonencode({...}) / yamlencode({...}) over hand-written heredocs — Terraform then guarantees syntactically valid output and interpolates values without quote-escaping hazards. Heredocs are the right tool for documents you author as documents; encode functions are the right tool for documents you construct from values.
A debugging tip for when an embedded document misbehaves after apply: inspect the value Terraform actually produced, not the source you wrote. terraform console with a reference to the attribute — aws_iam_role_policy.lambda.policy — prints the exact string, whitespace and trailing newline included, and wrapping that same reference in jsondecode() (jsondecode(aws_iam_role_policy.lambda.policy)) answers in one line whether the document is even valid JSON. Most "the policy says it is invalid" mysteries turn out to be one of three culprits visible in seconds this way: an unescaped ${...} that ate a document placeholder, indentation that survived <<- stripping unexpectedly, or a missing comma that no checker caught because the heredoc was hand-written. Both share the interpolation machinery, which is what makes the next question interesting: what the other comment-like syntax in HCL actually does.
For long documents, remember the escape hatch you already own: templatefile("policy.json.tftpl", { bucket = var.bucket }) moves the whole heredoc into its own file, where editors lint JSON and reviewers see the document at proper indentation — a straight upgrade once a policy passes roughly twenty lines or gains a second conditional.
Comments, Not Concatenation: What // Does and How Multi-line Strings Really Work
HCL has three comment forms, and the first one is the source of a persistent myth:
resource "aws_instance" "web" { # hash comment
ami = "ami-123" // double-slash comment
/* block comment
spanning lines */
instance_type = "t3.micro"
}
// and # are line comments — both, per the HCL spec, terminated by the next newline and otherwise ignored — and /* */ is an inline comment that may span lines. Now the myth, stated plainly so it dies: there is no // (or any comment-based) form that produces a multi-line string. No sequence of slashes gives you string content; the double slash is a comment and its line vanishes before evaluation ever runs. If a colleague, a blog post, or an older cheat sheet claims otherwise, the fastest refutation is ten seconds in terraform console. Multi-line string values in HCL come from exactly two sources: heredoc literals, and functions that assemble lines programmatically. For the programmatic route, join with an explicit newline is the honest tool:
content = join("\n", [
for s in var.services : "server ${s};"
])
# one line per service, no directive needed, value computed not declared
The %{for} directive inside a heredoc does the same job with declared text around it; knowing both, you choose by whether the document's shape is static (heredoc) or projected from data (join/for). One subtle rule from the spec worth knowing because it explains a rare parse error: comments cannot begin inside a template literal — a // inside a heredoc or quoted string is just characters in the string, and comments only exist in the structural layer (or inside ${...} interpolation bodies, where full expressions — and therefore full syntax — are allowed).
Comments themselves have a culture worth adopting, because in configuration that provisions real money they do real work. # and // are interchangeable; the community convention leans toward # for one-line annotations adjacent to arguments and // in places where the file's style uses it consistently — either is valid, and terraform fmt preserves both. The rule that matters more than the marker: comment the why, never the what. ami = "ami-0c55b159cbfafe1f0" # Ubuntu 22.04 LTS, FIPS-enabled, pinned for prod earns its lines during the next incident; a comment restating instance_type = "t3.micro" earns nothing. And when the "comment" is really a disabled argument during debugging, remember that a commented-out attribute is invisible to validation — the moment you stop needing it, delete it, because a graveyard of commented configuration is indistinguishable from a plan nobody understands.
A short checklist closes the topic. Use quoted strings for single-line values with interpolation. Use <<EOT when content is genuinely multi-line and flush-left is fine; <<-EOT when nested inside indented blocks. Escape $${/%%{ when the document contains literal placeholders. Reach for jsonencode/yamlencode for constructed documents, join/format for computed lines, and templatefile when the document deserves its own file with highlighting and linting. Comments (//, #, /* */) document configuration and never produce values. With the string layer complete, the next stop is the block that strings most often fill: Resource Block Anatomy.
Quiz
1. A heredoc is written as <<EOT, then "hello", then "EOT". What is the value?
2. An indented heredoc (<<-EOT) has lines indented with 4, 4, and 6 spaces. What is stripped?
3. What does the sequence // do inside a heredoc string body?
4. You must embed a Windows path C:\temp\logs and the literal text ${TOKEN} in a heredoc. What is correct?
Flashcards
Question
What is the exact anatomy of a heredoc, and what value does its final newline contribute?
Click to reveal answer
Answer
`<<MARKER` + newline, then content lines, then the marker alone on its own line. The newline before the closing marker is *included*, so `<<EOT\nhello\nEOT` equals `"hello\n"`. The marker is an arbitrary identifier; choose one that cannot collide with document content or the string ends early.
Question
How does <<- strip indentation, and what does it not do?
Click to reveal answer
Answer
It finds the smallest leading-space count among content lines and removes exactly that many spaces from every line, including the marker line; relative indentation survives. It does not process tabs as spaces, does not strip content beyond that minimum, and does not allow trailing text after the closing marker.
Question
Which escapes work in heredocs, and how do they differ from quoted strings?
Click to reveal answer
Answer
Only `$${` and `%%{`, producing literal `${` and `%{`. Backslash sequences (\n, \t, \") are NOT processed — backslashes are literal characters — and real newlines are allowed, which is the inverse of quoted strings where backslashes escape and newlines are forbidden.
Question
Does HCL have a // multi-line string form? What are the real mechanisms for multi-line string values?
Click to reveal answer
Answer
No — that is a myth. `//` and `#` are line comments and `/* */` spans lines; comments never produce values, and `//` inside a heredoc is literal text. Multi-line values come from heredocs (`<<EOT`/`<<-EOT`) or programmatic assembly: join("\n", [...]), %{}for directives, or templatefile().
Question
When should a document use a heredoc versus jsonencode/templatefile?
Click to reveal answer
Answer
Heredoc when you author the document as text (policies, cloud-init, nginx snippets) and want readable interpolation; jsonencode/yamlencode when Terraform constructs the document from values, guaranteeing valid syntax; templatefile when the document outgrows an inline literal and deserves its own highlighted, linted file with variables passed in.
Revision Notes
Key Takeaways
- 1. Heredocs are marker-delimited templates: the marker must stand alone, the trailing newline is part of the value, and choice of marker must not collide with document text
- 2. `<<-` strips the minimum leading-space count, preserving relative indentation — the form to use inside nested blocks; it counts spaces, not tabs
- 3. Heredocs invert quoted-string escaping: backslashes are literal, newlines are allowed, and only `$${`/`%%{` are escape sequences
- 4. `//`, `#`, and `/* */` are comments in the structural layer; `//` produces no string, and inside templates it is ordinary text — multi-line values come from heredocs, join, or templatefile
- 5. Prefer `jsonencode`/`yamlencode` for value-constructed documents (guaranteed valid syntax) and heredocs for authored ones (readable, lintable text)
Interview Tips
- • "Write a multi-line string in HCL." — heredoc with `<<-` for indentation, mention the included trailing newline and `$${` escaping; if asked "or with `//`?" — correct the myth and offer join("\n", ...) as the programmatic alternative
- • "Why is my embedded JSON invalid after apply?" — quote hazards in hand-written heredocs; the structural fix is `jsonencode`, which makes Terraform generate valid JSON and interpolate values safely
- • "Cloud-init script containing shell ${VARIABLE} placeholders renders wrong." — the `${}` was eaten by HCL interpolation; escape as `$${VARIABLE}` in the heredoc, and remember backslashes need no doubling there
- • "What does <<- actually strip?" — the minimum number of leading spaces found across lines, from every line; tabs are not counted as spaces, so tab-indented files can strip unexpectedly — keep .tf files space-indented
Cheat Sheet
Heredocs & Multi-line Strings
Plain: content = <<EOT … EOT — marker alone closes; value includes the newline before it; flush-left content only by choice.
Indented: content = <<-EOT … EOT — strips the minimum leading spaces from every line; marker may be indented; spaces not tabs.
Escapes: $${ → literal ${ · %%{ → literal %{ · backslashes are literal (no \n processing) · real newlines allowed. Interpolation, ternaries, and %{if}/%{for} all work inside.
Comments (structural layer only):
# line comment
// line comment
/* block comment */
// inside a string is text, not a comment — and there is no // multi-line string form in HCL.
Choosing a tool:
| Need | Tool |
|---|---|
| Authored multi-line document | <<-EOT heredoc |
| Document constructed from values | jsonencode({...}) / yamlencode |
| Lines projected from a collection | join("\n", [for …]) or %{for} in a heredoc |
| Large/complex document | templatefile("f.tftpl", {…}) |