Installing Node-RED

Node-RED, back to the Cold War?#


Node-RED is a product from IBM Laboratories. It lets us create and manage workflows by interconnecting “nodes”.

A node connection can be a simple input –> output flow, or it can be combined with logic-processing nodes, other workflows…

Let your imagination run wild, according to your needs!

This article was originally written in 2020. Since then, Node-RED has evolved… and so has our installation. Rather than hiding the old approach, we are going to reconcile it with what now runs on our OpenBSD test server.

The goal remains the same: install Node-RED cleanly on OpenBSD. This time, however, we will also check each layer before adding the next one.

Objectives#

We will keep the following principles:

  1. Node.js is installed and maintained through OpenBSD packages;
  2. Node-RED runs as the dedicated _node-red user;
  3. the persistent directory is /data/node-red;
  4. Node-RED is installed globally, while application modules remain local to the userDir;
  5. Node-RED is managed as a proper OpenBSD service with rc.d and rcctl;
  6. sensitive files are protected by default with umask 077;
  7. the credentialSecret is explicitly controlled and kept separate from the Node-RED assets;
  8. Node-RED listens only on 127.0.0.1:1880.

Previously, Node-RED handled HTTPS itself. Today it simply stays on HTTP over loopback; we will entrust secure publication to relayd.

We will come back to that choice. It deserves a little explanation. :)

Prerequisites#

A platform running OpenBSD.

The installation described here was actually rebuilt and tested on our OpenBSD test server with the following versions:

OpenBSD 8.0 arm64
Node.js v24.21.0
npm 11.19.0
Node-RED v5.0.7

Versions will change. The method is more interesting than the exact package number.

Installation#


Installing Node.js#

Let us first see which versions are available:

pkg_info -Q node

On our OpenBSD test server, the qualified version was:

node-24.21.0v0

We install it with the OpenBSD package manager:

doas pkg_add node-24.21.0v0

Then we check what was actually installed:

pkg_info | grep '^node-'
node --version
npm --version
npx --version

The result obtained during the rebuild was:

Node.js v24.21.0
npm     11.19.0

Our KISS rule: Node.js and npm remain under the responsibility of OpenBSD packages.

So we are not going to casually replace the npm supplied by OpenBSD with an npm install -g npm@....

Installing Node-RED#

Node-RED is our application runtime. We install it globally:

cd /home/ollie
doas npm install -g node-red@5.0.7

Then:

node-red --version
ls -l /usr/local/bin/node-red
npm list -g --depth=0

On our OpenBSD test server:

Node-RED v5.0.7
Node.js v24.21.0
OpenBSD 8.0 arm64 LE

What about node-red-admin?#

In the first version of this article, we installed this separately:

doas npm install -g node-red-admin

That is no longer necessary with our current version of Node-RED.

The command used, among other things, to generate a password hash is now directly available with:

node-red admin hash-pw

We will use it a little later.

The userDir: /data/node-red#

The service account used on our OpenBSD test server is:

_node-red:_node-red

with /data/node-red as the persistent directory.

This is where we keep, among other things:

flows.json
flows_cred.json
settings.js
.config.runtime.json

These four files do not have the same role.

flows.json contains the definition of our flows and properties that are not managed as credentials.

flows_cred.json contains the credentials associated with the flows, encrypted by Node-RED.

settings.js contains the runtime configuration.

And .config.runtime.json is also part of the persistent assets of the instance.

On our installation, we ultimately qualified these files with owner _node-red:_node-red and mode 0600:

doas chown _node-red:_node-red \
  /data/node-red/flows.json \
  /data/node-red/flows_cred.json \
  /data/node-red/settings.js \
  /data/node-red/.config.runtime.json

doas chmod 600 \
  /data/node-red/flows.json \
  /data/node-red/flows_cred.json \
  /data/node-red/settings.js \
  /data/node-red/.config.runtime.json

Why insist on this?

Before the rebuild, several operational files had been observed as 0644, including flows.json, flows_cred.json, .sessions.json, .config.users.json and some backups. settings.js had also been observed as 0660.

