Building the Home Assistant Docker Stack: MariaDB, Z-Wave JS UI, and the One USB Path That Survives a Reboot

Building the Home Assistant Docker Stack: MariaDB, Z-Wave JS UI, and the One USB Path That Survives a Reboot

The Z-Wave USB stick is invisible inside the container. Every setup guide tells you to add a devices: entry to your compose file and pass the path /dev/ttyUSB0. On first boot it works. After a reboot, the path has rotated to /dev/ttyACM0, or shifted to /dev/ttyUSB1 because something else enumerated first, and Z-Wave JS starts clean with no devices and no error. The fix isn't complicated, but it isn't in the official documentation: /dev/ttyUSBx is a kernel assignment that changes. /dev/serial/by-id/ is a stable udev symlink that doesn't.

This builds a three-container Home Assistant stack on Rocky Linux under Portainer: HA Core as the automation engine, MariaDB as the recorder backend replacing the default SQLite, and zwavejs2mqtt as the dedicated serial bridge. The services have a fixed startup dependency order, the MariaDB connection string has one non-obvious constraint that causes silent data loss if skipped, and the HubZ stick exposes two serial ports that split across two containers — those are the things that earn explanation here.

Jump to Commands Reference →


Create the Directory Tree First

Docker does not create missing bind-mount paths. On Rocky Linux with SELinux enforcing, a missing directory at mount time produces a permission error that looks like an SELinux denial — it isn't, the directory simply doesn't exist. Create all three trees before anything starts:

mkdir -p /working/docker/homeassistant/config
mkdir -p /working/docker/mariadb/data
mkdir -p /working/docker/zwavejs2mqtt/config

Find the Stable Serial Paths Before Writing the Stack

The HubZ is a combo Zigbee/Z-Wave controller that exposes two serial ports — one per radio. Before touching the compose file, confirm both paths:

ls -la /dev/serial/by-id/

This produces something like:

lrwxrwxrwx 1 root root 13 Mar  9 18:21 usb-Silicon_Labs_HubZ_Smart_Home_Controller_515019A3-if00-port0 -> ../../ttyUSB0
lrwxrwxrwx 1 root root 13 Mar  9 18:21 usb-Silicon_Labs_HubZ_Smart_Home_Controller_515019A3-if01-port1 -> ../../ttyUSB1

The symlink name is the stable identifier — built from USB vendor, product, and serial number, none of which change between reboots. The target (ttyUSB0, ttyUSB1) is what changes. For the HubZ, if00 is the Z-Wave radio and goes to zwavejs2mqtt; if01 is Zigbee and goes to the HA container directly. Copy both full symlink paths — you'll use them in the devices: mappings in the stack definition.

If your stick doesn't appear here, check dmesg | grep tty after plugging it in to confirm the host sees it at all.


The Stack Definition

Three services, one compose file. All three run on a single external bridge network — INTERNAL_BRIDGE — with ports bound to the host interface. HA reaches MariaDB and zwavejs2mqtt by container name via Docker DNS. This matters for the recorder connection string covered in the next section.

Both HA and zwavejs2mqtt require privileged: true. Without it, SELinux on Rocky Linux blocks the containers from holding exclusive access to their respective serial ports. The failure mode is deceptive: the container starts without error, the device path is mapped, and no devices appear. Running privileged: true bypasses this. If your threat model requires avoiding privileged containers, the alternative is labeling the device nodes with the correct SELinux context and adding the LOCK capability — that's a separate project.

Volume mounts use :z throughout — the shared SELinux label, appropriate here because the underlying host filesystem is accessible across this stack. Omitting the label entirely on Rocky Linux with SELinux enforcing causes silent write failures that surface only when HA tries to update its configuration or zwavejs2mqtt tries to save device state.

Substitute your actual serial paths and strong passwords before deploying:

version: "3.8"

services:
  homeassistant:
    image: homeassistant/home-assistant:latest
    container_name: homeassistant
    restart: unless-stopped
    privileged: true
    depends_on:
      - mariadb
      - zwavejs2mqtt
    devices:
      - /dev/serial/by-id/YOUR_HUBZ_if01_PATH:/dev/ttyUSB1
    environment:
      - TZ=America/New_York
    volumes:
      - /working/docker/homeassistant/config:/config:z
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "8123:8123"
    networks:
      - INTERNAL_BRIDGE

  mariadb:
    image: mariadb:latest
    container_name: mariadb
    restart: unless-stopped
    environment:
      - MARIADB_ROOT_PASSWORD=CHANGE_ME_ROOT
      - MARIADB_DATABASE=homeassistantdb
      - MARIADB_USER=homeassistant
      - MARIADB_PASSWORD=CHANGE_ME_HASS
    volumes:
      - /working/docker/mariadb/data:/var/lib/mysql:z
      - /etc/localtime:/etc/localtime:ro
    networks:
      - INTERNAL_BRIDGE

  zwavejs2mqtt:
    image: zwavejs/zwavejs2mqtt:latest
    container_name: zwavejs2mqtt
    restart: unless-stopped
    privileged: true
    tty: true
    stop_signal: SIGINT
    devices:
      - /dev/serial/by-id/YOUR_HUBZ_if00_PATH:/dev/ttyUSB0
    environment:
      - TZ=America/New_York
    volumes:
      - /working/docker/zwavejs2mqtt/config:/usr/src/app/store:z
    ports:
      - "9016:8091"
      - "3000:3000"
    networks:
      - INTERNAL_BRIDGE

