OnCallReady

Lesson 6.18 · Bash Scripting · 12 min read

Here-documents

In plain words

Imagine dictating a letter to a secretary. If you say "write this exactly as I say, word for word", the letter comes out with every symbol you spoke. If you just say "write this", the secretary helpfully fills in blanks: every time you say "dollar name", they write in the person's actual name, and if they do not know it, they leave it empty.

A here-document is that dictation: cat <<EOF ... EOF feeds several lines to a command. Unquoted EOF is the helpful secretary who expands $var and $(cmd). Quoted 'EOF' is word for word. When writing a unit file, a script or an nginx config that contains $, you want word for word. And sudo tee file <<'EOF' is how you dictate straight into a root-owned file.

Why this lesson

Scripts often need to write a whole file - a systemd unit, a config, another script. Ten echo lines with >> are ugly and easy to get wrong. A here-document (heredoc) lets you paste the file's text right into the script and feed it to a command as its stdin. You have been using them since 6.2; this lesson explains the one detail that decides whether they work.

What you need to know already: stdin and < (1.7); sudo and why sudo echo x > /etc/file fails (1.11); systemd drop-ins (2.3); subshells and pipes (6.5).

The shape

cat <<EOF
line one
line two
EOF

<<EOF means "the lines that follow, up to a line that is exactly EOF, are this command's stdin". EOF is just a marker word, the delimiter - any word works, EOF is the custom.

Quoted or not - it changes everything

name=world

cat <<EOF          # UNQUOTED: expands $var, $(cmd), backticks
hello $name
today is $(date +%F)
EOF

cat <<'EOF'        # QUOTED: completely literal
hello $name
today is $(date +%F)
EOF

The first prints hello world. The second prints hello $name.

($(date +%F) runs date and inserts today's date, e.g. 2026-09-22.)

Quote the delimiter whenever you are writing a file that contains $ - a script, a systemd unit, a config file. Forget, and the shell that is writing the file expands the variables right now, usually to empty strings, and you get a file full of blanks.

Writing a root-owned file

sudo tee /etc/systemd/system/demo.service > /dev/null <<'EOF'
[Unit]
Description=Demo

[Service]
ExecStart=/usr/local/bin/demo.sh
EOF

Remember why it is sudo tee and not sudo cat > file (1.11): the > is performed by your shell, as you, before sudo even starts. Here the heredoc is fed to tee's stdin, and tee - running as root - opens the file and writes it.

> /dev/null because tee also copies everything to stdout, and you do not need to see it twice.

<<- strips leading TABS

if true; then
	cat <<-'EOF'
	this line is indented with a tab in the script
	but comes out flush left
	EOF
fi

Only tabs, not spaces - which is why it is more trouble than it is worth in an editor that expands tabs.

Process substitution

diff <(sort a.txt) <(sort b.txt)
while read -r line; do ...; done < <(find . -name '*.log')

<(cmd) is process substitution: it makes a command's output look like a file. diff A B compares two files line by line, so the first example compares the sorted versions without saving them anywhere.

The second form you met in 6.8: piping into while read runs the loop in a subshell (6.5), so variables set inside it are lost afterwards. < <(...) feeds the loop from the command while keeping it in the current shell.

count=0
find . -name '*.log' | while read -r f; do count=$((count+1)); done
echo "$count"        # 0 - the loop ran in a subshell

while read -r f; do count=$((count+1)); done < <(find . -name '*.log')
echo "$count"        # correct

What you can now do

Why it helps

Here-documents are how scripts generate config files, unit files, the setup script a new server runs on first boot, and input for other tools. Getting quoting wrong produces a config full of blanks where variables were expanded to empty strings, like an nginx config missing every $host or a script with its own variables replaced at write time, and nothing reports an error.

sudo tee /etc/systemd/system/x.service > /dev/null <<'EOF' is a pattern you will use and see in runbooks constantly, and it combines three lessons: quoting, redirection and the sudo trap. Process substitution in the same lesson fixes the while read subshell bug, and diff <(...) <(...) is a daily tool for comparing configs across environments.

Commands in this lesson

echo

FAQ

How do I expand some variables but not others in a heredoc?

Use an unquoted delimiter and escape the dollars you want kept literal: \$host stays $host, while $PORT is expanded. That gets messy with many $, so alternatives are a quoted heredoc plus envsubst with an explicit variable list (envsubst '$PORT $HOST' < template), or sed substitution of placeholders. Dedicated templating tools are better for anything complex.

Does it matter which quote I use on the delimiter?

No. <<'EOF', <<"EOF" and even quoting part of it, <<E"OF", all make the body literal. What matters is whether any part of the delimiter word is quoted. The closing line must still be the plain word EOF, alone on its line, with no quotes and no trailing spaces, or bash keeps reading until the end of the file.

Why does my indented EOF not end the heredoc?

The closing delimiter must be at the start of the line. <<- allows leading tab characters, stripping them from the body and the delimiter line, but not spaces. Editors that convert tabs to spaces break it. Keep heredoc bodies unindented, or use <<- with real tabs and a shared editor config, or generate the text with printf lines instead.

Why use sudo tee instead of sudo cat > file with a heredoc?

In sudo cat > /etc/file <<'EOF', the > /etc/file redirection is performed by your own unprivileged shell before sudo runs, so it fails with Permission denied. With sudo tee /etc/file > /dev/null <<'EOF', the heredoc goes to tee's stdin and tee, running as root, opens the file. > /dev/null discards tee's copy of the content on stdout.

Can I feed a heredoc to any command?

Yes, to any command that reads stdin: sudo tee file, bash -s (run the lines as a script), or ssh host bash -s <<'EOF' to run a few commands on another machine. Quote the delimiter if the text contains $ meant for the other side. It is convenient in runbooks, but for anything long-lived, commit the file to git instead, so changes are reviewable.

In an interview Junior

What is the difference between a quoted and an unquoted here-document delimiter?

With an unquoted delimiter (<<EOF), the body is expanded: $var, $(cmd) and backticks are replaced by the shell writing it. With a quoted one (<<'EOF'), the body is passed literally.

name=world
cat <<EOF      # prints: hello world
hello $name
EOF
cat <<'EOF'    # prints: hello $name
hello $name
EOF

Rule: quote the delimiter whenever the text you are writing contains $ - a script, a systemd unit with $MAINPID, a config file. Forget, and the variables are expanded now, usually to empty strings, and you get a broken file with no error.

To write a root-owned file: sudo tee /etc/systemd/system/demo.service > /dev/null <<'EOF' - tee runs as root and opens the file; sudo cat > file would fail because your own shell does the >.

Also asked: How do you write a root-owned file from a script? · What is process substitution, and what problem does < <(cmd) solve? · What does <<- do?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.