Skip to content

Beszel

Beszel is a lightweight server monitor: CPU, memory, network, disk usage and I/O, temperatures, per-container stats and S.M.A.R.T. per disk, with history and threshold alerts. A hub (web UI + database) collects from an agent on every server.

Component Runs on
beszel-hub control-host, in the monitoring stack next to Uptime Kuma and Dozzle
beszel-agent every server, including control-host itself

Alerts (e.g. disk usage, CPU, temperature thresholds) go to ntfy alerts as the write-only beszel user. Configure that under the hub's notification settings with an ntfy URL that carries the token.


Hub

compose.yaml (monitoring stack, excerpt)
services:
  beszel-hub:
    image: henrygd/beszel:0.21.0
    container_name: beszel-hub
    restart: unless-stopped
    ports:
      - "8090:8090"
    volumes:
      - /mnt/appdata/beszel/hub-data:/beszel_data
    # The Beszel binary ships a health subcommand built for this
    healthcheck:
      test: ["CMD", "/beszel", "health", "--url", "http://localhost:8090"]
      interval: 120s
      start_period: 10s
      timeout: 5s

The hub sits behind the reverse proxy at https://beszel.<YOUR_LOCAL_DOMAIN>. Add a system in the UI and it shows you the KEY and TOKEN for that agent.

Pre-1.0 software

Beszel is still 0.x, where a minor bump (0.20 → 0.21) can be breaking. Hub and agents are pinned to the same version and updated together in one grouped update PR, which I review instead of automerging.


Agent

Every agent uses the same shape: pinned tag, unless-stopped, the /agent health healthcheck, disks passed by serial for SMART, and the real data mounts as extra filesystems. This is the NAS version (most disks); the other servers only pass their root NVMe and their appdata SSD.

compose.yaml (agent on the NAS)
services:
  beszel-agent:
    image: henrygd/beszel-agent:0.21.0
    container_name: beszel-agent
    restart: unless-stopped
    network_mode: host
    # SMART: every disk passed by serial, so a reordered /dev/sdX can't swap them.
    devices:
      - /dev/nvme0:/dev/nvme0   # root NVMe (controller device)
      - /dev/disk/by-id/ata-<MODEL>_<SERIAL_D1>:/dev/sda   # pool data d1
      - /dev/disk/by-id/ata-<MODEL>_<SERIAL_D2>:/dev/sdb   # pool data d2
      - /dev/disk/by-id/ata-<MODEL>_<SERIAL_SSD>:/dev/sdc  # appdata SSD
      - /dev/disk/by-id/ata-<MODEL>_<SERIAL_NVR>:/dev/sdd  # NVR recordings
      - /dev/disk/by-id/ata-<MODEL>_<SERIAL_P>:/dev/sde    # SnapRAID parity
    cap_add:
      - SYS_RAWIO # required for S.M.A.R.T. data
      - SYS_ADMIN # required for NVMe S.M.A.R.T. data
    volumes:
      - ./beszel_agent_data:/var/lib/beszel-agent
      - /var/run/docker.sock:/var/run/docker.sock:ro
      # Extra disk panels: real mountpoints, name = <diskstats partition>__<label>.
      # Root is the agent's primary filesystem already.
      - /mnt/storage/:/extra-filesystems/storage__Pool:ro
      - /mnt/appdata/:/extra-filesystems/sdc1__Appdata:ro
      - /mnt/hdd/<SERIAL_D1>:/extra-filesystems/sda1__Data 1:ro
      - /mnt/hdd/<SERIAL_D2>:/extra-filesystems/sdb1__Data 2:ro
      - /mnt/hdd/<SERIAL_P>:/extra-filesystems/sde1__Parity:ro
      - /mnt/nvr:/extra-filesystems/sdd1__NVR:ro
    environment:
      LISTEN: 45876
      KEY: ${BESZEL_KEY}
      HUB_URL: https://beszel.<YOUR_LOCAL_DOMAIN>
      TOKEN: ${BESZEL_TOKEN}
      CONTAINER_DETAILS: true
    healthcheck:
      test: ['CMD', '/agent', 'health']
      interval: 120s
      start_period: 10s
      timeout: 5s
.env.example
# Both shown by the hub when you add the system
BESZEL_KEY=ssh-ed25519 AAAA...
BESZEL_TOKEN=<token from the hub>

LISTEN is the current name of the agent port variable; PORT is the deprecated older name and still works. (Two of my agents still use PORT.) CONTAINER_DETAILS is documented as a hub setting (it controls whether container inspect/logs are viewable in the UI), so on an agent it has no documented effect.

Why it's built like this

  • Disks by serial (/dev/disk/by-id/...), not /dev/sdX. Kernel device letters can change between boots. With /dev/sdb:/dev/sdb a reorder silently swaps which physical disk the "parity" panel is showing. The by-id path maps a specific serial to a fixed name inside the container. When I rebuilt this mapping, the old one had two data disks labelled the wrong way round and had left out the parity disk entirely. Check labels against lsblk -o NAME,SERIAL,MOUNTPOINT and your snapraid.conf.
  • NVMe: pass the controller (/dev/nvme0), not the namespace (/dev/nvme0n1), and add SYS_ADMIN. SATA SMART needs SYS_RAWIO.
  • Extra filesystems must be the real mountpoints. Mounting an empty directory that happens to live on the root filesystem gives you a second panel showing the root disk. That happened on two hosts, where /mnt/sda was just a leftover empty dir. The folder name <device>__<label> tells Beszel which diskstats device to use for I/O, and the label is what the UI shows.
  • /agent health. The agent image's binary is /agent. A healthcheck copied from the hub (/beszel health) points at a file that doesn't exist, and that agent sat "unhealthy" for weeks without anyone noticing.
  • network_mode: host so the agent sees the host's real network interfaces and the hub can reach it on 45876.

WebSocket vs SSH

With HUB_URL + TOKEN the agent connects out to the hub over WebSocket. If it can't (for example the hub uses a private CA the agent image doesn't trust), the hub falls back to connecting in over SSH on port 45876, which works fine. To use the WebSocket path with a private CA, mount the CA bundle into the agent and point SSL_CERT_FILE at it.

SMART vs smartd

Beszel shows SMART attributes and history. It is not what runs self-tests or pages me about a failing disk. smartd on each host does that and sends to ntfy, see Host maintenance. Beszel is for "show me the trend", smartd for "wake me up".


Sources & further reading