Skip to main content

Shell Quoting Mistakes That Break Deploy Scripts: Spaces, Dollars, Newlines, JSON, and SSH

Five quoting failures that keep breaking real deploy scripts — spaced filenames, $ expansion through SSH, JSON bodies in curl, newlines in variables — with actual broken output and the safe copy-paste fix for each.

Published By Li Lei
#shell #bash #deployment #escaping #ssh #devops

Shell Quoting Mistakes That Break Deploy Scripts: Spaces, Dollars, Newlines, JSON, and SSH

Deploy scripts have a cruel property: they run least often exactly where they matter most. A quoting bug in a script you run daily gets caught on day one. The same bug in a rollback path might sit dormant for months, then fire at 2 a.m. while half the site is down. Every failure in this post comes from the same root cause — the shell re-parses your text before your program sees it — but each one wears a different disguise: a spaced filename, a $ inside an SSH command, a JSON body in curl, a newline in a variable.

Here are the five shapes I keep seeing, each with the actual broken output and the fix you can paste.

The shell splits on more characters than you think

The GNU Bash Reference Manual (§2, Definitions) defines exactly ten metacharacters that separate words when unquoted: |, &, ;, (, ), <, >, space, tab, and newline. Any of the ten appearing inside an unquoted variable will split your argument into pieces — and deploy scripts are full of variables you don't fully control: git branch names, release notes, commit messages, artifact paths, hostnames.

Take a release directory that contains a space:

RELEASE_DIR="/var/www/releases/old build"
rm -rf $RELEASE_DIR        # unquoted — DANGER

The shell splits $RELEASE_DIR on the space, so rm -rf receives two arguments: /var/www/releases/old and build. The first one may be a real directory you did not intend to delete; the second silently does nothing or hits a file in your current directory. The fix costs two characters:

rm -rf "$RELEASE_DIR"

Double quotes suppress word splitting and glob expansion but still allow the variable itself to expand — which is exactly the combination you want 95% of the time. If you rename artifacts or uploads before deploying, a planner like the batch file rename planner sidesteps the whole class by generating names that are already shell-safe.

Dollars survive one shell and detonate in the next

The nastiest deploy bugs involve two shells: your local one and the remote one behind ssh. OpenSSH joins all remote-command arguments with spaces into a single string and hands that string to the remote login shell, which parses it again. Anything your local quoting protected gets one more chance to expand on the other side.

I reproduced the minimal version of this on my own machine. A message variable holds a literal $(hostname) — maybe it came from a commit message or a templated notification:

MSG='done on $(hostname)'
bash -c "echo $MSG"       # stand-in for: ssh web1 "echo $MSG"

Actual output:

done on Mac

The command substitution I had safely single-quoted locally was executed by the inner shell, because the double quotes around echo $MSG let $MSG expand into the string that the second shell then re-parsed. Over SSH this is not a party trick — if that variable came from user input, someone just ran code on your production box.

Two safe patterns. For a fixed remote command, single-quote the entire thing so your local shell passes it through untouched:

ssh web1 'rm -rf "/var/www/releases/old build"'

For a remote command that must include local variables, escape the value first with printf %q (more on it below) or, on bash 4.4+, the ${var@Q} expansion:

ssh web1 "deploy-hook --message ${MSG@Q}"

JSON arguments: two quoting systems, one line

A deploy webhook is where shell quoting and JSON escaping collide. Both languages use double quotes, and interpolating a variable into a hand-built JSON string breaks the moment the value contains one. Here is the actual failure with a branch name containing quotes:

BRANCH='feat/"fast"-mode'
echo "{\"branch\": \"$BRANCH\"}" | python3 -m json.tool

Actual output:

json.decoder.JSONDecodeError: Expecting ',' delimiter: line 1 column 19 (char 18)

The branch's inner quotes terminated the JSON string early. The payload your webhook received was garbage, and depending on the endpoint you either got a 400 or — worse — a half-parsed request. The robust fix is to never build JSON by string concatenation. Let a JSON-aware tool do the escaping:

BODY=$(jq -n --arg branch "$BRANCH" '{"branch": $branch}')
curl -sS -d "$BODY" -H 'Content-Type: application/json' https://hooks.example.com/deploy

jq --arg produces {"branch": "feat/\"fast\"-mode"} — the quotes correctly escaped at the JSON layer, while the shell layer only ever handles one quoted variable. When a webhook response comes back mangled and you're not sure which layer ate your quotes, pasting the payload into a JSON formatter shows you immediately whether the JSON itself is valid or the shell mutilated it first.

Newlines: the metacharacter you can't see

Of bash's ten metacharacters, newline is the one that hides best, because it's invisible in most terminal output. Git commit messages routinely contain them, and POSIX permits them in filenames — a filename may contain any byte except / and NUL. Pass a multi-line commit message as an unquoted argument and the shell treats each line as a separate word or, in some contexts, a separate command.

printf %q makes the invisible visible. Feed it a two-line string:

printf '%q\n' $'line one\nline two'

Actual output:

$'line one\nline two'

That $'...' form is bash's ANSI-C quoting: the newline is now an explicit \n escape that survives copy-paste, logging, and a second shell. The same command flattens every other special character too:

printf '%q\n' 'release notes: 100% done ($5 under budget)'
# → release\ notes:\ 100%\ done\ \(\$5\ under\ budget\)

The checklist I actually run before shipping a script

When I audited our own deploy scripts last month, I found four unquoted expansions in about 200 lines — every one of them written by me, every one of them fine for months because no branch name had contained a space yet. The uncomfortable lesson was that quoting bugs are not typos you make once; they are defaults you fall back to whenever you're moving fast. So now I run a fixed routine instead of trusting my eyes.

The routine: quote every "$variable" unless I can articulate why splitting is desired; single-quote every remote command passed to ssh; build JSON with jq --arg, never string interpolation; and for any value that has to cross a shell boundary — into ssh, into bash -c, into a YAML run: block — I run it through the Shell Escape tool first and paste the escaped form. It applies the same printf %q-style rules in the browser, so the string that lands in the script is literal-safe on the other side. For the metacharacter and expansion rules themselves, I keep the Bash cheatsheet open in the next tab; checking ${var@Q} syntax takes ten seconds and rewriting a corrupted release directory takes an evening.

None of this is clever. That's the point — deploy scripts are the last place you want clever quoting. Quote everything, escape at the boundary, and let the boring version run at 2 a.m.


Made by Toolora · Updated 2026-07-02