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 · Updated October 13, 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.

Ways to pass awkward text to a shell command

ApproachRobust against quotes and umlautsNotes
Text directly in the commandNoBreaks on the first quote in a title
Base64 built in a Code nodeYesWhat the live workflow does now
Write a JSON file, pass the pathYesGood for long or structured input
Script fetches the data itself by idYesNo text crosses the shell at all

The last option is often the cleanest. The caption script on one of these pipelines receives only a task id and reads the script text from the render service's own API, so there is nothing to escape.

Base64 helpers in expressions

Newer n8n versions document string helper functions for expressions, including base64Encode() and base64Decode(), as in {{ $json.topic.base64Encode() }}. If your version has them, they are the intended way to do this in a parameter field. I still build the value in a Code node, because the Code node's output can be inspected in the execution and referenced by name, and because the inline Buffer call failed without any error on this instance. Whatever you use, test it on a scratch workflow with a value containing quotes and umlauts before relying on it.

Verify the file before you record it

The queue row claimed a thumbnail because it was written from a string, not from the disk. The check that would have caught both bugs is one line at the end of the thumbnail command:

python3 make_thumbnail.py "$TOPIC_B64" "$OUT" && test -s "$OUT" && echo OK

followed by an IF node that only continues when the SSH node's exit code is 0. A missing or empty thumbnail then stops the run at the step that caused it, instead of failing days later in the publisher.

Frequently asked questions

Why does Buffer.from().toString('base64') return an empty string in an n8n expression?

On this instance the call returned an empty string in Set and SSH parameter fields but worked in a Code node. Build the value in a Code node and reference it by name.

Why did the script fail with 'Incorrect padding'?

The empty argument disappeared from the shell command, so the script decoded the output path as if it were the base64 topic.

Why did {{ $json.topic_b64 }} also come back empty?

$json is the output of the node directly before. Here that was another SSH node, whose output only has code, signal, stdout and stderr.

How do I make sure a generated file really exists?

End the command with test -s on the file, and route on the SSH node's exit code before writing the path anywhere.

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.