A command that exits 0 without printing anything can mean two things that look identical from the outside. The script ran and genuinely found nothing, or the script never ran at all. On 13 October 2026 I got the second kind, from an ssh call to a production server that looked perfectly ordinary. The host alias below is swapped for a neutral one, and only the shape of the call matters here, not the script inside it.
ssh -n prod 'bash -s' <<'EOF'
# ... the script bash on the server was supposed to run ...
EOFThe call exited 0 and printed not a single line, which reads like "nothing found". In reality the remote bash never received the script. What it read was /dev/null.
This is the opposite of the ssh that hangs at a password prompt because DISPLAY is still set, which I wrote about separately. There, ssh never finishes, so at least something is visibly wrong. Here ssh finishes quickly and cleanly, without a single sign that the work was never done.
-n stops the heredoc before it leaves
The ssh manual page opens its entry for the flag with this: -n "Redirects stdin from /dev/null (actually, prevents reading from stdin)." The heredoc in that command is attached by the local shell as the stdin of the ssh process. Once -n is there, ssh does not read stdin at all, so the script inside the heredoc stays on the laptop and not one line of it is sent to the server.
On the server side, bash -s means bash reads its commands from stdin. The stdin it gets is empty, so it hits end of file immediately, runs zero commands and exits successfully. The same manual page says ssh exits with the exit status of the remote command, so that 0 is passed straight back to my terminal.
You can reproduce the server side on any machine, without ssh:
bash -s </dev/null
echo $? # 0, with nothing printed before itThere is no error message because nothing errored. Bash was handed an empty script and finished it correctly, so the 0 is honest. What was wrong was the assumption that a script had arrived.
Where -n actually belongs
The flag is not wrong, it is just in the wrong place. The rule I wrote into the runbook that day limits it to one situation: use -n only when ssh runs inside a local heredoc script and must not eat the rest of it.
Here is that situation. When a local script is fed to bash through a heredoc, the script itself is bash's stdin, and every command inside it inherits that same stdin. The ssh command is a greedy reader of stdin, because its whole job is to forward stdin to the command on the other end.
# simplified: a local script fed to bash through a heredoc
bash <<'EOF'
ssh -n prod 'uptime'
echo "this line belongs to the local bash"
EOFWithout -n, the ssh on the first line swallows the echo line as its own stdin, and the local bash never gets to it. You can see the effect without a server by replacing the ssh line with cat >/dev/null: the echo line never runs.
The 13 October call was the reverse case. The heredoc did not belong to the local bash, it was the script for the remote side, so ssh had to carry it. The same rule spells out that half: drop -n when the heredoc is meant for the remote side.
ssh prod 'bash -s' <<'EOF'
# ... the script for bash on the server ...
EOFThe logic applies to anything on the server that reads a script from stdin, not just bash -s. The same runbook section holds a read-only example that streams a PHP script through a heredoc into php -d display_errors=1 /dev/stdin on the server, with -o BatchMode=yes and an ed25519 key, and that example has no -n.
What changed on 13 October was the rule, not a script that got deployed. My notes do not record the result of rerunning that call without -n, so I cannot say whether there was actually anything to find on the server that day.
2>/dev/null on a write
Misleading silence does not only come from ssh. Another of my notes describes a wp eval-file run with 2>/dev/null that printed nothing. The script had in fact fatalled on a PHP 8 TypeError, count() called on an iterator, and the fatal happened before the script touched anything.
Since PHP 8.0, count() on a value that is neither an array nor a Countable object throws a TypeError, and an iterator is not automatically Countable. With stderr thrown away, a script that dies before touching anything and a script that finishes without needing to say anything produce the same screen, an empty one. For a write, that empty screen can read as a change that went through, when the change never started.
The rules recorded alongside it are two. Never hide stderr on a write. If there is noise in the way, filter those noise lines with grep -v instead of discarding stderr wholesale.
That noise is real. On the server from the ssh -n incident, wp option get home printed a third-party [ERROR] line before the correct answer. Lines like that are what gets filtered, not the whole stream.
The shell on the other end was sh
The third note comes from forensic work on a WordPress site, work that started from one question: who was behind a broken header. It keeps a list of dead ends, and one entry reads: the remote shell is sh, not bash.
That entry matters because the program that finally runs the command string you hand to ssh is not the shell on your laptop. The sshd manual page says so plainly: "All commands are run under the user's login shell as specified in the system password database." If the login shell on the server is sh, any bash syntax tucked into that command is read by sh.
In that note, three things inside a piped loop silently produced garbage: $'\t', read with a tab IFS, and date -d @$var. No error, just wrong data.
$'\t' is how bash spells a tab character. A shell that does not know the form does not reject it, it reads the characters as they are. I did not record which sh was running on that server, so the example below uses a dash that does not know the form, as a general illustration rather than a capture from that machine.
# general illustration on a laptop, not a capture from any server
cat > split.sh <<'EOF'
printf 'first\tsecond\n' | while IFS=$'\t' read a b; do echo "a=$a b=$b"; done
EOF
bash split.sh # a=first b=second
dash split.sh # a=firs b=<TAB>secondIn that dash, $'\t' becomes three characters: a dollar sign, a backslash and the letter t. The IFS now contains the letter t, read cuts the line at that letter, and both commands exit with status 0.
The rule recorded for this case moves the work out of the shell: format timestamps in SQL with FROM_UNIXTIME, or in PHP through wp eval. The same note's list of sources that worked does use FROM_UNIXTIME(option_value) in SQL, to read the time from a transient's _timeout_ row minus its TTL. That row's value is the Unix second at which the transient expires, so subtracting the TTL gives the moment the transient was written, and the program turning it into a date is MySQL, not a shell.
Rows affected: -1 from an empty string literal
Another entry in the same list of dead ends: REPLACE(url,"...","") with an empty "" literal inside an ssh-quoted wp db query came back as Rows affected: -1. Every time.
The note does not record the cause, and I am not going to guess at it here. What it does record is the replacement: use SUBSTRING(url, 24).
That replacement expression contains no string literal at all, only a column name and a number. MySQL counts character positions from 1, so SUBSTRING(url, 24) returns the column from the 24th character on, which means dropping 24 minus 1, or 23, leading characters, whatever they are. It is only equivalent to the REPLACE above if the text being removed always sits at the start of the column and is exactly 23 characters long. REPLACE would also remove the same text if it appeared again further in, while SUBSTRING only cuts off the head. My notes keep the rule, not the result of the replacement query.
Reading silence from a server
All four cases share a shape. The far end answers with something answer-shaped: exit 0 with no output from a script that never arrived, an empty screen from a write that died before it started, garbage with no error from a shell that is not bash, and minus one from a query that does not explain itself.
In the first two, the programs even worked correctly. Bash ran the empty script it was given and reported success, and PHP 8 stopped at count() on an iterator exactly as it should. What went missing was the channel the information travels on: a script that was never sent, and a stderr that was thrown away. Before reading silence as "nothing", make sure the command had a chance to speak: the script really arrived, stderr is visible, and the shell reading it is the shell you think it is.