Home Assistant & Zigbee Stack¶
Overview¶
This integrates Home Assistant, Zigbee2MQTT (Z2M), and an MQTT Broker (Mosquitto) to create a local smarthome.
I'm running this on a Sonoff Dongle Plus MG24, which uses the Silicon Labs EFR32MG24 chip. Silicon Labs adapters need the ember driver in Zigbee2MQTT, unlike the older Texas Instruments-based dongles (which use zstack).
The stack runs via Docker Compose on a Debian 13 server, with Home Assistant in host networking mode for device discovery. Zigbee2MQTT and Mosquitto share the stack's default network, so Z2M reaches the broker as mosquitto. Frigate runs on a different host and connects to the broker over the LAN, which is why port 1883 is published.
I'm a big fan of the Zigbee system as each mains-powered device acts as a repeater for the other devices. Also, because Zigbee runs on a separate protocol, your devices will continue working even without Wi-Fi.
MQTT requires a login. Every client (Home Assistant, Zigbee2MQTT, Frigate and the broker's own healthcheck) has its own user and password, and anonymous connections are refused. Anything that can reach port 1883 could otherwise read every topic and send commands to every Zigbee device in the house, and the broker is published on the LAN for Frigate.
Installation¶
1. Hardware Preparation¶
Identify the Dongle Before configuring software, make sure the dongle is recognized.
- Plug the dongle into a USB 2.0 port (or use a USB 2.0 extension cable to avoid USB 3.0 interference).
-
Run the following to identify your specific device ID:
-
Copy the output string. It usually looks like
usb-SONOFF_SONOFF_Dongle_Plus_MG24_<SERIAL>-if00-port0or similar. You will need this for your Compose file.
Use the by-id path
/dev/ttyACM0 / /dev/ttyUSB0 can swap between reboots if you plug in another USB serial device. The
/dev/serial/by-id/... path always points at the same dongle.
2. Directory Structure¶
Create a persistent directory structure for configuration and data. I keep app data on its own disk under /mnt/appdata:
sudo mkdir -p /mnt/appdata/homeassistant/config \
/mnt/appdata/zigbee2mqtt/data \
/mnt/appdata/mosquitto/{config,data,log}
3. Docker Compose¶
Create compose.yaml for the stack (e.g. in /opt/docker/homeassistant/).
Port Mapping
This configuration maps the Zigbee2MQTT frontend to port 8082 to avoid conflicts, and maps the USB device. Ensure the devices section matches the ID you found in Step 1.
services:
homeassistant:
image: lscr.io/linuxserver/homeassistant:2026.10.2-ls258
container_name: homeassistant
restart: unless-stopped
network_mode: host # Critical for Home Assistant discovery
environment:
- TZ=<TZ>
volumes:
- /mnt/appdata/homeassistant/config:/config
# No official HEALTHCHECK in the image - plain HTTP check
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8123/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
zigbee2mqtt:
image: koenkk/zigbee2mqtt:2.14.2
container_name: zigbee2mqtt
restart: unless-stopped
ports:
- "8082:8080" # Web Interface
environment:
- TZ=<TZ>
volumes:
- /mnt/appdata/zigbee2mqtt/data:/app/data
- /run/udev:/run/udev:ro
devices:
# UPDATE THIS to match your 'ls -l /dev/serial/by-id/' output
- /dev/serial/by-id/usb-SONOFF_SONOFF_Dongle_Plus_MG24_<SERIAL>-if00-port0:/dev/ttyACM0
depends_on:
- mosquitto
# No official HEALTHCHECK upstream - checks the frontend responds
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8080/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
mosquitto:
image: eclipse-mosquitto:2.1.2-alpine
container_name: mosquitto
restart: unless-stopped
ports:
- "1883:1883" # published: Frigate on another host connects over the LAN
volumes:
- /mnt/appdata/mosquitto/config:/mosquitto/config
- /mnt/appdata/mosquitto/data:/mosquitto/data
- /mnt/appdata/mosquitto/log:/mosquitto/log
# A real broker round trip: subscribe to the broker's own uptime topic.
# Anonymous access is off, so it logs in as a read-only `healthcheck` user.
healthcheck:
test: ["CMD-SHELL", "mosquitto_sub -u healthcheck -P \"$$(cat /mosquitto/config/healthcheck.pw)\" -t '$$SYS/broker/uptime' -C 1 -W 5 >/dev/null"]
interval: 60s
timeout: 10s
retries: 3
start_period: 20s
Image tags
All three images are pinned, and Renovate opens a PR for each update. Mosquitto and Zigbee2MQTT majors carry breaking
config changes, and Mosquitto 2.1.x is only published as -alpine tags. Home Assistant's monthly releases are
reviewed by hand rather than auto-merged, because they occasionally break HACS integrations (which no image pin
covers). Zigbee2MQTT is also published as ghcr.io/koenkk/zigbee2mqtt, which is the registry its docs now use;
either works.
Why the healthchecks look like this
None of the three images ship a HEALTHCHECK. The Home Assistant and Z2M checks just confirm the web UI answers.
The Mosquitto check does a real subscribe, so a broker that is running but refusing logins shows up as
unhealthy. Port 9001 (MQTT over websockets) is not published because nothing uses it and the config has no
websockets listener.
LinuxServer image vs official image
This uses the LinuxServer.io build of Home Assistant. Home Assistant's own docs recommend the
official image ghcr.io/home-assistant/home-assistant:stable, run with network_mode: host, privileged: true
(for USB/Bluetooth access), and /etc/localtime:/etc/localtime:ro + /run/dbus:/run/dbus:ro mounts. Either works for
this stack; see the Home Assistant container install docs.
LinuxServer images run the app as PUID/PGID, which default to 911:911 when unset (as above). That's why
files under /config end up owned by uid 911 on the host. Add PUID=<PUID> and PGID=<PGID> if you'd rather they
belong to your user. Without privileged/NET_ADMIN, HA logs a Bluetooth warning at startup; it's harmless if you
don't use Bluetooth.
Configuration¶
You must create the configuration files for Mosquitto and Zigbee2MQTT before starting the containers, or they will likely crash on boot.
1. Mosquitto Broker¶
Mosquitto 2.x refuses anonymous clients by default once a listener is defined; this setup makes that explicit and adds a password file plus an ACL.
Create /mnt/appdata/mosquitto/config/mosquitto.conf:
listener 1883
allow_anonymous false
password_file /mosquitto/config/passwd
acl_file /mosquitto/config/acl
persistence true
persistence_location /mosquitto/data/
log_dest file /mosquitto/log/mosquitto.log
Create /mnt/appdata/mosquitto/config/acl. One user per client means you can see in the broker log exactly which client
connected (or was refused), rotate one password without touching the others, and give a client less than full access:
# HA, Z2M and Frigate need the whole tree (discovery, the z2m bridge, frigate/*).
user homeassistant
topic readwrite #
user zigbee2mqtt
topic readwrite #
user frigate
topic readwrite #
# Container healthcheck: may only read the broker's own stats.
# ('#' never matches $SYS topics, so they need their own line.)
user healthcheck
topic read $SYS/#
Create the users. mosquitto_passwd hashes the password; -c creates the file, so only use it for the first user.
Running it through a throwaway container means you don't need Mosquitto installed on the host:
cd /mnt/appdata/mosquitto/config
IMG=eclipse-mosquitto:2.1.2-alpine
sudo docker run --rm -it -v "$PWD:/mosquitto/config" $IMG mosquitto_passwd -c /mosquitto/config/passwd homeassistant
for u in zigbee2mqtt frigate healthcheck; do
sudo docker run --rm -it -v "$PWD:/mosquitto/config" $IMG mosquitto_passwd /mosquitto/config/passwd "$u"
done
# The healthcheck reads its password from a file next to passwd/acl
printf '%s' '<HEALTHCHECK_PASSWORD>' | sudo tee healthcheck.pw >/dev/null
sudo chmod 600 passwd acl healthcheck.pw
File ownership
Since 2.0.16 Mosquitto logs a warning if passwd/acl are world-readable or owned by a different user than the
broker runs as, and says future versions will refuse to open them. Keep them 0600. If the broker can't read them
after you change ownership, check the log in /mnt/appdata/mosquitto/log/.
To add a client later: add a user with docker exec -it mosquitto mosquitto_passwd /mosquitto/config/passwd <user>, add a
user <user> block to acl, then docker restart mosquitto.
2. Zigbee2MQTT¶
Create /mnt/appdata/zigbee2mqtt/data/configuration.yaml:
Driver Selection
The adapter: ember setting is critical for the Sonoff MG24. If you omit this, Z2M will try to use the default driver and fail to start.
version: 5 # config schema version; Z2M migrates and bumps this itself
homeassistant:
enabled: true
mqtt:
base_topic: zigbee2mqtt
server: mqtt://mosquitto:1883
user: zigbee2mqtt
password: <MQTT_Z2M_PASSWORD>
# Serial settings for the Sonoff MG24 (Ember driver).
# This path inside the container is mapped from the host in the compose file.
serial:
port: /dev/ttyACM0
adapter: ember
advanced:
log_level: info
# network_key / pan_id / ext_pan_id are generated on first start.
# Don't share them: the network key decrypts all Zigbee traffic.
frontend:
enabled: true
port: 8080
auth_token: <Z2M_FRONTEND_TOKEN>
# Zigbee2MQTT 2.x removed the `permit_join` setting.
# Joining is enabled from the frontend (max 254 seconds) or over MQTT.
frontend.auth_token makes the web UI ask for a token. Without it, anyone who can reach port 8082 can pair, rename or
remove devices. Generate one with openssl rand -hex 24.
Z2M writes the generated network key into this file, so lock it down after first start:
Keeping passwords out of configuration.yaml
Z2M can read values from a separate file: put password: '!secret.yaml password' (quotes included) in
configuration.yaml and password: ... in secret.yaml next to it, then chmod 600 secret.yaml. The same works
for auth_token. See the Z2M MQTT docs and
securing guide.
Setup & Device Pairing¶
Once the files are created, launch the stack:
Pairing Devices (Example: ThirdReality ZL1 Bulb)¶
- Access Z2M: Open
http://<YOUR_SERVER_IP>:8082and enter the frontend token. - Permit Join: Click Permit Join (All) in the top bar to allow new devices.
- Reset Bulb:
- Screw in the ThirdReality ZL1 bulb.
- Power Cycle 5 Times: Turn the switch OFF and ON 5 times quickly (approx. 1 second per toggle).
- Sequence: ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON.
-
Confirmation: The bulb will flash a sequence of colors (Warm White -> Cool White -> Red -> Green -> Blue) and stay solid Warm White.
-
Rename: Once it appears in the Z2M dashboard, click the blue "Edit" icon. Give it a friendly name (e.g., "Office_Lamp") and ensure "Update Home Assistant entity ID" is checked.
Connecting to Home Assistant¶
- Access HA: Open
http://<YOUR_SERVER_IP>:8123. - Integrations: Go to Settings > Devices & Services.
- Discovery: HA should auto-discover "MQTT". If not, click Add Integration > MQTT.
- Broker Settings:
- Broker:
localhost(Since HA is on the host network). - Port:
1883. -
Username:
homeassistant, Password: the one you set withmosquitto_passwd. -
Verify: Your "Office_Lamp" should now appear as a device in Home Assistant.
To change the MQTT login later: Settings > Devices & Services > MQTT > Configure > Re-configure MQTT.
Securing Home Assistant¶
Home Assistant controls locks, cameras and lights, so treat its login like any other admin UI:
- Enable multi-factor authentication for every human user (Profile > Security > Multi-factor authentication modules > TOTP). Users who never need remote access can be set to Can only log in from the local network.
-
Ban repeated failed logins. HA never bans by default. Add this to
configuration.yaml:configuration.yamlhttp: use_x_forwarded_for: true # only if HA sits behind a reverse proxy trusted_proxies: - 127.0.0.1 - <PROXY_NETWORK_SUBNET> # the Docker network your reverse proxy / tunnel runs on ip_ban_enabled: true login_attempts_threshold: 5Banned IPs land in
ip_bans.yamlin the config dir; delete the line and restart HA to lift a ban. Gettrusted_proxiesright first, or the ban hits your proxy's IP and locks everyone out. - Keep it LAN/VPN-only, or put it behind SSO. The simplest option is no public hostname at all and reaching HA over a VPN (WireGuard, Tailscale). If you do publish it, put it behind an identity-aware proxy and only bypass it for the paths the Companion App needs (see Remote access). - Keep secrets insecrets.yaml(password: !secret mqtt_password) rather than inline inconfiguration.yaml, so the main config can be shared or committed without leaking anything.
Check the config before every restart:
Maintenance¶
Updates¶
Bump the pinned tags (or merge the Renovate PR), then recreate the containers. Your data is safe in the persistent volumes.
Read the Home Assistant release notes' Backward-incompatible changes before a monthly upgrade, and update HACS integrations (Frigate in particular) right after, since they are not covered by the image pin.
Troubleshooting¶
If Zigbee2MQTT fails to start, check the logs immediately:
- Error: "Error: Error: No such file or directory...": Check your USB mapping in
compose.yaml. The ID must match exactly whatls -l /dev/serial/by-id/shows. - Error: "Adapter failed to start": Ensure
adapter: emberis in yourconfiguration.yaml. -
Connection refused: Not authorizedin a client's log: wrong user/password, or the user has no ACL entry. See who the broker is refusing: -
Mosquitto is
unhealthybut clients work: the healthcheck user's password inhealthcheck.pwdoesn't matchpasswd, or its ACL line is missing.
Testing button and switch automations end-to-end is covered in Lessons learned: Zigbee2MQTT buttons.
Sources & further reading¶
- mosquitto.conf man page source (
allow_anonymous,password_file,acl_file) - mosquitto_passwd man page source
- eclipse-mosquitto Docker image
- Zigbee2MQTT: Docker install
- Zigbee2MQTT: MQTT options
- Zigbee2MQTT: securing your setup
- Zigbee2MQTT: frontend options
- Zigbee2MQTT: Ember adapters
- Home Assistant: HTTP integration (
ip_ban_enabled,trusted_proxies) - Home Assistant: multi-factor authentication
- LinuxServer: understanding PUID and PGID