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
| Approach | Robust against quotes and umlauts | Notes |
|---|---|---|
| Text directly in the command | No | Breaks on the first quote in a title |
| Base64 built in a Code node | Yes | What the live workflow does now |
| Write a JSON file, pass the path | Yes | Good for long or structured input |
| Script fetches the data itself by id | Yes | No 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.
n8n Automation Hub