We did not establish what had created each of those modes. There is no point inventing a culprit.

We can, however, do better from now on.

Installing application modules#

The Node-RED runtime is global, but the modules used by our flows remain local to /data/node-red.

During the rebuild of our OpenBSD test server, four directly used modules were reintroduced:

node-red-contrib-bigtimer        2.8.6
node-red-contrib-smstools        1.1.1
node-red-contrib-watchdirectory  1.0.15
node-red-dashboard               3.6.5

We install them as the service account and with a restrictive umask:

doas -u _node-red sh -c '
cd /data/node-red &&
umask 077 &&
npm install --save-exact \
  node-red-contrib-bigtimer@2.8.6 \
  node-red-contrib-smstools@1.1.1 \
  node-red-contrib-watchdirectory@1.0.15 \
  node-red-dashboard@3.6.5
'

Then:

doas -u _node-red sh -c '
cd /data/node-red &&
npm ls --depth=0
'

The umask 077 is not there just to decorate the command.

By default, it ensures that a file created by the process grants no permissions to the group or other users. Since Node-RED and npm can create or modify flows, credentials, sessions, runtime files, package.json, package-lock.json, node_modules and files produced by modules, we may as well start from the principle of least privilege.

We will use the same principle again in our rc.d service.

First start: safe mode#

At this point we have Node.js, Node-RED, our assets and the modules.

We could start everything immediately.

We are not going to. :)

Node-RED has a safe mode (--safe) that loads the runtime, configuration, flows, credentials and modules without executing the flows.

Exactly what we need: check one layer before moving on to the next:

doas -u _node-red sh -c '
cd / &&
umask 077 &&
exec /usr/local/bin/node-red \
  --safe \
  --userDir /data/node-red \
  --settings /data/node-red/settings.js
'

During the rebuild, this start loaded Node-RED 5.0.7, our four local modules, settings, flows and dashboard without a blocking error. The flows themselves remained stopped.

We also checked the listener:

netstat -an -f inet | grep '\.1880 '
doas fstat | grep ':1880'
ftp -Vo /dev/null http://127.0.0.1:1880/

Node-RED listens on:

127.0.0.1:1880

We can then cleanly stop this instance with Ctrl-C.

flows_cred.json and credentialSecret#

Safe mode also taught us something interesting.

Our flows_cred.json was already encrypted and Node-RED could read it perfectly well. The issue was therefore not converting clear-text credentials into an encrypted file.

No explicit credentialSecret had been defined. Node-RED was using an automatically generated key, with one simple risk: losing that key meant risking access to the encrypted credentials.

We therefore explicitly defined our credentialSecret, keeping it separate from the Node-RED assets.

The secret is stored in:

/etc/node-red-secret-settings

with:

owner = root:_node-red
mode  = 0640

Its contents obviously remain secret. :)

A first attempt using the form credentialSecret=... reminded us of a simple rule: test what we write.

Node-RED rejected it:

Error processing -D option: /etc/node-red-secret-settings:
Unexpected token 'c', "credential"... is not valid JSON

The file passed through -D @file had to be JSON here.

After correcting the format, we tested the new configuration in safe mode without displaying the secret value.

The final service now loads this file with:

-D @/etc/node-red-secret-settings

After a Deploy -> Full, flows_cred.json changed: Node-RED had re-encrypted the credentials with the explicitly defined key. One additional restart then confirmed that the file remained stable.

That is why this option is now in our service. It is not a magic line added at the end of the installation.

What about credentials stored in the flows?#

The rebuild also identified historical application credentials stored directly in flows.json.

They were removed, and a check then confirmed the absence of the residual values we were looking for.

/etc/node-red-secret-settings protects the credentialSecret used by Node-RED for its credential store. It is not a general-purpose vault for every API key used by the flows.

Protecting access to Node-RED#

The historical article used:

node-red-admin hash-pw

With Node-RED 5.0.7, we now use:

node-red admin hash-pw
Password: <Enter a password>
$2a$08$gxCsv3bGcPOvlarJiZw.QOqNfQKak7.vzltgRIAbGScbvGdOfE3HO

