Skip to content
FreeToolNet
Go back

n8n 2.x "Unrecognized node type" for Execute Command: use a sidecar

I had a workflow that needed to run a command-line tool on the server. I added an Execute Command node, deployed the workflow, and activation failed with “Unrecognized node type”.

The node is not missing. n8n 2.x ships with it switched off. I decided to leave it off and call a small internal HTTP service instead. This post covers why the error happens, the two ways out, and the sidecar setup I ended up with.

Why the node is “unrecognized”

I run the stock n8nio/n8n:2.45.5 image. In n8n 2.x, the node configuration in the @n8n/config package (nodes.config.js) has a default exclude list:

exclude = ["n8n-nodes-base.executeCommand", "n8n-nodes-base.localFileTrigger"];

An excluded node type is not loaded at all. So when a workflow that contains n8n-nodes-base.executeCommand is activated, n8n reports the type as unrecognized. The message reads like a typo in the node type or a missing community package. It is neither.

Two ways out

Option 1: turn the node back on. The NODES_EXCLUDE environment variable overrides the default list. Set it on the n8n container and Execute Command is available again. The variable is described in the n8n nodes environment variables docs.

Option 2: leave it off and put the shell work somewhere else. This is what I did.

My reason is simple. With the node excluded, no workflow on that instance can ever run a shell command on the server. That is a strong property, and I did not want to trade it away for one workflow. My n8n has a hosted form that takes user input. I would rather that input never has a path to a shell.

My first attempt was also wrong

My first attempt was a custom n8n image with the command-line tools baked in, so that Execute Command could call them. Even with the node enabled, this was the wrong direction:

  • The n8n image is hardened and has no package manager, so adding tools to it is awkward.
  • Every n8n upgrade would have meant rebuilding my own image.
  • The tools would run inside the same container that holds my credentials.

I deleted that Dockerfile. n8n is back on the stock image, pulled by tag.

The sidecar

The replacement is a small FastAPI service in its own container. It is built from python:3.12-slim, which has a normal package manager, so the tools install directly. It runs the tool with a fixed argument list and no shell, so a value from n8n cannot inject anything into a command line.

It exposes three routes:

  • POST /download with {url} does the job and returns {token, filename, size}.
  • GET /file/{token} streams the result file.
  • DELETE /file/{token} removes it.

There is also GET /healthz. Anything left behind is cleaned up after an hour.

The job is slow and the result is a large file. That shaped the API: the job call returns a small JSON answer, and the bytes are fetched in a separate call.

Put both containers on one private network

n8n and the sidecar share a user-defined Docker network. The sidecar publishes no ports. This is from my setup script, trimmed to the relevant lines:

NET=automation

# Private network shared by n8n and Obscura
docker network inspect $NET >/dev/null 2>&1 || docker network create $NET >/dev/null

docker run -d --name n8n --restart unless-stopped --memory 2g \
  --network $NET -p 127.0.0.1:5678:5678 --env-file /etc/n8n.env \
  -v /srv/n8n/data:/home/node/.n8n $N8N_IMG >/dev/null

docker run -d --name downloader --restart unless-stopped --memory 1g \
  --network $NET -v /srv/downloader/tmp:/tmp/dl $DL_IMG >/dev/null

Three details matter here:

  • n8n binds to 127.0.0.1:5678 only. nginx on the host proxies the public hostname to it.
  • The sidecar has no -p flag at all. It cannot be reached from the host’s public interface or from the internet. Inside the network, Docker’s DNS resolves the container name, so n8n calls it at http://downloader:8080.
  • The sidecar’s temp directory is a host mount, so large files do not grow the container’s writable layer.

The same script checks the path from inside the n8n container after each run:

echo "== downloader (n8n -> downloader over docker net): $(docker exec n8n wget -qO- http://downloader:8080/healthz 2>&1 || echo UNREACHABLE)"

If n8n has to reach a service that lives on a different Docker network, you can attach n8n to a second network. I do that for another internal app:

