One of the bigger changes in Realm Labs was moving Node-RED away from a Raspberry Pi and into a more centralised setup.
At first, Node-RED had been running directly on the Pi because it was convenient.
The Pi already handled:
- GPIO
- Audio playback
- TARDIS lighting
- Local Python scripts
- Timed automation
- MQTT
- Home Assistant integration
So Node-RED had easy access to everything.
That worked well.
Until I wanted to move Node-RED somewhere better.
The obvious problem was that a central Node-RED instance doesn’t have access to the Raspberry Pi’s GPIO pins, local sound card or scripts.
So moving Node-RED wasn’t just a case of exporting the flows and importing them somewhere else.
I had to separate the automation logic from the physical hardware.
The solution was MQTT.
The Original Setup
The early version looked roughly like this:
Raspberry Pi
|
+-- Node-RED
+-- GPIO
+-- Python scripts
+-- WAV files
+-- MQTT
+-- TARDIS hardware
Node-RED could directly execute commands such as:
python3 /home/pi/scripts/led.py
or:
aplay /home/pi/audio/tardis.wav
It could also control the GPIO pins directly.
That made development simple.
But it also meant the entire automation system was tied to one Raspberry Pi.
Why I Wanted to Move Node-RED
As the home lab grew, I started moving more services into Docker and onto the Synology infrastructure.
Node-RED was a natural candidate.
A central instance would give me:
- Easier backups
- Easier updates
- Better separation between automation and hardware
- Less reliance on the Pi’s SD card
- One Node-RED instance for the whole home
- Easier management through Portainer
The proposed architecture became:
Synology / Docker
|
v
Node-RED
That looked cleaner.
But there was a problem.
Containers Can’t Reach Raspberry Pi GPIO
Once Node-RED runs on another machine, this:
Node-RED
|
v
GPIO 13
is no longer possible.
The GPIO pins physically exist on the Raspberry Pi.
The Docker host has no idea they exist.
The same problem applies to:
/home/pi/scripts
and:
/home/pi/audio
Those files belong to the Pi.
So if I simply migrated the Node-RED flows, anything that relied on local hardware would break.
The Architecture Needed to Change
The key was to stop treating Node-RED as the thing that directly controlled the hardware.
Instead, Node-RED would become the brain.
The Raspberry Pi would become the hardware controller.
That changed the architecture from:
Node-RED
|
+-- GPIO
+-- Sound
+-- Scripts
to:
Central Node-RED
|
v
MQTT
|
v
Raspberry Pi Agent
|
+-- GPIO
+-- Sound
+-- Scripts
That separation turned out to be much better.
MQTT Was the Bridge
MQTT is ideal for this kind of job.
Node-RED doesn’t need to know how the Pi controls the LED.
It only needs to send a message.
For example:
Topic:
pi/tardis/command
Payload:
LIGHT_ON
The Pi receives the message and handles the actual hardware.
That means the automation logic can live anywhere.
Node-RED could be running on:
- Synology
- Docker
- Proxmox
- Another Raspberry Pi
- A VM
The physical Pi only needs network connectivity to the MQTT broker.
Building the Pi Agent
The solution was a lightweight Python service running on the Raspberry Pi.
Its job was simple:
Connect to MQTT
Listen for commands
Run the correct local action
Conceptually:
if command == "LIGHT_ON":
turn_light_on()
elif command == "LIGHT_OFF":
turn_light_off()
elif command == "PLAY_SOUND":
play_sound()
This meant all the hardware-specific code stayed on the Pi.
Node-RED only had to publish messages.
A Simple MQTT Example
Using Python and paho-mqtt, the basic structure looks like this:
import paho.mqtt.client as mqtt
BROKER = "192.168.68.20"
TOPIC = "pi/tardis/command"
def on_message(client, userdata, msg):
command = msg.payload.decode()
print(f"Received: {command}")
if command == "LIGHT_ON":
print("Turning light on")
elif command == "LIGHT_OFF":
print("Turning light off")
client = mqtt.Client()
client.on_message = on_message
client.connect(BROKER, 1883, 60)
client.subscribe(TOPIC)
client.loop_forever()
That is the basic idea.
The real script can then call GPIO functions, shell commands or Python scripts.
Controlling GPIO Locally
The Pi agent can retain all the normal GPIO functionality.
For example:
import RPi.GPIO as GPIO
LED_PIN = 35
GPIO.setmode(GPIO.BOARD)
GPIO.setup(LED_PIN, GPIO.OUT)
Then:
GPIO.output(LED_PIN, GPIO.HIGH)
or:
GPIO.output(LED_PIN, GPIO.LOW)
Node-RED never needs to know which GPIO pin is involved.
It just sends:
LIGHT_ON
That makes the automation flow much easier to understand.
Playing Audio Remotely
The same model works for sound.
Instead of Node-RED trying to run:
aplay /home/pi/audio/tardis.wav
on the Docker host, it sends:
PLAY_TARDIS
The Raspberry Pi receives the command and runs:
aplay /home/pi/audio/tardis.wav
locally.
That avoids problems with:
- Audio devices
- File paths
- Permissions
- Missing files
- Container audio access
The sound stays where the speaker is.
Running Existing Python Scripts
I already had scripts for effects such as lighting and strobing.
Rather than rewriting everything, the agent could simply launch them.
For example:
import subprocess
subprocess.Popen([
"python3",
"/home/pi/scripts/FastStrobe.py"
])
That meant the existing hardware work wasn’t wasted.
The MQTT layer simply became the trigger.
Node-RED Became Much Simpler
The old Node-RED flow might contain an Exec node with something like:
python3 /home/pi/scripts/FastStrobe.py
After the migration, the same flow only needed to publish:
Topic:
pi/tardis/command
Payload:
FAST_STROBE
That’s a much cleaner separation.
The flow describes what should happen.
The Pi decides how it happens.
Running the Pi Agent as a Service
One problem with a standalone Python script is that it stops when the SSH session closes.
That isn’t suitable for automation.
So I turned the agent into a systemd service.
A service file can look like:
[Unit]
Description=Realm Labs Pi Agent
After=network.target
[Service]
ExecStart=/usr/bin/python3 /home/pi/pi-agent.py
Restart=always
User=pi
[Install]
WantedBy=multi-user.target
Save it as:
/etc/systemd/system/pi-agent.service
Then reload systemd:
sudo systemctl daemon-reload
Enable the service:
sudo systemctl enable pi-agent
Start it:
sudo systemctl start pi-agent
And check it:
sudo systemctl status pi-agent
Now the hardware controller starts automatically with the Pi.
The First Failure: Missing Python Module
Of course, it didn’t work perfectly first time.
The service failed because the MQTT Python dependency wasn’t installed.
The script needed:
import paho.mqtt.client
but the Pi didn’t have the package available.
Installing it solved that:
pip install paho-mqtt
or depending on the system:
pip3 install paho-mqtt
After that, the service could connect to MQTT normally.
Adding an Online Status
Once the Pi became a remote hardware agent, I wanted Node-RED and Home Assistant to know whether it was actually online.
So the agent also published a status message.
For example:
Topic:
pi/tardis/status
Payload:
online
This can be sent when the agent starts.
That gives Node-RED something useful to monitor.
Instead of blindly sending commands to an offline Pi, the automation system can know whether the controller is available.
Using MQTT Last Will
A better version of this uses MQTT’s Last Will and Testament feature.
The Pi connects with a last-will message such as:
Topic:
pi/tardis/status
Payload:
offline
Then, if the Pi disappears unexpectedly, the broker automatically publishes:
offline
That gives the system a proper availability state.
The behaviour becomes:
Pi connects
|
v
status = online
Pi crashes or loses network
|
v
MQTT broker publishes offline
This is much more useful than relying only on periodic polling.
Home Assistant Integration Became Easier Too
Once the Pi was controlled through MQTT, Home Assistant could interact with it directly as well.
The path became:
Home Assistant
|
v
MQTT
|
v
Raspberry Pi
or:
Home Assistant
|
v
Node-RED
|
v
MQTT
|
v
Raspberry Pi
That made the physical project much less dependent on any one automation platform.
The TARDIS Was the Perfect Test
The model TARDIS was where this architecture really proved itself.
The Pi still handled:
- Top lamp
- Interior lights
- Sign lighting
- Audio
- Python effects
- GPIO timing
But the higher-level automation could now happen elsewhere.
For example:
Sunset
|
Home Assistant
|
Node-RED
|
MQTT
|
Raspberry Pi
|
TARDIS Take-Off Sequence
The hardware hadn’t changed.
Only the control architecture had.
Why This Is Better Than Remote SSH
Another option would have been for Node-RED to SSH into the Pi and execute commands remotely.
For example:
ssh pi@raspberrypi python3 /home/pi/scripts/led.py
That would work.
But I didn’t particularly like it.
It introduces:
- SSH credentials
- Key management
- Remote shell execution
- Command quoting
- Connection startup delay
- More complicated error handling
MQTT is much cleaner for small automation commands.
The message:
FAST_STROBE
is far easier to work with than a remote shell command.
MQTT Also Decouples the Systems
This is probably the biggest architectural advantage.
Node-RED doesn’t know that the hardware is a Raspberry Pi.
The Pi doesn’t know that the command came from Node-RED.
Both systems only know about MQTT.
That means I can later replace Node-RED with something else without rewriting the Pi.
Likewise, I could replace the Pi hardware controller without completely rebuilding the automation flows.
The relationship becomes:
Automation
|
v
MQTT Topic
|
v
Hardware Controller
That’s a much more flexible design.
Centralising Node-RED
With the hardware problem solved, I could move Node-RED into the central Docker environment.
The new arrangement became:
Synology DS224+
|
Docker
|
Node-RED
|
v
MQTT Broker
|
v
Raspberry Pi
|
+-- GPIO
+-- Audio
+-- Scripts
+-- TARDIS
Node-RED could now be updated and backed up independently from the Pi.
And the Raspberry Pi could be rebuilt without losing the entire automation platform.
What Happens If the Pi Is Rebuilt?
This separation also made disaster recovery much easier.
The Pi only needs:
Operating system
Python
MQTT agent
GPIO scripts
Audio files
systemd service
Node-RED itself doesn’t need to be restored to it.
That means a Pi rebuild becomes much smaller and more predictable.
Testing the Migration
Once the central Node-RED instance was running, I tested each hardware command separately.
For example:
LIGHT_ON
LIGHT_OFF
FAST_STROBE
PLAY_SOUND
I could publish test messages manually using MQTT tools.
For example:
mosquitto_pub \
-h 192.168.68.20 \
-t pi/tardis/command \
-m "LIGHT_ON"
If the light came on, I knew:
MQTT broker
Pi agent
GPIO
were all working.
That made debugging much easier than trying to test the entire automation chain at once.
Troubleshooting the Pi Agent
If the hardware stops responding, the first thing I check is the service.
sudo systemctl status pi-agent
Then:
journalctl -u pi-agent
That reveals Python errors, MQTT connection failures and permission problems.
Next I check whether the broker is reachable:
ping 192.168.68.20
Then MQTT itself:
mosquitto_sub \
-h 192.168.68.20 \
-t pi/tardis/command \
-v
That makes it easy to see whether Node-RED is publishing anything.
Troubleshooting from Node-RED
On the Node-RED side, I use Debug nodes around MQTT outputs and decision points.
The question becomes:
Did Node-RED send the command?
If yes, troubleshoot MQTT and the Pi.
If no, troubleshoot the flow.
That separation is extremely useful.
The Final Architecture
The end result looked like this:
┌─────────────────────┐
│ Home Assistant │
└──────────┬──────────┘
|
v
┌─────────────────────┐
│ Node-RED │
│ Synology / Docker │
└──────────┬──────────┘
|
v
┌─────────────────────┐
│ MQTT Broker │
└──────────┬──────────┘
|
v
┌─────────────────────┐
│ Raspberry Pi │
│ Pi Agent │
└──────────┬──────────┘
|
┌──────────────┼──────────────┐
| | |
v v v
GPIO Audio Scripts
|
v
TARDIS
Node-RED handles automation.
MQTT handles communication.
The Raspberry Pi handles hardware.
Each system does the job it is best suited to.
What I Learned
The biggest lesson was that migrating an application isn’t always about moving the application itself.
Sometimes you have to rethink the dependencies around it.
The old design was:
Node-RED controls everything directly
The new design became:
Node-RED decides what should happen
|
v
MQTT carries the instruction
|
v
Pi performs the physical action
That’s much cleaner.
A Useful Pattern Beyond Raspberry Pi
This architecture isn’t limited to Node-RED or GPIO.
The same idea works for almost any remote hardware controller.
For example:
Central automation
|
v
MQTT
|
+-- Raspberry Pi
+-- ESP32
+-- Arduino gateway
+-- Linux server
+-- Sensor controller
The automation system remains centralised while hardware stays local.
The Realm Labs Takeaway
At first, moving Node-RED away from the Raspberry Pi looked like it would break everything connected to the Pi.
In reality, it forced me to build a better architecture.
The problem was:
Central Node-RED can't access remote GPIO
The solution was:
Don't make it access GPIO
Instead:
Node-RED
|
v
MQTT
|
v
Pi Agent
|
v
GPIO
Once I made that separation, Node-RED was free to run wherever I wanted.
And the Raspberry Pi became what it should have been all along:
A small, dedicated hardware controller rather than the machine responsible for the entire automation stack.

