IoT & Home Automation
    TLS + ACLs

    Deploy Mosquitto + Zigbee2MQTT on a VPS

    Run Mosquitto with TLS and ACLs alongside Zigbee2MQTT on a RamNode VPS using a network-attached Zigbee coordinator tunneled from your home network.

    Mosquitto is the most widely deployed open-source MQTT broker, and Zigbee2MQTT bridges Zigbee devices to MQTT, removing the need for vendor hubs like SmartThings or Hue Bridge. Running both on a RamNode VPS gives you a centralized MQTT fabric for IoT projects, home automation backends, and remote telemetry, without depending on cloud services.

    This guide covers a production deployment of Mosquitto with TLS, password and ACL-based authentication, and a Zigbee2MQTT instance that connects to a network-attached Zigbee coordinator on your local network. Because a VPS has no USB ports, the Zigbee radio must live on-prem and expose itself over TCP, which is the only realistic architecture for cloud-hosted Zigbee2MQTT.

    Architecture Overview

    There are three sensible ways to combine these services with a VPS:

    1. Broker only: Mosquitto runs on the VPS, and Zigbee2MQTT runs at home on a Raspberry Pi or mini-PC with a USB Zigbee coordinator. The local Zigbee2MQTT publishes to the VPS over TLS-MQTT.
    2. Broker plus remote-coordinator Zigbee2MQTT: Both Mosquitto and Zigbee2MQTT run on the VPS. A network-attached Zigbee coordinator (such as the SMLIGHT SLZB-06, ITead Zigbee 3.0 USB Dongle Plus paired with ser2net, or any TCP-exposed coordinator) lives at home. Zigbee2MQTT connects to it over the public internet, ideally tunneled.
    3. Hybrid: Mosquitto on the VPS, multiple Zigbee2MQTT instances at different physical sites, each publishing to the same broker.

    This guide walks through option 2, which is the most demanding and covers everything needed for options 1 and 3 as a subset.

    Resource Requirements

    For a RamNode VPS you should plan for the following minimums:

    • CPU: 1 vCPU
    • RAM: 1 GB (Mosquitto uses around 15 MB, Zigbee2MQTT around 150 MB)
    • Disk: 10 GB SSD
    • OS: Ubuntu 24.04 LTS or Debian 12

    A 2 GB plan gives comfortable headroom and room to add Node-RED or a small time-series store like VictoriaMetrics later.

    Prerequisites

    • A RamNode VPS with Ubuntu 24.04 installed
    • A domain name pointed at the VPS IPv4 address (A record on mqtt.example.com)
    • SSH access as a non-root sudo user
    • A network-attached Zigbee coordinator on your home network, with a public-facing tunnel (Cloudflare Tunnel, Tailscale, WireGuard, or a port-forwarded TCP port behind firewall ACLs)

    If you don't already have a tunnel from your VPS into your home network, set up Tailscale or WireGuard before continuing. Exposing a Zigbee coordinator on the open internet without authentication is a serious security risk.

    Initial Server Hardening

    Log in and apply baseline hardening before installing anything:

    shell
    sudo apt update && sudo apt upgrade -y
    sudo apt install -y ufw fail2ban unattended-upgrades curl gnupg ca-certificates
    sudo dpkg-reconfigure --priority=low unattended-upgrades

    Configure the firewall with a deny-by-default posture:

    shell
    sudo ufw default deny incoming
    sudo ufw default allow outgoing
    sudo ufw allow 22/tcp comment 'SSH'
    sudo ufw allow 80/tcp comment 'HTTP for Lets Encrypt'
    sudo ufw allow 443/tcp comment 'HTTPS'
    sudo ufw allow 8883/tcp comment 'MQTT over TLS'
    sudo ufw enable
    sudo ufw status verbose

    Note that we are not opening 1883 (plaintext MQTT). All client connections will use 8883 with TLS.

    If you have a static IP or a known set of admin IPs, restrict SSH further:

    shell
    sudo ufw delete allow 22/tcp
    sudo ufw allow from 203.0.113.42 to any port 22 proto tcp

    Install Docker

    We will run Mosquitto and Zigbee2MQTT in containers for clean isolation and easy upgrades.

    shell
    curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
    echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
    sudo apt update
    sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
    sudo usermod -aG docker $USER
    newgrp docker

    Verify with docker compose version. You should see a v2.x release.

    Directory Layout

    Create a clean project structure so volumes and configs are easy to back up:

    shell
    sudo mkdir -p /opt/mqtt/{mosquitto/{config,data,log},zigbee2mqtt/data,certs}
    sudo chown -R $USER:$USER /opt/mqtt

    Generate TLS Certificates with Let's Encrypt

    We will issue certificates with certbot standalone mode, then mount them read-only into Mosquitto.

    shell
    sudo apt install -y certbot
    sudo certbot certonly --standalone -d mqtt.example.com --agree-tos --register-unsafely-without-email --non-interactive

    The certificates land in /etc/letsencrypt/live/mqtt.example.com/. Mosquitto running as a non-root user inside the container cannot read these by default, so we deploy a renewal hook that copies them into our project directory with appropriate permissions:

    Create /etc/letsencrypt/renewal-hooks/deploy/mosquitto.sh:

    shell
    sudo tee /etc/letsencrypt/renewal-hooks/deploy/mosquitto.sh > /dev/null <<'EOF'
    #!/bin/bash
    set -e
    DOMAIN="mqtt.example.com"
    DEST="/opt/mqtt/certs"
    cp /etc/letsencrypt/live/${DOMAIN}/fullchain.pem ${DEST}/fullchain.pem
    cp /etc/letsencrypt/live/${DOMAIN}/privkey.pem ${DEST}/privkey.pem
    chown 1883:1883 ${DEST}/fullchain.pem ${DEST}/privkey.pem
    chmod 644 ${DEST}/fullchain.pem
    chmod 600 ${DEST}/privkey.pem
    docker restart mosquitto || true
    EOF
    sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/mosquitto.sh
    sudo /etc/letsencrypt/renewal-hooks/deploy/mosquitto.sh

    UID 1883 is the mosquitto user inside the Eclipse Mosquitto image.

    Mosquitto Configuration

    Create /opt/mqtt/mosquitto/config/mosquitto.conf:

    shell
    persistence true
    persistence_location /mosquitto/data/
    log_dest stdout
    log_type error
    log_type warning
    log_type notice
    log_type information
    log_timestamp true
    
    # No anonymous access
    allow_anonymous false
    password_file /mosquitto/config/passwd
    acl_file /mosquitto/config/acl
    
    # TLS listener for external clients
    listener 8883
    protocol mqtt
    cafile /mosquitto/certs/fullchain.pem
    certfile /mosquitto/certs/fullchain.pem
    keyfile /mosquitto/certs/privkey.pem
    tls_version tlsv1.2
    require_certificate false
    
    # Internal listener for Zigbee2MQTT on the same Docker network
    listener 1883
    protocol mqtt
    allow_anonymous false

    Now create the password and ACL files. The password file is created empty and populated using mosquitto_passwd:

    shell
    touch /opt/mqtt/mosquitto/config/passwd
    touch /opt/mqtt/mosquitto/config/acl

    Docker Compose Manifest

    Create /opt/mqtt/docker-compose.yml:

    shell
    services:
      mosquitto:
        image: eclipse-mosquitto:2.0
        container_name: mosquitto
        restart: unless-stopped
        ports:
          - "8883:8883"
        volumes:
          - ./mosquitto/config:/mosquitto/config
          - ./mosquitto/data:/mosquitto/data
          - ./mosquitto/log:/mosquitto/log
          - ./certs:/mosquitto/certs:ro
        networks:
          - mqtt
    
      zigbee2mqtt:
        image: koenkk/zigbee2mqtt:latest
        container_name: zigbee2mqtt
        restart: unless-stopped
        depends_on:
          - mosquitto
        volumes:
          - ./zigbee2mqtt/data:/app/data
          - /run/udev:/run/udev:ro
        environment:
          - TZ=America/New_York
        ports:
          - "127.0.0.1:8081:8080"
        networks:
          - mqtt
    
    networks:
      mqtt:
        driver: bridge

    The Zigbee2MQTT frontend (port 8080 inside the container) is bound only to localhost on the host, because we will expose it through a reverse proxy with authentication, not directly.

    Creating MQTT Users

    Start Mosquitto first so we can use it to hash passwords:

    shell
    cd /opt/mqtt
    docker compose up -d mosquitto
    docker exec -it mosquitto mosquitto_passwd -b /mosquitto/config/passwd zigbee2mqtt 'STRONG_PASSWORD_HERE'
    docker exec -it mosquitto mosquitto_passwd -b /mosquitto/config/passwd dashboard 'ANOTHER_STRONG_PASSWORD'
    docker exec -it mosquitto mosquitto_passwd -b /mosquitto/config/passwd sensors 'YET_ANOTHER_PASSWORD'

    Use a password manager and 24+ character random strings. These credentials will be embedded in client configs and homes.

    ACL Policy

    Edit /opt/mqtt/mosquitto/config/acl:

    shell
    # Zigbee2MQTT has full access to its own namespace
    user zigbee2mqtt
    topic readwrite zigbee2mqtt/#
    topic readwrite homeassistant/#
    
    # Dashboard / Home Assistant reads everything but only publishes to commands
    user dashboard
    topic read zigbee2mqtt/#
    topic readwrite zigbee2mqtt/+/set
    topic readwrite zigbee2mqtt/bridge/request/#
    topic read homeassistant/#
    
    # Read-only sensors data consumer
    user sensors
    topic read zigbee2mqtt/#

    Reload Mosquitto to apply password and ACL changes:

    shell
    docker exec mosquitto kill -HUP 1

    Zigbee2MQTT Configuration

    Edit /opt/mqtt/zigbee2mqtt/data/configuration.yaml. This config assumes you reach a remote coordinator via Tailscale at 100.64.10.5:6638:

    shell
    homeassistant: true
    permit_join: false
    
    mqtt:
      base_topic: zigbee2mqtt
      server: 'mqtt://mosquitto:1883'
      user: zigbee2mqtt
      password: !secret mqtt_password
      client_id: zigbee2mqtt-vps
    
    serial:
      port: 'tcp://100.64.10.5:6638'
      adapter: zstack
    
    advanced:
      log_level: info
      network_key: GENERATE
      pan_id: GENERATE
      ext_pan_id: GENERATE
      channel: 25
    
    frontend:
      port: 8080
      host: 0.0.0.0
      auth_token: !secret frontend_token
    
    availability: true
    
    device_options:
      legacy: false
      retain: true

    Adapter values for common coordinators:

    • zstack for TI CC2652 and CC1352 (most SLZB-06 variants, Sonoff Dongle Plus)
    • ember for Silicon Labs EFR32 (Sonoff Dongle-E)
    • deconz for ConBee II
    • zboss for Nordic nRF52840 with ZBOSS firmware

    Now create the secrets file at /opt/mqtt/zigbee2mqtt/data/secret.yaml:

    shell
    mqtt_password: 'STRONG_PASSWORD_HERE'
    frontend_token: 'random-32-char-token-from-openssl-rand-hex-16'

    Generate a frontend token with openssl rand -hex 16.

    Start both services:

    shell
    docker compose up -d
    docker compose logs -f zigbee2mqtt

    Watch for Connected to MQTT server and Coordinator firmware version lines. If the coordinator connection fails, check the tunnel and confirm the coordinator's TCP port is reachable from the VPS with nc -vz 100.64.10.5 6638.

    Reverse Proxy for the Frontend

    Install Caddy for the Zigbee2MQTT web UI. Caddy handles ACME, automatic redirects, and basic auth in a few lines:

    shell
    sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
    curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
    curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
    sudo apt update
    sudo apt install -y caddy

    Generate a bcrypt hash for HTTP basic auth:

    shell
    caddy hash-password

    Replace /etc/caddy/Caddyfile:

    shell
    z2m.example.com {
        basic_auth {
            admin BCRYPT_HASH_FROM_ABOVE
        }
        reverse_proxy 127.0.0.1:8081
        encode gzip
        log {
            output file /var/log/caddy/z2m.log
        }
    }

    Reload Caddy:

    shell
    sudo systemctl reload caddy

    Caddy will issue a Let's Encrypt cert automatically on first request. Confirm the A record for z2m.example.com resolves to your VPS before reloading.

    Testing the Broker

    From a workstation, use mosquitto_clients:

    shell
    mosquitto_sub -h mqtt.example.com -p 8883 --capath /etc/ssl/certs -u sensors -P 'YET_ANOTHER_PASSWORD' -t 'zigbee2mqtt/#' -v

    In another terminal, pair a device through the Zigbee2MQTT frontend. You should see device announcements appear immediately in the subscriber.

    fail2ban for Mosquitto

    Mosquitto logs failed auth attempts. Create /etc/fail2ban/filter.d/mosquitto.conf:

    shell
    [Definition]
    failregex = Client connection from <HOST> failed: not authorised\.
                Socket error on client <HOST>, disconnecting\.
                Bad socket read/write on client <HOST>: The connection was lost\.
    ignoreregex =

    Configure /etc/fail2ban/jail.d/mosquitto.local:

    shell
    [mosquitto]
    enabled = true
    port = 1883,8883
    filter = mosquitto
    backend = systemd
    journalmatch = CONTAINER_NAME=mosquitto
    maxretry = 5
    findtime = 600
    bantime = 86400

    Restart fail2ban:

    shell
    sudo systemctl restart fail2ban
    sudo fail2ban-client status mosquitto

    Backups

    Three things matter for restore: the Mosquitto password and ACL files, the Zigbee2MQTT data directory (which holds the network key, device database, and configuration), and any custom blueprints.

    Create /usr/local/sbin/mqtt-backup.sh:

    shell
    #!/bin/bash
    set -euo pipefail
    BACKUP_DIR="/var/backups/mqtt"
    TS=$(date +%Y%m%d_%H%M%S)
    mkdir -p "$BACKUP_DIR"
    tar -czf "$BACKUP_DIR/mqtt-$TS.tar.gz" \
        -C /opt/mqtt \
        mosquitto/config \
        zigbee2mqtt/data
    find "$BACKUP_DIR" -name 'mqtt-*.tar.gz' -mtime +30 -delete

    Make it executable and schedule it nightly:

    shell
    sudo chmod +x /usr/local/sbin/mqtt-backup.sh
    echo "0 3 * * * root /usr/local/sbin/mqtt-backup.sh" | sudo tee /etc/cron.d/mqtt-backup

    The Zigbee2MQTT coordinator_backup.json file inside zigbee2mqtt/data is the only way to migrate to a new coordinator without re-pairing every device. Treat this file as critical and copy it off-server regularly.

    For off-site backups, push the archive to S3-compatible storage with rclone or restic. Avoid storing the encrypted backup on the same VPS without redundancy.

    Updates

    shell
    cd /opt/mqtt
    docker compose pull
    docker compose up -d
    docker image prune -f

    Before updating Zigbee2MQTT, check the breaking changes section of the release notes. Major versions sometimes change configuration schema or drop adapter support.

    Monitoring

    Mosquitto exposes per-broker metrics on the $SYS/# topic tree. Subscribe to verify it's healthy:

    shell
    mosquitto_sub -h mqtt.example.com -p 8883 --capath /etc/ssl/certs -u dashboard -P 'PASS' -t '$SYS/#' -v

    For graphing, point Telegraf or mqtt-exporter at the broker and ship metrics to Prometheus or InfluxDB. The $SYS/broker/load/messages/sent/1min topic is a useful traffic gauge.

    Zigbee2MQTT publishes health to zigbee2mqtt/bridge/state (online/offline) and per-device link quality on zigbee2mqtt/<device> payloads as linkquality. Mesh problems usually show up as repeated linkquality < 30 values, which is your cue to add a router or move the coordinator.

    Common Issues

    • Connection refused on TLS port: Check that the cert paths are mounted correctly into the container and that the mosquitto user can read both files.
    • Coordinator disconnects every few hours: Often a network MTU issue between the VPS and home network. Drop the WireGuard MTU to 1380 and retest.
    • Devices won't pair: Confirm permit_join: true is set in the frontend (do not leave it on permanently), and check that the device is in pairing mode within the join window.
    • ACL changes don't take effect: Send SIGHUP to the broker with docker exec mosquitto kill -HUP 1. A restart is not required.

    This stack is now ready for production use. Add Home Assistant, Node-RED, or a custom application on top by creating dedicated MQTT users and ACL entries for each.