
A four-VM smart-home stack: Home Assistant + Mosquitto + Zigbee2MQTT + Node-RED, from one prompt
March 10, 2026
Home Assistant is the only vendor-neutral smart-home hub that actually works at scale: it speaks every protocol your devices speak, keeps automations local, and survives the moment a cloud provider sunsets your hub or your light bulbs. It's the third-most-deployed self-hosted app in the 2026 r/selfhosted survey, after Jellyfin and Immich.
The case for self-hosting it has only gotten stronger. In Home Assistant 2026.1, the open protocols — Matter and Thread — were promoted out of the buried “Devices & Services” list to the top level of the Settings menu, a deliberate signal that the project treats local, IP-based standards as the default rather than the exception (Matter Alpha). Matter control is 100% local — the controller talks to devices over your own LAN, not a vendor cloud — which is exactly the property that makes a hub worth running yourself.
This post walks through the canonical Home Assistant lab shape on OpenFactory: four buildable VMs — Home Assistant core, Mosquitto as the MQTT broker, Zigbee2MQTT as the Zigbee gateway, and Node-RED for visual automation — from a single prompt, shipped as bootable ISOs. It is a preparation lab, not a production deployment: the topology, the ports, and every config file land in bootable images so that swapping the compatibility stubs for upstream Home Assistant Core is a one-step promotion, not a from-scratch install.
homeassistant (10.72.0.10:8123) — the central hub with the MQTT integration pre-pointed at the broker. In a real deployment this is also where Matter and Thread devices land over IP, and where the Assist voice pipeline (Whisper + Piper over the Wyoming protocol) runs if you want local voice.mosquitto (10.72.0.20:1883) — the MQTT broker with persistence enabled at /var/lib/mosquitto. Every other node publishes here; it is the single message bus the whole stack agrees on.zigbee2mqtt (10.72.0.30:8080) — the bridge from a USB Zigbee coordinator to MQTT, with the frontend ready on :8080. The recipe's YAML already pins /dev/ttyUSB0 so the coordinator slots in without editing.nodered (10.72.0.40:1880) — visual automation engine wired to the same MQTT broker, for flows that are easier to draw than to write in YAML.configuration.yaml, mosquitto.conf, and zigbee2mqtt's YAML all live in bootable images, not in a recovered backup tarball./dev/ttyUSB0; the host hypervisor maps the device through.Four Debian Trixie VMs on 10.72.0.0/24. Mosquitto is the message-bus hub; Home Assistant, Node-RED, and Zigbee2MQTT all connect to it on 1883. The reason for splitting four roles across four VMs instead of one fat appliance is operational: you can reboot the MQTT broker, or upgrade Zigbee2MQTT's coordinator firmware, without taking the dashboard down with it.
/dev/ttyUSB0.The thing a smart-home hub can't fake is the radio. For Zigbee and Z-Wave, Home Assistant doesn't need a vendor cloud or even a vendor integration — it needs a USB coordinator and the matching built-in stack (Zigbee2MQTT or ZHA for Zigbee, Z-Wave JS for Z-Wave). In this lab the zigbee2mqtt VM carries that role: its config pins /dev/ttyUSB0, and the hypervisor passes a physical CC2652, EFR32MG21, or ConBee II stick through to it. Matter and Thread devices are different — they speak IPv6 over Wi-Fi, Ethernet, or a Thread mesh, so they attach to the homeassistant node directly rather than through the Zigbee gateway.
Paste this verbatim into the chat builder at console.openfactory.tech. Nothing above or below it — the builder expects the prompt body to start at the “Build a compact multi-node lab…” line.
Build a compact multi-node lab named `home-assistant-smarthome`.
Output discipline: keep the plan small. Use one startup script per node, about 25 shell lines or less. Do not install Home Assistant Core, Mosquitto, Zigbee2MQTT, Node-RED, Python integrations, or USB Zigbee/Zwave coordinators at build time. Write deployment-time config examples and tiny Python stdlib or shell compatibility stubs only. The goal is a buildable preparation lab, not a production deployment.
## Topology
Create 4 buildable `debian-trixie` nodes, all `x86_64`, SSH enabled, DHCP/default route intact with lab aliases, firewall disabled, DNS `1.1.1.1` and `8.8.8.8`, user `ops` password `ha-ops` in `sudo`. Every recipe must set top-level `test_config` to `{ "enabled": false, "tests": [] }`.
- `homeassistant`: role `smarthome-hub`, 4 GB RAM, 24 GB disk, alias `10.72.0.10/24`, x `230`, y `60`
- `mosquitto`: role `mqtt-broker`, 1 GB RAM, 8 GB disk, alias `10.72.0.20/24`, x `110`, y `220`
- `zigbee2mqtt`: role `zigbee-gateway`, 1 GB RAM, 8 GB disk, alias `10.72.0.30/24`, x `350`, y `220`
- `nodered`: role `automation`, 2 GB RAM, 12 GB disk, alias `10.72.0.40/24`, x `230`, y `380`
Connections: `homeassistant` to `mosquitto:1883` and `nodered:1880`; `zigbee2mqtt` to `mosquitto:1883`; `nodered` to `mosquitto:1883`.
## Common Recipe Requirements
All nodes: features `headless`, `ssh`; packages `openssh-server`, `python3`, `curl`, `jq`, `iproute2`, `netcat-openbsd`, `ca-certificates`. Each startup script adds the alias with `IFACE=$(ip route show default | awk '{print $5; exit}')`, `ip link set "$IFACE" up || true`, and `ip addr add <alias> dev "$IFACE" || true`. If `os.startup_scripts[].after` is present, it must be the string `"network-online.target"`, not an array.
## Node Requirements
`homeassistant`: features `headless`, `ssh`. Write `/etc/homeassistant/configuration.yaml` containing:
```
homeassistant:
name: lab-home
time_zone: UTC
http:
server_port: 8123
mqtt:
broker: 10.72.0.20
port: 1883
```
Create `/var/lib/homeassistant/{config,deps,www}` mode `0750 ops:ops`. Add a Python stdlib HTTP service on `0.0.0.0:8123` exposing:
- `GET /api/` -> `200 {"message":"API running."}`
- `GET /api/config` -> `200 {"version":"compat-1.0","location_name":"lab-home","time_zone":"UTC"}`
- `GET /metrics` -> `homeassistant_compat_up 1`
Register `homeassistant-compat.service`.
`mosquitto`: features `headless`, `ssh`. Write `/etc/mosquitto/conf.d/lab.conf` with `listener 1883 0.0.0.0`, `allow_anonymous true`, `persistence true`, `persistence_location /var/lib/mosquitto/`. Create `/var/lib/mosquitto/` mode `0750 ops:ops`. Add a Python stdlib stub listener on `0.0.0.0:1883` accepting connections and a `:9234/metrics` listener with `mosquitto_compat_up 1`. Register `mosquitto-compat.service`.
`zigbee2mqtt`: features `headless`, `ssh`. Write `/etc/zigbee2mqtt/configuration.yaml`:
```
homeassistant: true
mqtt:
base_topic: zigbee2mqtt
server: mqtt://10.72.0.20:1883
serial:
port: /dev/ttyUSB0
frontend:
port: 8080
```
Add a Python stdlib service on `0.0.0.0:8080` exposing `GET /` -> `200 {"status":"compat"}`, `GET /api/info` -> `200 {"version":"compat-1.0","coordinator":{"type":"stub"}}`, `GET /metrics` with `zigbee2mqtt_compat_up 1`. Register `zigbee2mqtt-compat.service`. Write `/root/zigbee2mqtt-notes.md` warning that real deployments need a passthrough USB Zigbee coordinator (CC2652 / EFR32MG21 / ConBee II).
`nodered`: features `headless`, `ssh`. Write `/etc/nodered/settings.js` snippet with `uiPort: 1880` and `flowFile: '/var/lib/nodered/flows.json'`. Add a Python stdlib service on `0.0.0.0:1880` exposing `GET /` -> `200 {"status":"compat"}`, `GET /flows` -> `200 []`, `GET /metrics` with `nodered_compat_up 1`. Register `nodered-compat.service`.
## Scenario
Emit exactly one group scenario named `home-assistant-smarthome-validation`. Put `custom_tests[].assertions[]` inside the scenario entry; leave `scenarios[].tests` empty. Every assertion needs `on_vm`. Use only `port_listening`, `command_output`, and `http_responds`; do not emit `vm_boots`, `network_reachable`, or `service_running`.
- `Stack ports listen`: `port_listening` for `homeassistant:8123`, `mosquitto:1883`, `mosquitto:9234`, `zigbee2mqtt:8080`, `nodered:1880`.
- `HA API responds`: on `homeassistant`, `curl -fsS http://localhost:8123/api/ | jq -e '.message == "API running."' >/dev/null && echo ha-ok`.
- `HA reaches MQTT broker`: on `homeassistant`, `nc -z -w 5 10.72.0.20 1883 && echo mqtt-reachable`.
- `Zigbee2MQTT info`: on `zigbee2mqtt`, `curl -fsS http://localhost:8080/api/info | jq -e '.coordinator.type == "stub"' >/dev/null && echo z2m-ok`.
- `Node-RED flows endpoint`: on `nodered`, `curl -fsS http://localhost:1880/flows | jq -e 'type == "array"' >/dev/null && echo nodered-ok`.
Preserve warnings that real Home Assistant Core or HAOS installation, USB Zigbee/Zwave coordinator passthrough, Mosquitto user/ACL setup, TLS for MQTT, persistent volume strategy for `/config`, integration credential storage, automation backup, and `10.72.0.0/24` aliasing are deployment-time concerns.Driving OpenFactory from an AI agent instead of the browser? The same flow is exposed through the OpenFactory MCP server — submit the prompt programmatically, get the build-plan preview back, and call create_build / start_vm on the resulting recipes. Single-image builds go straight through the openfactory CLI.
The prompt produces a buildable preparation lab — the right topology, the right ports listening, deployment-time config templates dropped in the right places, and tiny compatibility services that prove the wiring works. A few things still sit outside the recipe and need operator attention before this carries real load:
/dev/ttyUSB0; you need to pass a CC2652, EFR32MG21, or ConBee II through to the VM.allow_anonymous true is fine in a lab, not on a network where guests can talk to the broker. Add mosquitto_passwd users and ACLs.Do I really need the MQTT broker if I only have Matter devices? No — Matter and Thread devices attach to Home Assistant over IP and don't touch MQTT. But the moment you add a Zigbee stick (via Zigbee2MQTT), an ESPHome sensor, or a Node-RED flow, Mosquitto becomes the shared bus they all meet on. Building it in from day one costs one small VM and saves a re-architecture later.
Why Zigbee2MQTT instead of the built-in ZHA integration? Both are valid and both are local. ZHA lives inside Home Assistant; Zigbee2MQTT runs as its own service and publishes to MQTT, which is exactly why it gets its own VM here — you can restart or re-flash the coordinator without bouncing the hub.
Is anything phoning home? Not by default. Core control is local. Cloud only enters if you add a cloud integration or pick cloud voice — which is the whole point of running your own hub instead of a vendor appliance.
Smart-home traffic deserves a private DNS layer to keep cloud trackers off your network. See the Pi-hole DNS cluster post. For verifying the kernel under the smart-home stack, read the runtime attestation post. And the Enterprise & GxP page covers larger rollouts.
OpenFactory's free flow is for browsing. Persistent VMs, SSH access, snapshots, your own ISO, and fleet deployment live on a paid plan.