Heredocs and ssh
Run Commands on a Remote Server over SSH sends heredoc scripts to one host or twenty and checks each exit code. This page explains the heredoc itself: what expands, where, and how it ends.
A heredoc is the easy way to put a block of text in a script, and the place where most "it worked locally" bugs in deploy scripts live. The difference between <<EOF and <<'EOF' decides whether $HOME means your home directory or the server's. <<- lets you indent the block, until an editor turns a tab into spaces and the script stops parsing. Every behaviour below is demonstrated, not described.
Does My Heredoc Expand Variables?
Reproduced here on 2026-10-03 (Kali, bash 5.3.15). Same body, two delimiters:
Unquoted, the body behaves like a double-quoted string: $name and $(date) are filled in, and \$ produces a literal dollar sign. Quoted, nothing is touched. Any quoting of the delimiter counts: <<'EOF', <<"EOF" and <<\EOF all switch expansion off.
Where Does the Expansion Happen With ssh?
bash -s reads a script from stdin, the same thing ssh host bash -s does on the other machine. It stands in for the remote shell here, and the PIDs show which shell did the expanding:
With <<EOF, $local_dir and $$ were replaced by the sending shell before the other shell ever ran, so the "remote" output contains the local PID. \$\$ survived to be expanded on the other side. With <<'EOF', everything is the other shell's: local_dir does not exist there. Over real ssh, that is the difference between $HOME being /home/you and /home/deploy. Quote the delimiter for remote scripts, and pass the few local values you need as arguments or with printf %q.
How Do I Indent a Heredoc?
<<- strips leading tabs. cat -A shows them as ^I:
Leading tabs went; the four spaces after the tab stayed. Now the same block indented with spaces, which is what most editors insert:
EOF with spaces is not the delimiter, so bash kept reading: fi and echo became heredoc text, and the if never closed. If your editor expands tabs, keep heredoc bodies and their EOF at column 0, or assign the text to a variable instead.
How Do I Write a File or Use a Here-String?
cat > file <<'EOF' writes the body literally, so $PORT lands in the unit file for systemd to read later. <<< is a here-string: one string as stdin. Because there is no pipe, read runs in the current shell and first and rest survive, which echo "…" | read first rest would lose to a subshell (the same trap as piping into a while loop). For a root-owned file, sudo tee /etc/app.conf > /dev/null <<'EOF': a plain sudo cat > /etc/app.conf fails, because your own shell performs the redirect before sudo starts.
The Script
Rendering a config from variables is the job heredocs do most often, and the one that fails worst: an unset variable silently becomes an empty string in the file. Save as render-nginx-site.sh:
Prerequisites
bash 4 or later and coreutils (mktemp, mv). nginx is not needed to render the file; run sudo nginx -t after copying it into place to have nginx check it too.
How Does the Script Work?
: "${VAR:?message}"aborts with the variable's name when it is unset or empty, before anything is written.set -ualone would also stop on an unset variable, but:?catches empty ones and says what to set.- The unquoted
<<EOFis deliberate: this heredoc exists to fill in values. Every$meant for nginx is escaped as\$, so$hostreaches the file as nginx syntax. - The leftover check greps the rendered file for any
$nameother than the two nginx variables. A forgotten escape or a typo shows up here, with its line number, instead of in nginx's error log. mktempin the target's directory, thenmvmakes the replacement atomic: nginx, or anyone reading the file, sees the old version or the new one, never half of one. TheEXITtrap removes the temp file on any failure.
What Does the Script Print?
With PORT missing, the script stopped before touching anything, so no broken.conf exists. Render, check, then rename is the habit worth copying into any script that writes config; The Production Bash Toolkit ships a ShellCheck-clean script template to start from. The :? checks are explained on Bash Environment Variables, and the EXIT trap on Bash Trap Cleanup.
Frequently Asked Questions
What is the difference between <<EOF and <<'EOF' in bash?
With <<EOF, bash expands the heredoc body before passing it on: $variables are replaced, $(commands) run, and backslashes escape. With <<'EOF' (any quoting of the delimiter, including \EOF or "EOF"), the body is literal text and nothing is expanded. Use the quoted form when the text is code or config that contains $, such as a script you send over ssh or a systemd unit, and the unquoted form when you want to fill in values.
How do I indent a heredoc in bash?
Use <<- instead of <<. It strips leading tab characters from every body line and from the line holding the delimiter, so the heredoc can follow the indentation of an if block or function. It only strips tabs. If your editor converts tabs to spaces, the closing delimiter is no longer recognised and bash reads to the end of the file, then reports here-document delimited by end-of-file. Indentation after the leading tabs is kept.
How do I write a file with a heredoc?
Redirect the command's output: cat > /path/file <<'EOF' writes the body to the file, and cat >> file <<'EOF' appends. For a file only root can write, use sudo tee: sudo tee /etc/app.conf > /dev/null <<'EOF'. A plain sudo cat > /etc/app.conf fails because the redirection is done by your unprivileged shell, not by sudo. For configs, write to a temporary file and mv it into place, so a reader never sees half a file.
How do I use a heredoc with ssh?
Pipe the heredoc to a shell on the remote side: ssh host bash -s <<'EOF', the commands, EOF. Quote the delimiter so $variables and $(commands) are expanded by the remote shell, on the remote machine. Leave it unquoted only for values you deliberately fill in locally, and escape the rest as \$. Because ssh reads the script from stdin, do not also use ssh -n here, and do not run commands in the block that read stdin themselves.
What is a here-string (<<<) in bash?
A here-string passes one word or string as standard input: grep -c a <<< "$text" or read -r first rest <<< "$line". It is shorter than echo "$text" | cmd and, unlike the pipe, runs the command in the current shell, so read can set variables that survive. bash appends a newline to the string. Here-strings are a bash and zsh feature, not POSIX sh.
Part of the bash snippets collection
Related Scripts
- Run Commands on a Remote Server over SSH — heredoc scripts to many hosts, with per-host exit codes
- Bash String Manipulation — building the text before it goes into a heredoc
- Bash Trap Cleanup — the EXIT trap that removes the temp file when rendering fails
- Bash Environment Variables —
${VAR:?}and why the remote shell has different variables