The principle remains the same: the result is a bcrypt hash that we can use in settings.js.

For example, to protect the editor and administration API:

adminAuth: {
    type: "credentials",
    users: [{
        username: "admin",
        password: "$2a$08$gxCsv3bGcPOvlarJiZw.QOqNfQKak7.vzltgRIAbGScbvGdOfE3HO",
        permissions: "*"
    }]
},

And to protect the HTTP endpoints defined by the nodes:

node-red admin hash-pw
Password: <Enter a password>
$2a$08$Fa6lO1rVaMPZYsxivz/PVOJoMly4/.Qi9EqMhReMMOLzutmVcC8Ty

then, for example:

httpNodeAuth: {
    user: "dashUser",
    pass: "$2a$08$Fa6lO1rVaMPZYsxivz/PVOJoMly4/.Qi9EqMhReMMOLzutmVcC8Ty"
},

On our OpenBSD test server, the authorizations already existed in the restored settings.js. The rebuild therefore did not recreate those accounts: it preserved those assets and then verified that they worked.

Starting normally#

Once safe mode had been qualified, we could start Node-RED normally with the same userDir, the same settings and our externalized secret.

We then check the real flows, MQTT, local HTTP access, authentication, the dashboard and the functions actually in use.

Let us view our test site through a tunnel:

ssh -L 1880:127.0.0.1:1880 ollie@test-server

Then from the local browser:

Editor    http://127.0.0.1:1880/
Dashboard http://127.0.0.1:1880/ui

Port 1880 does not need to be exposed on the network.

Node-RED as an OpenBSD service#

At this point, Node-RED works manually.

Now let us write our startup script using rc.d:

#!/bin/ksh

daemon='/usr/local/bin/node-red'
daemon_flags='--userDir /data/node-red --settings /data/node-red/settings.js'
daemon_user='_node-red'
daemon_execdir='/'
rc_bg=YES

umask 077

. /etc/rc.d/rc.subr

rc_cmd $1

That gave us a rather amusing result.

Node-RED really was running: the process was listening on 127.0.0.1:1880, the HTTP service responded correctly… and we were rather pleased with the result. :)

… but our friend rcctl seemed a little confused:

rcctl start node_red  -> node_red(ok)
rcctl check node_red  -> node_red(failed)

Our investigation let us understand this paradox. Let us look at the process actually being executed:

pgrep -T 0 -xf 'node: node-red'

Node-RED is launched through Node.js, and the observed process title is:

node: node-red

So rcctl did start our service correctly, but did not find the process correctly during check.

We added:

pexp='node: node-red'

with one additional subtlety: this value is placed after loading rc.subr.

The service currently used on our OpenBSD test server is therefore:

#!/bin/ksh

daemon='/usr/local/bin/node-red'
daemon_flags='--userDir /data/node-red --settings /data/node-red/settings.js -D @/etc/node-red-secret-settings'
daemon_user='_node-red'
daemon_execdir='/'
rc_bg=YES

umask 077

. /etc/rc.d/rc.subr

# Node-RED is executed by Node.js; this is the exact process title
# observed and independently matched with pgrep -x.
pexp='node: node-red'

rc_cmd $1

We install it as:

/etc/rc.d/node_red

then:

doas chmod 755 /etc/rc.d/node_red
doas rcctl enable node_red
doas rcctl start node_red

sleep 8

doas rcctl check node_red
pgrep -fl 'node-red|red.js'
netstat -an -f inet | grep '\.1880 '
ftp -Vo /dev/null http://127.0.0.1:1880/

An RCA is often made of “whys”. So, why these few lines in our script? :)

  • Because Node-RED does not daemonize itself in this mode, we let rc.subr manage it in the background with rc_bg=YES.
  • Because our manual launch had been tested from /, daemon_execdir='/' makes our service independent of the current working directory.
  • Because files created by the service must retain restrictive permissions, our umask 077 belongs to the service, not to the shell that launches rcctl.

Let us make sure our service does not regress#

Our friend rcctl has already given us one small surprise. Before considering our script finished, let us now check its full lifecycle. :)

First, stop Node-RED:

