n8n logon8n Automation Hub
Debugging notes

The Thumbnails Never Existed: Base64 in an n8n Expression Came Back Empty, Twice

Debugging notes · n8n workflow · September 19, 2026

Every video a new channel pipeline rendered had its video file and no thumbnail. The Google Sheets queue row said otherwise, because the thumbnail path column was filled in every time. Two separate bugs were stacked on top of each other, and both left n8n's execution log green.

Workflow at a glance
  1. A render workflow finishes a video, then an SSH node runs a Python script that builds a thumbnail. The video topic goes in as a base64 argument so quotes and umlauts can't break the shell command
  2. First cause: the base64 was computed inline in the SSH node's command field with Buffer.from(...).toString('base64'). That expression evaluated to an empty string
  3. The shell dropped the empty argument, so the script read the output path as the topic and died with binascii.Error: Incorrect padding
  4. Second cause: after moving the encoding into an upstream Code node, the SSH node still read {{ $json.topic_b64 }}, but $json was the output of the SSH node just before it, not of the Code node
  5. Fix: encode in a Code node, then reference that node by name: {{ $('Parse Task Status').item.json.topic_b64 }}

The queue said the thumbnail existed. The disk said otherwise.

The pipeline renders a video, runs a caption pass, then calls a small Python script over an SSH node to build a thumbnail from the video's topic, and finally appends a row to a Google Sheets render queue. That row has a thumbnail_path column. The column is written from a string the workflow builds, not from checking that the file exists, so it was filled in on every row whether or not a thumbnail had been produced.

I only noticed because I listed the task folders by hand after a batch of test renders. One long video and four shorts had their video files and no thumbnail.png at all. The same command had been copied from one channel's workflow into a second channel's, so both pipelines had the problem.

Bug one: Buffer inside an expression field

Topics can contain quotes and umlauts, so the topic is passed to the script base64-encoded, which keeps the shell command safe. The encoding was done inline, in the SSH node's command field:

python3 make_thumbnail.py {{ Buffer.from($json.topic).toString('base64') }} /path/to/thumbnail.png

That expression evaluates to an empty string. I re-checked it on a throwaway workflow so this wasn't just a memory:

typeof Buffer                                               -> "object"
Set node, {{ Buffer.from('hallo').toString('base64') }}     -> empty
SSH node command, same expression                           -> empty
Code node, Buffer.from('hallo').toString('base64')          -> "aGFsbG8="

The global exists, but the encoded string never comes out of an inline expression. The identical call works inside a Code node. I don't know which layer of n8n's expression handling drops it, so I treat it as a rule: no Node.js encoding calls in parameter fields, only in Code nodes.

Why the error said "Incorrect padding"

With the expression empty, the command became python3 make_thumbnail.py /path/to/thumbnail.png. The shell collapses the empty gap, so the script's first argument was the output path, which it then tried to base64-decode as the topic. The traceback had nothing to do with an encoding library being missing:

TOPIC = base64.b64decode(sys.argv[1]).decode("utf-8")
binascii.Error: Incorrect padding

n8n's SSH node returns the exit code and stderr as data rather than raising an error, which is why the execution stayed green. The article on exit code 124 covers that behaviour and why a dedicated exit-code check node is worth adding.

Bug two: the fix that failed on the next scheduled run

The obvious fix was to compute the base64 in an upstream Code node, where Buffer works, and reference the new field in the SSH command as {{ $json.topic_b64 }}. That went into every affected workflow. A scheduled run of the second pipeline on September 17 then produced exactly the same traceback, even though the workflow snapshot stored with that execution contained the new field.

The reason is what $json means: the item arriving at the current node, which is the output of the node right before it. In this workflow the thumbnail node follows another SSH node (the caption burn), and an SSH node's output is only code, signal, stdout and stderr. Neither topic_b64 nor the task_id used later in the same command existed there. A chain of Code node, SSH node, SSH node on a scratch workflow shows it directly:

{{ $json.topic_b64 }}                          -> ""
{{ $('Make Field').item.json.topic_b64 }}      -> "aGFsbG8="

It's the same family of problem as a Sheets update node sitting between a value and the node that reads it, with a different node in the middle.

The fix

Encode in a Code node, then reference that node by name in everything downstream:

// Code node "Parse Task Status"
const topic_b64 = Buffer.from(prev.topic || '', 'utf-8').toString('base64');
return [{ json: { ...prev, topic_b64 } }];

// SSH node command
python3 make_thumbnail.py {{ $('Parse Task Status').item.json.topic_b64 }} /path/{{ $('Parse Task Status').item.json.task_id }}/thumbnail.png

Two habits would have caught both bugs earlier. Search the instance for Buffer. in any node that isn't a Code node. And write the queue row's file path only after a check that the file exists, for example a follow-up command ending in test -s thumbnail.png whose exit code is checked, so a missing file stops the run instead of surfacing at publish time.

A pipeline that reports success while quietly skipping steps?

I debug production n8n workflows for a living, including the failures that never turn a single node red.