ToyTools Guide

How Shell Quoting Works Across ssh, sudo and docker

Why each shell in the path eats one level of quoting, and how to work out how many levels a command needs.

6 min read Updated Aug 2026

Quick Answer

A command needs one level of quoting for every shell that reads it before the last one runs it. Over ssh that is one level, because the local shell reads your line and the remote shell reads what arrives. Wrapping the far side in sudo sh -c or docker exec sh -c adds another shell and another level. Wrap the command in single quotes once per level, replacing each embedded ' with '\''.

Try the Shell Quote Escalator →

Why One Set of Quotes Is Not Enough

A shell does two things to every line it reads: it removes one level of quoting, then it splits what remains on whitespace into arguments. Those two steps happen again in full at every shell along the path, and that is the whole source of the confusion.

Three mechanisms do all the work: shell word splitting, parameter expansion, and POSIX single quoting. Remote command execution over ssh is nothing more than shell quoting repeated once per hop, and counting those quoting layers is the entire skill. Nested shell quoting by hand is where it goes wrong, because shell escaping gives no feedback until the command has already run somewhere.

Take ssh host 'echo hi'. Your local shell strips the single quotes and hands ssh three arguments. ssh joins everything after the hostname back into one string and sends echo hi to the server, where the remote shell parses it again and runs echo with the argument hi. One level of quoting was spent getting the text past the local shell.

Now the command itself contains quotes: you want the server to run echo 'a b' so that echo receives one argument. The remote shell has to see echo 'a b' literally, so that string is what must survive your local shell, so it needs quoting of its own. The line you type becomes ssh host 'echo '\''a b'\'''. Nothing about this is arbitrary, but nobody derives it correctly at three in the morning.

How To Count The Layers

Count the shells between your keyboard and the process, then subtract the one that finally executes the command. Each remaining shell costs one level of quoting, and nested shell quoting is simply that count applied honestly.

  • One level: ssh host CMD, sudo sh -c CMD, or bash -c CMD run locally.
  • Two levels: ssh host 'sudo sh -c CMD', or ssh into a host and then docker exec c sh -c CMD.
  • Three levels: ssh, then sudo, then into a container.

A common near-miss is assuming ssh itself consumes a level. It does not: ssh is not a shell, it just concatenates its remaining arguments and ships the text. The shells are what cost you, and ssh only decides which shell reads it next.

Single Quotes vs Double Quotes For Remote Commands

Inside single quotes a POSIX shell expands nothing whatsoever: no $VAR, no backticks, no $(...), no globs, no leading tilde. That total inertness is what makes the rule repeatable. Quoting a quoted string with the same rule again produces something that survives one more shell, every time, without you having to reason about what the previous layer did to it.

Backslash escaping and double quotes both fail that test. Under double quotes the shell still expands variables and command substitution, so the correct escape depends on what the previous layer already consumed, and the number of backslashes roughly doubles at each hop. That is the sequence people are counting by hand when they end up with eight backslashes and no idea whether the answer is seven or nine.

The one character single quotes cannot contain is a single quote, which is why each one is written as '\'': close the quoting, emit an escaped quote outside it, reopen. It looks alarming and it is mechanical.

Where Variables And Globs Actually Resolve

This is the part that silently does the wrong thing. Because single quoting protects $HOME, *, ~ and $(...) from every shell along the way, they arrive intact and are expanded by the last shell, against the far end environment and filesystem. Most of the time that is exactly right: you want ~ to mean the remote user home directory and * to match files on the server.

Occasionally it is the opposite of what you meant. If you wanted the local value baked into the command, substitute it yourself before quoting, or leave that fragment unquoted so your own shell expands it. The trap is that the quoted output looks identical either way, so nothing on screen distinguishes a command that will read the server home directory from one you believed would read yours. The tool prints a line naming the expandable tokens it found and which shell will resolve them, so the choice is visible while you can still change it.

Reading The Ladder

The tool shows every intermediate stage, not just the answer. The top rung is what you type, each middle rung is what the next shell receives after the one above it has parsed the line, and the bottom rung is what actually executes. A correct result is one where the bottom rung is character for character the command you intended.

This matters because a wall of apostrophes and backslashes is unreadable, so an answer on its own can only be trusted or not. The ladder turns it into something checkable: if the bottom rung is missing a quote, or a path has come apart into two words, you can see the exact stage where it happened rather than discovering it from a stack trace on the server.

Worked Examples

Three shapes cover almost everything. For example, the most common one is a path containing a space, which behaves correctly on your own machine and comes apart the moment a second shell is involved.

One layer: a command with spaces over ssh

Input: touch '/var/log/my app.log' is what the server has to run.

Output: ssh host 'touch '\''/var/log/my app.log'\'''

Quoting the whole thing once gets the text past your local shell. However, the inner quotes have to survive as well, because the remote shell splits on whitespace after it has removed its level. Miss that and the server runs touch with two arguments and creates two files.

Nested quotes: sudo inside ssh

Input: sudo sh -c 'echo hi > /etc/motd'

Output: ssh host 'sudo sh -c '\''echo hi > /etc/motd'\'''

Two shells read this before sh -c runs it, so the payload is quoted twice. Therefore the '\'' sequences multiply: each one closes the outer quoting, emits a literal apostrophe, and reopens it.

How to pass a command with spaces to docker exec

A container adds a shell like any other hop, so the rule does not change: a path with spaces needs its own quoting plus one round per shell above it. Input: docker exec web sh -c 'cat /etc/my app.conf'. Run straight from a remote shell that is one more layer out, the whole thing needs quoting again, which is what turns a one-space filename into a two-argument error.

Three layers: into a container on a remote host

Adding docker exec c sh -c on the far side adds a third shell and a third round of quoting. At this depth the string stops being readable, which is where the shell quote escalator earns its keep: rather than counting apostrophes, you read the bottom rung of the ladder and confirm it is the command you meant.

Shell Quote Escalator vs The Alternatives

Trial and error against the real host. The usual method, and the expensive one: every failed attempt executed something on a live machine, and a command that stops erroring is not the same as a command that is correct.

printf %q or Python shlex.quote. Both quote correctly for exactly one layer. They are the right tool when there is one shell, however the problem here is arithmetic across several, and neither knows how many are in the path.

Base64 the whole command. A real workaround that does work, because base64 output contains nothing a shell reacts to. The cost is that nobody reading the playbook or the CI log afterwards can see what runs, so a debugging problem is traded for an auditing one.

The Shell Quote Escalator does the same arithmetic the first two options leave to you, and unlike all three it shows the intermediate stages, so the result can be checked instead of trusted.

Common Mistakes

  • Testing against production. Adding a backslash and re-running the real command until the error stops means every failed attempt ran something on a real machine.
  • Quoting for one layer when there are two. The command usually still runs. It just runs a different command, which is why this surfaces days later.
  • Mixing quote styles mid-command. An unbalanced single quote inside a double-quoted section ends the wrong thing, and the rest of the line quietly becomes separate arguments.
  • Assuming a working local command works remotely. Locally there is one shell. Nothing about the command changed; the number of parsers did.

You May Also Need

You may also need

Next steps

Alternatives

Continue Learning