Node-RED for the Automations Home Assistant Can't Express
Where the YAML automation engine runs out of road, and what to do about it

Contents
I resisted Node-RED for two years on the grounds that I can write code and drawing boxes on a canvas felt like a step backwards. Then I spent an entire Sunday trying to express one automation in Home Assistant’s YAML and gave up somewhere around the fourth nested template.
The automation: when motion is detected in the hall, turn on the light — unless the sun is up, or someone has manually set the light in the last twenty minutes, or the “night mode” flag is on, in which case use 5% brightness. Turn it off two minutes after motion stops, unless the door sensor says someone came in and hasn’t left, in which case wait ten minutes. And if the light was manually turned on, never turn it off automatically at all.
Every clause is trivial. The combination is a state machine with memory, and Home Assistant’s automation engine has no comfortable way to say that.
That is the whole argument for Node-RED, and it is narrower than its fans suggest. If you want the general introduction, Node-RED for the rest of us covers the basics. This post is about the specific cases where it beats the built-in engine, and the cases where reaching for it is a mistake.
What Home Assistant’s engine is actually bad at
Home Assistant’s automations are a trigger, some conditions, and a sequence of actions. That model covers a genuinely enormous amount of ground, and 90% of what runs in my house is plain YAML because plain YAML is the right tool.
The model strains in four specific places.
State that outlives a run. An automation has no memory. To remember that a light was manually adjusted, you create an input_datetime helper, write to it from a second automation, and read it back with a template. You now have three objects implementing one concept, spread across two files and a UI-managed helper store, and nothing in any of them says why they exist.
Debouncing and rate limiting. A sensor that flaps produces a hundred triggers a minute. mode: single with max_exceeded: silent gets you a crude version, and for: on the trigger handles a simple settle time. Real debouncing — leading edge, then quiet for N seconds — takes a helper and some templates.
Branching that returns a value. choose: blocks nest, and nested choose: in YAML is where readability goes to die. Four levels deep, the indentation carries meaning no human is tracking accurately.
Anything that talks to a thing Home Assistant does not integrate with. An HTTP API with a custom auth header, a TCP socket, a scraped page. RESTful command entities exist and are fine for simple cases. Anything with a retry, a token refresh or a paginated response becomes a shell script wrapped in a command line sensor, which is a sentence that should worry you.
Node-RED handles all four natively, because it is a general-purpose flow engine with a JavaScript runtime underneath and Home Assistant is only one of the things it can talk to.
The setup
Node-RED wants a container and a persistent volume. The one wrinkle is authentication: leave it open and anyone on the network gets a JavaScript execution endpoint into your smart home.
| |
Then in data/settings.js:
| |
contextStorage is the setting people miss and later regret. Without it, everything a flow remembers lives in memory and evaporates on restart. With the file store declared, you can write flow.set("lastManual", Date.now(), "file") and the value survives a container update. Any flow whose whole reason for existing is memory needs this.
Then install the Home Assistant nodes from the palette manager (node-red-contrib-home-assistant-websocket) and give it a long-lived access token from your Home Assistant profile page. It connects over the same WebSocket API the frontend uses, so it sees every state change in real time with no polling.
The flow that started it
Here is the hall light logic as Node-RED sees it. An events: state node watches the motion sensor. Its output goes to a switch node checking the sun, then to a function node that holds the actual decision:
| |
That goes into a trigger node set to reset on a new message, then a call service node. A separate small flow watches the light entity for changes whose context.user_id is non-null — meaning a human did it — and writes flow.set("hallLastManual", Date.now(), "file").
The whole thing is about nine nodes. The YAML equivalent was three automations, two helpers and a template I could no longer read a week after writing it.
Two things in that function node are worth stealing. node.status() puts a live label under the node on the canvas, so the flow shows you its current reasoning while you watch it. And return null is how a function node says “stop here”, which is a clearer way to express a guard clause than any condition: block.
Drawing the boundary
The failure I have seen most often is a house where nobody can answer “where does this behaviour live?”. Half in YAML, half on a canvas, some in a template sensor, and the interaction between them discovered by experiment.
The rule I settled on: Node-RED owns a behaviour end to end, or it owns none of it. If the hall light logic is in Node-RED, then no Home Assistant automation touches that light. Ever. Splitting a single device’s control across two engines gives you two systems racing to decide, and the loser is whoever is standing in the hall.
Practically, that means a small number of Node-RED flows, each with a clear boundary, each owning a named set of entities. Everything else stays in YAML. I keep a comment node at the top-left of each flow naming the entities it owns, which sounds like bureaucracy and takes ten seconds and has saved me from exactly this confusion twice.
The other useful boundary is MQTT. When a flow needs to talk to something outside Home Assistant — a script on another machine, a display, a logging pipeline — publishing to the broker keeps the coupling loose. Node-RED publishes a decision; whatever cares subscribes. Nothing has to know Node-RED exists, and you can replace the flow with a shell script later without touching a consumer.
Where Node-RED is the wrong answer
I want to be firm about this, because Node-RED enthusiasm has a failure mode.
Simple automations belong in YAML. “Turn on the porch light at sunset” is one trigger and one action. Putting it in Node-RED buries it in a canvas nobody will find in two years, when the YAML version would be four lines in a file you can grep. I have watched people move their entire automation set to Node-RED and produce a canvas with two hundred nodes on it that nobody, including its author, can reason about.
It is a second thing to back up, and a harder one. Your flows live in data/flows.json, which is machine-generated JSON containing node coordinates. It diffs horribly. Moving a node six pixels produces a commit. You can version it and you should, though do not expect the history to tell you anything useful. Home Assistant’s YAML, meanwhile, diffs like code because it is code.
The credential secret will bite you. Node-RED encrypts stored credentials — your Home Assistant token, any API keys — with a key derived from NODE_RED_CREDENTIAL_SECRET, or a random one it generates and stores if you leave it unset. Restore a backup on a fresh host without that variable set to the same value and every credential in every flow is unrecoverable. Set it explicitly, from day one, and keep it with your other secrets — SOPS and age is where mine live.
It is a JavaScript execution engine on your network. Function nodes run arbitrary code as the container user. Anyone who reaches port 1880 without authentication owns your smart home and has a shell-adjacent foothold. adminAuth is mandatory, and the container has no business being reachable from the internet.
Troubleshooting
The Home Assistant nodes show “disconnected” and never recover. Usually the access token. Long-lived tokens are invalidated when the user that created them is deleted or their password changes; the node reports this identically to a network failure. Regenerate it and re-enter it in the server config node. If the token is fine, check that you used ws:// and the correct port — pointing at the wrong scheme fails in an unhelpfully quiet way.
A flow works when you click Deploy and stops after a restart. Context. You wrote to flow.set() without the "file" store argument, so it lived in memory. Add the store name, and confirm contextStorage is configured in settings.js.
Deploying breaks running timers. The default deploy mode restarts every flow, which cancels in-flight delay and trigger nodes. Switch the deploy dropdown to “Modified Nodes” and only what you touched restarts. This matters when you have a flow holding a ten-minute timer and you deploy an unrelated change.
Nodes go red with “Error: Maximum call stack size exceeded”. You have made a loop. Something feeds a state change back into the node that watches for state changes. The events: state node has an “if state” filter and an option to ignore events triggered by Node-RED’s own calls; use it, or gate the loop explicitly.
Everything is slow and the container uses a gigabyte of RAM. A flow is leaking. The usual culprit is an array in context that gets appended to and never trimmed — a “keep the last N readings” pattern where N was never enforced. The Node-RED debug sidebar has a context browser; look at it.
MQTT messages arrive twice. Two MQTT-in nodes subscribed to overlapping wildcards, or a flow republishing to a topic it also listens to. Node-RED makes both mistakes easy and neither is obvious on a canvas. If MQTT is new territory, MQTT explained for people who just want sensors sets out the topic model that prevents this.
Verdict
Node-RED is worth running if you have two or three automations that genuinely need state, and it is worth running for the API-glue jobs where Home Assistant’s REST integrations run out of expressiveness. The visual model is much better than I expected at showing the shape of a decision, and node.status() labels on a live canvas are a debugging experience YAML cannot match.
It is the wrong tool for the other ninety percent. Sunset triggers, scheduled scripts, straightforward “if this then that” — those belong in YAML, where they are greppable, diffable, and obvious. The install where Node-RED made things worse is always the one that migrated everything.
My canvas has eleven flows on it. Home Assistant has about seventy automations. That ratio is roughly right, and the eleven are the ones I would otherwise still be fighting with on a Sunday.




