Skip to content

Home Assistant & Zigbee Stack

Home Assistant Github GitHub

Z2MQTT Github GitHub

Eclipse Mosquitto Github GitHub


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.

  1. Plug the dongle into a USB 2.0 port (or use a USB 2.0 extension cable to avoid USB 3.0 interference).
  2. Run the following to identify your specific device ID:

    ls -l /dev/serial/by-id/
    

  3. Copy the output string. It usually looks like usb-SONOFF_SONOFF_Dongle_Plus_MG24_<SERIAL>-if00-port0 or 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.

compose.yaml
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:

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:

acl
# 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.

configuration.yaml
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:

sudo chmod 600 /mnt/appdata/zigbee2mqtt/data/configuration.yaml

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:

docker compose up -d
docker compose ps    # all three should become (healthy)

Pairing Devices (Example: ThirdReality ZL1 Bulb)

  1. Access Z2M: Open http://<YOUR_SERVER_IP>:8082 and enter the frontend token.
  2. Permit Join: Click Permit Join (All) in the top bar to allow new devices.
  3. Reset Bulb:
  4. Screw in the ThirdReality ZL1 bulb.
  5. Power Cycle 5 Times: Turn the switch OFF and ON 5 times quickly (approx. 1 second per toggle).
  6. Sequence: ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON.
  7. Confirmation: The bulb will flash a sequence of colors (Warm White -> Cool White -> Red -> Green -> Blue) and stay solid Warm White.

  8. 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

  1. Access HA: Open http://<YOUR_SERVER_IP>:8123.
  2. Integrations: Go to Settings > Devices & Services.
  3. Discovery: HA should auto-discover "MQTT". If not, click Add Integration > MQTT.
  4. Broker Settings:
  5. Broker: localhost (Since HA is on the host network).
  6. Port: 1883.
  7. Username: homeassistant, Password: the one you set with mosquitto_passwd.

  8. 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.yaml
    http:
      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: 5
    

    Banned IPs land in ip_bans.yaml in the config dir; delete the line and restart HA to lift a ban. Get trusted_proxies right 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 in secrets.yaml (password: !secret mqtt_password) rather than inline in configuration.yaml, so the main config can be shared or committed without leaking anything.

Check the config before every restart:

docker exec homeassistant python -m homeassistant --script check_config --config /config

Maintenance

Updates

Bump the pinned tags (or merge the Renovate PR), then recreate the containers. Your data is safe in the persistent volumes.

docker compose pull
docker compose up -d

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:

docker compose logs zigbee2mqtt
  • Error: "Error: Error: No such file or directory...": Check your USB mapping in compose.yaml. The ID must match exactly what ls -l /dev/serial/by-id/ shows.
  • Error: "Adapter failed to start": Ensure adapter: ember is in your configuration.yaml.
  • Connection refused: Not authorized in a client's log: wrong user/password, or the user has no ACL entry. See who the broker is refusing:

    sudo grep -a 'not authori' /mnt/appdata/mosquitto/log/mosquitto.log | tail
    
  • Mosquitto is unhealthy but clients work: the healthcheck user's password in healthcheck.pw doesn't match passwd, or its ACL line is missing.

Testing button and switch automations end-to-end is covered in Lessons learned: Zigbee2MQTT buttons.


Sources & further reading