doas rcctl stop node_red
sleep 3

doas rcctl check node_red || true

pgrep -fl 'node-red|red.js' || \
    echo 'NODE_RED_PROCESS=NONE'

netstat -an -f inet | \
    grep '\.1880 .*LISTEN' || \
    echo 'TCP_1880_LISTENER=NONE'

No process, no listener? Good. Let us start it again:

doas rcctl start node_red
sleep 8
doas rcctl check node_red

This time, rcctl finds our service correctly. The process returns, the listener too, and our HTTP test responds.

We can therefore start, check and stop Node-RED cleanly with rc.d.

One detail can be surprising after a stop: a socket may remain in TIME_WAIT for a while. That is not Node-RED secretly carrying on with its life. :) What we check here is the absence of a process and a socket in LISTEN.

HTTP through Node-RED? Yes, but let us stay hidden… :)#

The old version of this article created a self-signed certificate and enabled HTTPS directly in Node-RED.

We changed our approach. Node-RED now listens over HTTP, but only on:

127.0.0.1:1880

Clear-text HTTP? Yes. Exposed on the network? No. And that makes all the difference.

Node-RED is no longer directly exposed to the network. We separate responsibilities:

Client
  |
  | HTTPS / TLS
  v
relayd
  |
  | local HTTP
  v
127.0.0.1:1880
  |
  v
Node-RED

Node-RED remains our application.

relayd, OpenBSD’s native reverse proxy, handles secure publication upstream.

This architecture also means we no longer need to keep the old .ssl directory among the Node-RED assets.

We deliberately stop here for relayd: that layer deserves its own article and its own qualification. We are not going to declare TLS or mTLS validated simply because Node-RED works perfectly over loopback.

What about after a reboot?#

We now have:

  • a working runtime;
  • protected data and credentials;
  • a local-only listener;
  • a service that rcctl can start, check and stop.

One fairly simple test remains: turn off the lights and see whether everything comes back. :)

doas reboot

After reconnecting, without any manual start:

uptime
mount | grep '/data'

doas rcctl check node_red
pgrep -fl 'node-red|red.js'
netstat -an -f inet | grep '\.1880 '
doas fstat | grep ':1880'
ftp -Vo /dev/null http://127.0.0.1:1880/

On our OpenBSD test server, the post-reboot test confirmed:

RAID1                        = ONLINE
/data/node-red               = MOUNTED
rcctl check node_red         = OK
runtime owner                = _node-red
127.0.0.1:1880              = LISTEN
HTTP                         = PASS

But a process and an open port are still not enough to prove that our home automation works.

So we also checked MQTT, the dashboard, real telemetry and a real shutter command with its returned state.

Node-RED was back.

And so was what it controls. This is not entirely anecdotal… I have already found myself locked outside. :)

To finish#


The first version of this article launched Node-RED from rc.local, installed node-red-admin separately and made Node-RED handle HTTPS directly.

That installation worked for years.

But our recent rebuild allowed us to revisit it calmly, layer by layer, and evolve several choices:

OpenBSD packages
        |
        v
Node.js / npm

Node-RED global
        |
        v
/data/node-red
flows + configuration + modules + credentials

/etc/node-red-secret-settings
        |
        v
controlled credentialSecret

/etc/rc.d/node_red
        |
        v
rcctl + umask 077

127.0.0.1:1880
        |
        v
local HTTP only

The most interesting part is ultimately not the list of commands.

It is the order in which we were able to verify them:

Node.js
   |
Node-RED
   |
assets + modules
   |
safe mode
   |
normal runtime
   |
rc.d
   |
start / check / stop
   |
reboot
   |
real functional test

At each stage, we tried to change only one thing, observe what actually happened and move on only once the previous step was understood.

That is how we discovered, among other things, that an rcctl start ... (ok) did not yet mean that our service was properly controlled by rc.d.

The next step will be to move one level up in the architecture:

Internet / client
       |
     HTTPS
       |
     relayd
       |
  HTTP loopback
       |
    Node-RED

And this time, we will leave Node-RED alone: publication security will be relayd’s problem.

Enjoy !