Skip to content
DevOps AI ToolKit
Newsletter
All guides
AI for Bash & Python Automation By James Joyner IV · · 9 min read

Bash Here-Documents and Config Templating Without the Mess

Generate config files, SQL, and multi-line payloads from Bash cleanly. A practical guide to here-docs, here-strings, and safe variable expansion in templates.

  • #bash
  • #python
  • #config

Sooner or later every Bash automation script needs to write a multi-line blob: an nginx config, a systemd unit, a SQL statement, a JSON payload for curl. I used to build these with a stack of echo lines and >> redirections, escaping quotes until my eyes crossed. Then I learned to use here-documents properly, and config generation in Bash stopped being painful.

Here-docs are deceptively simple but have a few sharp edges around quoting and variable expansion that bite people constantly. Let me walk through the patterns that actually work, and where I let an AI assistant draft the templates.

The basic here-document

A here-doc feeds a block of text to a command’s stdin. The most common use is redirecting it into a file:

cat > /etc/myapp/config.yaml <<EOF
server:
  host: localhost
  port: 8080
EOF

Everything between <<EOF and the closing EOF becomes the input to cat, which writes it to the file. The delimiter (EOF here) can be any word; it just has to match. This is dramatically cleaner than a pile of echo statements.

Expanded versus literal: the quoting that trips everyone

By default, a here-doc expands variables and command substitutions, just like a double-quoted string:

port=8080
cat > config.yaml <<EOF
port: $port
hostname: $(hostname)
EOF

That writes port: 8080 and the actual hostname. But sometimes you want the text literal — for instance, writing a script that itself contains $variables you do not want expanded. Quote the delimiter to turn off expansion:

cat > deploy.sh <<'EOF'
#!/bin/bash
echo "Running as $USER in $PWD"
EOF

With 'EOF' in quotes, $USER and $PWD are written verbatim into the file instead of being expanded now. This single distinction — quoted versus unquoted delimiter — is the source of most here-doc confusion. Decide deliberately which one you want.

Pro Tip: When generating a script or config that contains literal $, always quote the delimiter (<<'EOF'). When you genuinely want to inject values, leave it unquoted but treat every interpolated variable as a potential injection point and validate it first.

Indenting here-docs for readability

Plain here-docs must have the closing delimiter at column zero, which looks ugly inside an indented function. The <<- variant strips leading tabs (tabs only, not spaces) so you can indent:

deploy() {
    cat > config.yaml <<-EOF
	server:
	  port: 8080
	EOF
}

The lines and the closing EOF are indented with tabs, which <<- removes. This keeps your script readable. The tabs-not-spaces requirement is finicky, which is one reason many people template in another language instead.

Here-strings for single values

For a single line of input, a here-string (<<<) is lighter than a here-doc:

jq '.version' <<< "$json_response"

read -r first second <<< "$line"

It feeds one string to stdin. I use it constantly to pipe a variable into jq, grep, or read without an echo | subshell.

Building API payloads safely

A frequent use is constructing a JSON body for curl. The danger is that injecting unescaped variables into JSON breaks the moment a value contains a quote or newline:

# FRAGILE: breaks if $message contains a quote
curl -X POST "$url" -d @- <<EOF
{"text": "$message"}
EOF

Do not build JSON by hand. Use jq to construct it so values are escaped correctly:

jq -n --arg msg "$message" '{text: $msg}' \
  | curl -X POST "$url" -H 'Content-Type: application/json' -d @-

jq -n --arg injects the variable as a properly escaped JSON string, no matter what characters it contains. This eliminates a whole class of injection and corruption bugs. Save here-docs for config formats where you control the structure, and use the right tool for structured data.

Capturing here-doc output into a variable

A here-doc does not have to write to a file or a command — you can capture it into a variable with command substitution. This is handy for building a multi-line message you will use more than once:

read -r -d '' body <<EOF
Deploy of $service to $env completed.
Version: $version
Started: $(date -u +%FT%TZ)
EOF

echo "$body" | mail -s "Deploy done" oncall@example.com

The read -r -d '' trick reads until end-of-input into body, preserving the newlines. Note that read returns non-zero when it hits EOF this way, so do not let set -e abort the script here — pair it with || true only on this specific read, since the non-zero is expected.

Writing files that need elevated permissions

A common snag: redirecting a here-doc into a root-owned path with sudo does not work as people expect, because the redirection happens in your shell before sudo runs:

# FAILS: the > redirect runs as you, not root
sudo cat > /etc/myapp/config.yaml <<EOF
...
EOF

Route the here-doc through sudo tee so the privileged process does the writing:

sudo tee /etc/myapp/config.yaml >/dev/null <<EOF
server:
  port: 8080
EOF

tee runs under sudo, opens the file with root’s permissions, and >/dev/null discards the copy it would otherwise echo to your terminal. This is the canonical way to write a here-doc to a protected location.

When to leave Bash entirely

Here-docs are great for short, static-ish templates. Once you need loops, conditionals, or includes inside the template, stop fighting Bash and use a real templating engine. A tiny Python script with Jinja2 is far more maintainable:

from jinja2 import Template
from pathlib import Path

tmpl = Template(Path("nginx.conf.j2").read_text())
Path("/etc/nginx/conf.d/app.conf").write_text(
    tmpl.render(port=8080, upstreams=["a", "b"])
)

Knowing when to cross that line is half the skill. A handful of substitutions, stay in Bash; logic in the template, move to Jinja2.

Letting AI draft the templates

Generating a here-doc for a systemd unit, an nginx block, or a config file is exactly the kind of boilerplate an AI assistant produces quickly. I will describe the target config to ChatGPT or GitHub Copilot and ask for a here-doc that writes it, with the right quoting on the delimiter.

It is a fast junior engineer for this, but I review every generated template before it runs, because a bad config can take down a service. I check:

  • Whether the delimiter quoting matches the intent — literal versus expanded is the most common mistake the AI makes.
  • That any interpolated variable going into JSON or YAML is escaped (via jq or a real templater), not raw.
  • That no real secrets were pasted into the prompt or baked into the template. Generated configs should reference secrets from the environment at runtime, and I never hand the AI a real credential as “example data.”

I keep that checklist in the prompt workspace and run config-writing scripts through the code review dashboard before they touch a server.

Conclusion

Here-documents make multi-line output in Bash clean: <<EOF for expanded text, <<'EOF' for literal, <<-EOF for indented blocks, and <<< for single strings. Build structured data like JSON with jq rather than hand-escaping, move to Jinja2 once the template needs logic, and let an AI draft the boilerplate while you review the quoting and the injection points. Done right, config generation in Bash is tidy instead of a quoting nightmare.

More in the Bash and Python automation category. Reusable starters are in the prompt library, and curated sets are in the prompt packs.

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.