A Raspberry Pi relay MQTT controller lets you switch lamps, pumps, fans or contactors from a phone, a dashboard or an automation script, without opening ports on your router. In this tutorial you will wire a multi-channel relay board, write a Python service that maps MQTT topics to relay channels, and use retained messages so every relay comes back in the correct state after a reboot.
What You Need
| Item | Notes |
|---|---|
| Raspberry Pi (3B+, 4, 5 or Zero 2 W) | Raspberry Pi OS Bookworm, network connected |
| 2, 4 or 8 channel relay board | 5 V coil with optocouplers, 3.3 V-compatible inputs |
| Female-to-female jumper wires | One per channel plus power and ground |
Python 3 with gpiozero and paho-mqtt 2.x |
Installed in a virtual environment |
| An MQTT broker | broker.connectmq.com for testing, private credentials for production |
A safety note before anything else: relay boards switch whatever you connect to them. If you control mains voltage, the wiring must be done inside a proper enclosure by someone qualified, with the relay rated for the load. For learning, switch a 12 V LED strip or a small DC fan.
Wiring the Relay Board
Most hobby relay modules have a header labelled VCC, GND and IN1 to IN4. Connect them like this:
| Relay pin | Raspberry Pi pin |
|---|---|
| VCC | 5 V (physical pin 2) |
| GND | GND (physical pin 6) |
| IN1 | GPIO17 (physical pin 11) |
| IN2 | GPIO27 (physical pin 13) |
| IN3 | GPIO22 (physical pin 15) |
| IN4 | GPIO23 (physical pin 16) |
Many of these boards are active-low: pulling an input to ground energises the coil. That is why the code below uses active_high=False. If your board clicks on when you expect off, flip that flag.
Boards with a JD-VCC jumper let you power the coils from a separate 5 V supply for full isolation. Remove the jumper, feed JD-VCC from the external supply, keep VCC on the Pi's 3.3 V or 5 V as the board's documentation says, and share ground only if the manual requires it.
One more hardware detail matters for reliability. Before your script starts, GPIO pins are inputs with default pull resistors, so an active-low board may briefly switch at boot. Test what your relays do during a power cycle. If a glitch would be harmful, pick pins whose default pull matches the "off" level or add external pull resistors.
Designing the Topic Contract
A clean topic layout makes the relay controller easy to use from any client, whether that is Node-RED, Home Assistant or a script:
| Topic | Direction | Payload | Retained |
|---|---|---|---|
connectmq/demo/a91c4e/relays/<n>/set |
Controller to Pi | ON, OFF (or TOGGLE, not retained) |
Yes for ON/OFF |
connectmq/demo/a91c4e/relays/<n>/state |
Pi to everyone | ON or OFF |
Yes |
connectmq/demo/a91c4e/relays/status |
Pi (and last will) | online or offline |
Yes |
Replace a91c4e with your own random string. On a shared public broker every topic is readable by everyone, so a unique prefix keeps your test traffic separate. If you need a refresher on why separate set and state topics are good practice, the Raspberry Pi GPIO over MQTT guide explains the command and state pattern in detail.
How Retained States Survive a Reboot
Retained messages are the key to a relay controller that behaves after a power cut. The broker stores the last retained message on each topic and delivers it immediately to any new subscriber. That gives you two useful behaviours:
- When your dashboard publishes
ONtorelays/1/setwith the retain flag, the broker keeps it. After the Pi reboots and subscribes torelays/+/set, it receives the desired state for every channel and restores it. - When the Pi publishes
relays/1/statewith retain, any dashboard that opens later sees the real state immediately instead of waiting for the next change.
There is one trap: never retain TOGGLE. A retained toggle would be replayed on every reconnect and flip the relay each time. The script below ignores retained toggles for that reason. For a deeper look at the mechanism, read our explainer on MQTT retained messages.
Raspberry Pi Relay MQTT Script in Python
Create a project folder and virtual environment:
sudo apt update
sudo apt install -y python3-venv python3-lgpio
mkdir -p ~/relay-mqtt && cd ~/relay-mqtt
python3 -m venv --system-site-packages .venv
source .venv/bin/activate
pip install "paho-mqtt>=2.0" gpiozero
The --system-site-packages flag lets the virtual environment use the lgpio backend that gpiozero needs on the Raspberry Pi 5 and works on older models too. Save the following as relay_mqtt.py:
#!/usr/bin/env python3
"""Control a multi-channel relay board from MQTT topics."""
import os
import signal
import sys
import uuid
import paho.mqtt.client as mqtt
from gpiozero import OutputDevice
BROKER = os.getenv("MQTT_HOST", "broker.connectmq.com")
PORT = int(os.getenv("MQTT_PORT", "1883"))
USERNAME = os.getenv("MQTT_USERNAME")
PASSWORD = os.getenv("MQTT_PASSWORD")
CLIENT_ID = os.getenv("MQTT_CLIENT_ID", f"rpi-relay-{uuid.uuid4().hex[:8]}")
PREFIX = os.getenv("MQTT_PREFIX", "connectmq/demo/a91c4e/relays")
RELAY_PINS = {1: 17, 2: 27, 3: 22, 4: 23}
ACTIVE_HIGH = False # most opto-isolated hobby boards are active-low
relays = {
n: OutputDevice(pin, active_high=ACTIVE_HIGH, initial_value=False)
for n, pin in RELAY_PINS.items()
}
def publish_state(client, n):
state = "ON" if relays[n].value else "OFF"
client.publish(f"{PREFIX}/{n}/state", state, qos=1, retain=True)
def on_connect(client, userdata, flags, reason_code, properties):
if reason_code.is_failure:
print(f"Connection refused: {reason_code}")
return
print(f"Connected as {CLIENT_ID}")
client.publish(f"{PREFIX}/status", "online", qos=1, retain=True)
client.subscribe(f"{PREFIX}/+/set", qos=1)
for n in relays:
publish_state(client, n)
def on_message(client, userdata, msg):
try:
n = int(msg.topic.split("/")[-2])
except ValueError:
return
if n not in relays:
print(f"Ignoring unknown channel {n}")
return
command = msg.payload.decode(errors="ignore").strip().upper()
if command in ("ON", "1", "TRUE"):
relays[n].on()
elif command in ("OFF", "0", "FALSE"):
relays[n].off()
elif command == "TOGGLE":
if msg.retain:
print(f"Ignoring retained TOGGLE on channel {n}")
return
relays[n].toggle()
else:
print(f"Unknown command {command!r} for channel {n}")
return
print(f"Relay {n} -> {'ON' if relays[n].value else 'OFF'}")
publish_state(client, n)
def on_disconnect(client, userdata, flags, reason_code, properties):
print(f"Disconnected: {reason_code}; paho will reconnect")
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=CLIENT_ID)
client.on_connect = on_connect
client.on_message = on_message
client.on_disconnect = on_disconnect
client.will_set(f"{PREFIX}/status", "offline", qos=1, retain=True)
client.reconnect_delay_set(min_delay=1, max_delay=60)
if USERNAME:
client.username_pw_set(USERNAME, PASSWORD)
if PORT == 8883:
client.tls_set()
def shutdown(signum, frame):
for relay in relays.values():
relay.off()
client.publish(f"{PREFIX}/status", "offline", qos=1, retain=True)
client.disconnect()
sys.exit(0)
signal.signal(signal.SIGTERM, shutdown)
signal.signal(signal.SIGINT, shutdown)
client.connect(BROKER, PORT, keepalive=30)
client.loop_forever(retry_first_connection=True)
Run it with python relay_mqtt.py. The script switches every relay off on a clean shutdown; if you would rather keep loads running when the service stops, remove the loop in shutdown.
Testing from Your Laptop
Open a second terminal on any machine with the Mosquitto clients installed and watch the state topics:
mosquitto_sub -h broker.connectmq.com -p 1883 -i laptop-a91c4e \
-t 'connectmq/demo/a91c4e/relays/#' -v
Then switch channel 1 on with a retained command and toggle channel 2:
mosquitto_pub -h broker.connectmq.com -p 1883 -i cmd-a91c4e \
-t 'connectmq/demo/a91c4e/relays/1/set' -m 'ON' -q 1 -r
mosquitto_pub -h broker.connectmq.com -p 1883 -i cmd-a91c4e \
-t 'connectmq/demo/a91c4e/relays/2/set' -m 'TOGGLE' -q 1
You should hear the clicks and see relays/1/state ON appear. Now reboot the Pi. When the script reconnects, channel 1 returns to ON because the retained command is still on the broker, while channel 2 starts OFF because toggles are never retained. To clear a retained command, publish an empty retained message: mosquitto_pub ... -t '.../relays/1/set' -r -n.
Run It as a Service
A relay controller must start on boot and restart after a crash. Create /etc/systemd/system/relay-mqtt.service:
[Unit]
Description=Relay MQTT controller
After=network-online.target
Wants=network-online.target
[Service]
User=pi
WorkingDirectory=/home/pi/relay-mqtt
Environment=MQTT_HOST=broker.connectmq.com
Environment=MQTT_PORT=8883
Environment=MQTT_USERNAME=YOUR_USERNAME
Environment=MQTT_PASSWORD=YOUR_PASSWORD
Environment=MQTT_CLIENT_ID=YOUR_CLIENT_ID
Environment=MQTT_PREFIX=home/garage/relays
ExecStart=/home/pi/relay-mqtt/.venv/bin/python relay_mqtt.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Enable it with sudo systemctl daemon-reload && sudo systemctl enable --now relay-mqtt. The full explanation of service files, logging and environment files is in running a Python MQTT script on boot. Adjust the user name if yours is not pi.
Moving from the Test Broker to Production
The public test broker is perfect for the first hour, but anyone who guesses your topic could switch your relays. For anything connected to real equipment, use a private broker with authentication and TLS. With a fully managed MQTT service like ConnectMQ, you create a broker in the portal, copy the generated username, password and client ID into the service file above, and set the port to 8883. The client ID is bound to the issued credentials, so another device cannot impersonate your controller. The script does not change at all.
The ConnectMQ portal also helps while commissioning: its in-browser MQTT client can publish ON and OFF to each channel, and the live topic explorer shows retained state and status values at a glance. If you want a hardware alternative to the Pi for the same job, compare it with the ESP32 relay over MQTT build.
Troubleshooting
- Relays click on at start and off when commanded on: your board is active-high; set
ACTIVE_HIGH = True. - LEDs on the board light but the relay does not click: the coil supply is too weak; power
JD-VCCfrom a separate 5 V source. Connection refused: Not authorized: check username, password and client ID, or remove them when using the anonymous public broker.- Relay restores an old state after reboot: a stale retained command is on the broker; clear it with an empty retained publish.
GPIO busyerrors: another script or service is using the same pins; stop it before starting this one.
Get Started with ConnectMQ
Ready to control real equipment? Sign up for the free Serverless plan, no credit card needed, create a broker and point your relay service at it over TLS. You get MQTT 3.1.1 and 5.0, retained messages and a portal to watch every switch event. New to the concepts? The Raspberry Pi MQTT tutorial covers the fundamentals, and our overview of a cloud MQTT broker explains when hosting beats running Mosquitto on the Pi.
Frequently Asked Questions
Why does my relay change state after the Raspberry Pi reboots?
It is replaying a retained command from the broker, which is usually what you want. If the state is wrong, publish the correct value with retain or clear the topic with an empty retained message.
Can I use QoS 1 for relay commands?
Yes, and you should. QoS 1 guarantees the command arrives at least once, and since ON and OFF are idempotent, a duplicate delivery does no harm.
How many relays can one Raspberry Pi control?
As many as you have free GPIO pins, typically up to eight on a hobby board. For more channels, use an I2C GPIO expander such as an MCP23017 and keep the same topic layout.
Is it safe to control mains devices this way?
Only with relays rated for the load, a proper enclosure and wiring done to local electrical code. Always use authenticated TLS connections for anything that switches real equipment.