networks:
  INTERNAL_BRIDGE:
    external: true

If port 8123 needs to be reachable from off-host, open it in the firewall:

firewall-cmd --permanent --add-port=8123/tcp
firewall-cmd --reload
firewall-cmd --list-all

Port 9016 (zwavejs2mqtt web UI) and 3000 (WebSocket) follow the same logic — open them only if you need off-host access.


Wire HA to MariaDB and Z-Wave

Two configuration pieces before starting HA for the first time with this stack.

Add the recorder block to /working/docker/homeassistant/config/configuration.yaml:

recorder:
  db_url: mysql+mysqldb://homeassistant:CHANGE_ME_HASS@mariadb/homeassistantdb?charset=utf8mb4

Two things here that break silently if wrong. The hostname is mariadb — the container name resolved via Docker DNS — not localhost. HA is on a bridge network; localhost resolves to the HA container itself and the connection fails without a useful error. The charset=utf8mb4 parameter is not decoration: HA writes state values and entity names containing characters that exceed standard utf8's three-byte ceiling. Without it, those writes fail silently — the recorder appears healthy, but specific states are dropped and the gaps only show up in history.

Start the stack. MariaDB initialises the homeassistantdb database on first run using the environment variables. HA creates its schema on first start. MariaDB's internal init takes a few seconds after the container is up — if HA logs a recorder connection error on first start, wait 30 seconds and restart the HA container.

Add the Z-Wave JS integration from within HA: Settings → Integrations → Add Integration → Z-Wave JS. When prompted for the WebSocket server URL, enter ws://zwavejs2mqtt:3000. Z-Wave devices appear under Settings → Devices once the integration connects and zwavejs2mqtt completes its network interview.


If You're Migrating from a Running SQLite Instance

There is no in-place migration path from SQLite to MariaDB. The recorder schema differs enough that no supported import exists, and existing history cannot be transferred without third-party tooling that carries its own complexity and failure surface. The practical path is to start fresh.

Before adding the MariaDB db_url, purge the SQLite recorder from HA Developer Tools → Services. Call recorder.purge with keep_days: 0 and repack: true. This compacts the SQLite database and clears internal state. Shut down HA, add the db_url to configuration.yaml, start MariaDB first, then restart HA. After confirming the MariaDB recorder is writing correctly — check Settings → System → Logs for recorder errors — the old home-assistant_v2.db file in the config volume can be deleted.

History from the SQLite period is gone. Automations, integrations, and entity configuration live in configuration.yaml and the .storage/ directory — those survive intact.


Commands Reference

# Create host directories
mkdir -p /working/docker/homeassistant/config
mkdir -p /working/docker/mariadb/data
mkdir -p /working/docker/zwavejs2mqtt/config

# Confirm stable serial device paths
ls -la /dev/serial/by-id/

# If serial device isn't visible
dmesg | grep tty

# Open firewall ports if needed
firewall-cmd --permanent --add-port=8123/tcp
firewall-cmd --permanent --add-port=9016/tcp
firewall-cmd --permanent --add-port=3000/tcp
firewall-cmd --reload
firewall-cmd --list-all
# docker-compose.yml
version: "3.8"

services:
  homeassistant:
    image: homeassistant/home-assistant:latest
    container_name: homeassistant
    restart: unless-stopped
    privileged: true
    depends_on:
      - mariadb
      - zwavejs2mqtt
    devices:
      - /dev/serial/by-id/YOUR_HUBZ_if01_PATH:/dev/ttyUSB1
    environment:
      - TZ=America/New_York
    volumes:
      - /working/docker/homeassistant/config:/config:z
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "8123:8123"
    networks:
      - INTERNAL_BRIDGE

  mariadb:
    image: mariadb:latest
    container_name: mariadb
    restart: unless-stopped
    environment:
      - MARIADB_ROOT_PASSWORD=CHANGE_ME_ROOT
      - MARIADB_DATABASE=homeassistantdb
      - MARIADB_USER=homeassistant
      - MARIADB_PASSWORD=CHANGE_ME_HASS
    volumes:
      - /working/docker/mariadb/data:/var/lib/mysql:z
      - /etc/localtime:/etc/localtime:ro
    networks:
      - INTERNAL_BRIDGE

  zwavejs2mqtt:
    image: zwavejs/zwavejs2mqtt:latest
    container_name: zwavejs2mqtt
    restart: unless-stopped
    privileged: true
    tty: true
    stop_signal: SIGINT
    devices:
      - /dev/serial/by-id/YOUR_HUBZ_if00_PATH:/dev/ttyUSB0
    environment:
      - TZ=America/New_York
    volumes:
      - /working/docker/zwavejs2mqtt/config:/usr/src/app/store:z
    ports:
      - "9016:8091"
      - "3000:3000"
    networks:
      - INTERNAL_BRIDGE

networks:
  INTERNAL_BRIDGE:
    external: true
# /working/docker/homeassistant/config/configuration.yaml
recorder:
  db_url: mysql+mysqldb://homeassistant:CHANGE_ME_HASS@mariadb/homeassistantdb?charset=utf8mb4
Ryan Mckernan
Author

Ryan Mckernan

IT service manager & infrastructure architect turning real-world IT messes into practical, documented fixes. I build systems, streamline ops, and share field notes at Painfully Useful—tested, refined, and repeatable.