n8n logon8n Automation Hub
Debugging notes

My SSH Node Got an Empty Body Because a Sheets Update Sat Between It and the Data

Debugging notes · n8n workflow · September 6, 2026

A render pipeline had been failing for a day with the same "field required" error on every attempt. The value it needed had been built correctly, several nodes earlier — the SSH node just wasn't looking in the right place for it anymore.

Workflow at a glance
  1. A daily video-render workflow builds a JSON request body (base64-encoded) in a Code node, then needs that value later at an SSH node that starts the render via curl -d "$(echo '{{ … }}' | base64 -d)"
  2. A Google Sheets "update" node was inserted between the two, to mark the source row as claimed the instant rendering starts
  3. The SSH node's command still read {{ $json.field }} — a reference to whatever node feeds it directly, which was now the Sheets update node
  4. A Sheets "update" operation only returns the columns it actually wrote (here: row_id, status) — every other field from earlier in the run, including the one the SSH node needed, was simply gone
  5. Result: the render API received an empty request body and rejected it with 400 Field required, on every single attempt, silently
  6. Fix: point the SSH node at the node that actually built the value — {{ $('Code Node Name').item.json.field }} — instead of the implicit, position-dependent $json

The same error, four times in a row, no obvious cause

The workflow renders a short video once, three times a day: read the next topic from a queue, build the render request as JSON, base64-encode it so quote characters in the topic text can't break a shell command, then SSH into the render service and POST it. That base64-wrapping step had already been hardened once before, specifically to survive exactly this kind of shell-quoting failure — so when the render started failing again with a completely different error, shell quoting wasn't the first suspect.

Every failed run showed the same thing: the render service's own API rejected the start-render call with {"status":400,"data":[{"type":"missing","loc":["body"],"msg":"Field required"}]}. Not malformed JSON — no body at all. The task ID never got created, every status poll afterward came back 404, and after enough retries the workflow gave up and moved on, one queue item short.

Reproducing it by hand ruled out the obvious suspects

Re-running the exact SSH command manually, with a hand-built payload, worked fine — a real render task started immediately. That ruled out the render service itself, the SSH transport, and the base64 round-trip. Whatever was failing lived entirely on the n8n side, in how the command's expression resolved at runtime versus by hand.

The command referenced {{ $json.mptRequestBodyB64 }}. In n8n, a bare $json inside an expression doesn't mean "somewhere earlier in this execution" — it means specifically the JSON output of whichever node feeds directly into the current one. Reading the workflow's actual node graph (not the mental model of it) showed the SSH node's direct input wasn't the Code node that built mptRequestBodyB64 at all. A Google Sheets "update" node sat in between, added in an earlier fix to mark each topic as claimed the moment rendering starts, rather than only after a successful upload.

An update node's output is not a passthrough

The assumption that broke here is a natural one: that inserting a node "in the middle" of a chain just adds a side effect, and whatever was flowing through before keeps flowing through unchanged. A Google Sheets update operation doesn't work that way — its output is the row as n8n understands it was written, built from exactly the columns named in the node's own field-mapping configuration. Here, that mapping only set row_id and status. Every other field that had been part of the item a moment earlier — the topic text, the title, and the base64 request body built by an even earlier Code node — simply wasn't part of the update node's own output, because it was never asked to write those columns anywhere.

So the SSH node's $json.mptRequestBodyB64 resolved to undefined on every run, every time, since the day that Sheets node was inserted. echo undefined | base64 -d produces nothing usable, so the curl call went out with an effectively empty -d argument — which is exactly what the render API's "field required" response was reporting, accurately, the whole time.

The fix, and why it's the more durable one

The correct reference doesn't ask "what does my direct input look like" — it asks "which node actually produced this value," by name: {{ $('Code Node Name').item.json.mptRequestBodyB64 }}. That resolves the same way regardless of what gets inserted between the two nodes later, because it isn't relative to position in the chain at all. Re-running the workflow with that one change produced a real task ID on the first attempt.

The broader lesson isn't specific to SSH nodes or Sheets updates. Any node — Code, HTTP Request, Set, a database write — that returns a deliberately narrowed or reshaped item can silently break a downstream $json reference that used to work only because nothing sat in between. A reference built with $('Node Name') costs one extra function call to write and survives every future edit to the graph around it; a bare $json reference is a bet that the node graph never changes shape upstream, which is a bet that stops paying off the first time someone (including a past version of the same person) inserts exactly the kind of node this pipeline needed for an unrelated fix.

An n8n workflow that fails the same way on every attempt, with no obvious change to explain it?

I debug production n8n workflows for a living — including the reference and data-shape bugs that don't throw until several nodes downstream.