cloudsium Made to be shared.
← All notes
Jakob Boghdady

Moving Zigbee2MQTT without re-pairing the house

A host-only migration: preserve the coordinator and data directory, start one Compose service, then compare Home Assistant entity IDs before and after.

I moved one Zigbee2MQTT instance from a Home Assistant add-on to Docker on a support Pi. The coordinator was already reachable over the network. Keeping that radio in place let me change the application host without rebuilding the Zigbee network.

The critical state was the existing data directory, coordinator identity, MQTT naming and application version. A container reporting “healthy” would not establish that those survived.

What moved, and what stayed

Paired Zigbee devices still talk to the same network coordinator. The coordinator connects over TCP port 6638 to Zigbee2MQTT in Docker on the support Pi. The preserved data directory and existing MQTT broker feed the existing Home Assistant entities.

View the topology at full size.

Keep stable Change deliberately
Physical coordinator and adapter identity Host running Zigbee2MQTT
Network key, PAN identity and paired-device state Data directory's host path
Friendly names and MQTT base topic Broker hostname reachable from the Pi
Zigbee2MQTT version for the first boot Container frontend binding

This was a host move with the same coordinator. Replacing a coordinator has different compatibility and backup requirements; the Zigbee2MQTT FAQ distinguishes those cases. I did not combine this cutover with an application upgrade.

Stop the old writer before taking the final copy

I stopped the source add-on and disabled its watchdog before copying the final state. Only one Zigbee2MQTT process should own the coordinator. The source data includes credentials, so the transfer archive belongs outside the repository with restrictive permissions.

The following is a Linux shell template for a stopped instance whose data directory is accessible on that machine. The path is illustrative; identify the actual add-on storage first. Stopping the add-on is an authorized Home Assistant operation, not something these filesystem commands do.

set -eu
umask 077
SOURCE_DATA=/path/to/stopped/zigbee2mqtt/data
BACKUP_DIR=$(mktemp -d)

test -s "$SOURCE_DATA/configuration.yaml"
test -s "$SOURCE_DATA/database.db"
tar -C "$SOURCE_DATA" -czf "$BACKUP_DIR/zigbee2mqtt-data.tgz" .
(
  cd "$BACKUP_DIR"
  sha256sum zigbee2mqtt-data.tgz > zigbee2mqtt-data.tgz.sha256
  sha256sum --check zigbee2mqtt-data.tgz.sha256
)

Transfer the archive and its checksum over the existing trusted administration connection. Verify the checksum on the destination before extracting into an empty, private data directory. Do not print configuration.yaml, paste it into an issue or commit the archive. A hash checks transfer integrity; it does not prove the backup can restore a working network.

The MQTT hostname needed attention: an add-on-only DNS name was not resolvable from the Pi. I changed that address to the existing broker's reachable endpoint while preserving credentials and the base topic. The radio remained the same network coordinator on TCP port 6638.

Start exactly one service

The real Compose file contains three Zigbee2MQTT instances. Running the whole deployment playbook could affect all three, so the cutover targeted one service. Here is the relevant structure with generalized names:

services:
  zigbee2mqtt_migrated:
    image: koenkk/zigbee2mqtt:2.7.2
    container_name: zigbee2mqtt_migrated
    volumes:
      - ./private/data_migrated:/app/data
    ports:
      - '127.0.0.1:8081:8080'
    restart: always

2.7.2 is the historical source version used for this move, not a recommendation for a new installation. The loopback frontend binding is an illustrative local-access choice; use the access path intended for your deployment. Zigbee2MQTT documents the /app/data volume contract.

The migrated configuration enables the frontend on container port 8080. After staging the exact image and reviewing the rendered Compose configuration:

docker compose config --quiet
docker compose up -d --no-deps --pull never zigbee2mqtt_migrated
docker compose ps zigbee2mqtt_migrated
docker compose logs --tail 100 zigbee2mqtt_migrated

--no-deps limits the start operation; --pull never prevents an implicit image download during cutover. Review logs locally for adapter startup, broker connection and device recovery. Avoid sharing raw logs before checking for private device information.

Compare identities, then test behavior

I captured Home Assistant's baseline before the move and compared it afterward. The REST API exposes current states at /api/states. With credentials already supplied through your normal secret handling, capture each snapshot at its respective point in the cutover:

umask 077
# Before stopping the source:
curl --fail --silent --show-error \
  -H "Authorization: Bearer ${HA_TOKEN:?set HA_TOKEN}" \
  "${HA_URL:?set HA_URL}/api/states" > before.json

# After the migrated instance has connected and discovery has settled:
curl --fail --silent --show-error \
  -H "Authorization: Bearer ${HA_TOKEN:?set HA_TOKEN}" \
  "${HA_URL:?set HA_URL}/api/states" > after.json

node compare-ha-entities.mjs before.json after.json

Download the entity comparison script. It requires Node.js 22 or later, prints missing and added IDs, exits 1 for missing IDs and 2 for invalid input. A successful comparison looks like:

{"missing": [], "added": []}

The script ignores changing sensor values and array order. A renamed sensor.room_temperature_2 appears as one missing ID plus one added ID, which makes accidental rediscovery visible. Comparing all states can also expose unrelated changes; inspect the affected integration before attributing them to the move.

This check covers entity_id continuity, not registry unique_id values, restored automations or physical radio behavior. In this migration, all eight paired devices remained, and the recorded baseline of 490 MQTT entities was unchanged. Passive observation alone produced no useful device events during one ten-minute window, so the user's physical device check was a separate part of acceptance.

Rollback has the same single-writer rule

Stop the destination before restarting the source. Check restart policy and the old add-on watchdog as well; otherwise a host restart can bring back a second owner later.

The retained archive and checksums were evidence of a preserved copy. I did not perform a full rollback rehearsal. The commands and comparison script here can be checked offline, but they cannot establish that a different radio, network or backup will behave like this one.