if docker network inspect autoclip-network >/dev/null 2>&1; then
  docker inspect -f '{{json .NetworkSettings.Networks}}' n8n | grep -q '"autoclip-network"' || docker network connect autoclip-network n8n
fi

Calling it from n8n

On the n8n side, the Execute Command node became three HTTP Request nodes that talk to the sidecar, around the upload to the destination. I generate my workflows from a Python script, so this is the Python that produces those nodes. http() is a helper that returns an HTTP Request node, and DOWNLOADER is "http://downloader:8080":

# Not retried, so a hiccup never starts a second download; failures surface on the form and by email.
http("Request download", [1540, 0], "POST", f"{DOWNLOADER}/download",
     json_body="={{ JSON.stringify({url: $('Validate').first().json.url}) }}", timeout=3600000),
# ... a "Start upload" node opens a resumable upload session at the destination API ...
# Fetch the bytes right before the PUT: an http node does not carry input binary to its output.
http("Fetch bytes", [1980, 0], "GET", f"={DOWNLOADER}/file/{{{{ $('Request download').first().json.token }}}}",
     file=True, timeout=1800000),
http("Upload bytes", [2200, 0], "PUT", "={{ $('Start upload').first().json.headers.location }}", "drive",
     binary_body=True, timeout=1800000),
http("Delete temp", [2420, 0], "DELETE", f"={DOWNLOADER}/file/{{{{ $('Request download').first().json.token }}}}",
     onError="continueRegularOutput"),

In the editor, the same settings are: Response Format “File” on the fetch node, Body Content Type “n8n Binary File” on the upload node, and a long timeout under Options.

Gotchas

Node order matters for binary data. An HTTP Request node does not pass its input binary through to its output. If you fetch the file, then call another API, then try to send the bytes, the binary is gone by the third node. So the order is: start the upload session first, fetch the bytes, and send them in the very next node. Values from earlier nodes are still reachable with $('Node name').first().json.

Raise the timeout on slow calls. The job call can run for minutes. I set its timeout to 3600000 ms and the two transfer nodes to 1800000 ms.

Do not retry the job call. The other nodes have retryOnFail with three tries. The job call has none. If the connection drops while the sidecar is still working, a retry would start the same job a second time.

Make cleanup best-effort. The delete node has onError="continueRegularOutput". A failed delete should not fail a run that already did its work, and the sidecar cleans up old files on its own anyway.

Keep large binaries on disk. I do not want large files held in the n8n container’s memory, and that container is capped at 2 GB. So my setup script adds this to the n8n env file:

grep -q '^N8N_DEFAULT_BINARY_DATA_MODE=' /etc/n8n.env || echo 'N8N_DEFAULT_BINARY_DATA_MODE=filesystem' >> /etc/n8n.env

Changing only the env or mounts does not recreate the container. My script recreates a container when its image ID changes. If I only edit the sidecar’s env file, I have to remove the container by hand first:

docker rm -f downloader && bash vps/setup.sh

What I would do again

I would pick the sidecar again. It costs one more container and a few more nodes per workflow. In return, n8n stays on the stock image, upgrades are a one-line tag change, and the instance still has no node that can run a shell command. A second workflow now calls the same sidecar, and it needed no changes on the server.

If your command is trivial and your n8n is not exposed to anyone else’s input, NODES_EXCLUDE is the shorter path. Just know that it is a decision about what every workflow on the instance may do, not only the one you are building.

How this post was made

Written with AI assistance and reviewed by the author before publishing. See the editorial policy.

Commands and output were captured on 2026-11-01 on Self-hosted n8n in Docker (SQLite) on a VPS behind nginx, with a FastAPI sidecar container on the same private Docker network (n8n 2.45.5 (n8nio/n8n:2.45.5), python:3.12-slim, fastapi 0.115.6, uvicorn 0.34.0).



Previous Post
Generate n8n workflows from a Python script and deploy by API
Next Post
n8n form submitted by script: values are null, use field-0 keys