From 7938399e99fd351967c19a9738e31a4aae9acbc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 03:43:58 +0000 Subject: [PATCH] Add Beszel for lightweight server + Docker monitoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers "what's the best way to see CPU/RAM/disk usage on this box" (IONOS's own dashboard doesn't expose it) and "does Gatus cover this" — it doesn't, Gatus is a black-box HTTP check (is the site responding from the outside), Beszel is white-box host/process monitoring (is the box under memory/disk pressure, is a container actually running vs. crash-looping). Complements Gatus rather than replacing it. Mirrors the hub+agent same-system layout from beszel's own supplemental/docker/same-system/docker-compose.yml (fetched from the actual upstream repo, not reconstructed from memory) — hub is the web dashboard, agent reads /var/run/docker.sock (read-only) to report every currently-running container automatically, no per-service config needed as containers get added or removed. Genuinely a two-phase install: the hub's SSH keypair and universal token only exist after logging into its web UI once, so this starts the hub, walks through where to find both values, and finishes wiring the agent once provided — skipping is fine, a rerun in "update" mode detects the agent was never connected and offers to finish it. Verified the generated docker-compose.yml/.env by running the actual file-writing code path with docker/configure_caddy_for_service mocked out — confirmed TOKEN/KEY are correctly left as literal ${AGENT_TOKEN:-}/${AGENT_KEY:-} for Docker Compose's own substitution at "up" time, not prematurely expanded by the heredoc itself. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn --- README.md | 2 +- services/beszel.sh | 371 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 services/beszel.sh diff --git a/README.md b/README.md index 2d4af54..29ee6a0 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`. |-------|---------| | `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network | | `homelab` | `caddy`, `crowdsec`, `authelia`, `coturn` (shared TURN/STUN relay — Asterisk, Mattermost Calls, and future WebRTC-capable services all register a dedicated credential against one instance instead of each running its own), `homeassistant`, `asterisk`, `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine`, `vpn-data-mount` (mount existing SMB shares from a NetBird-connected home box — SSH trust bootstrap, then read-only discovery of shares already configured there; never writes to the home box's Samba config; repeatable, pick from any number of a home box's shares in one pass; optional per-share [gocryptfs decrypt layer](#client-side-encryption-for-vpn-data-mount) so the VPS only ever handles ciphertext) | -| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) | +| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `beszel` (lightweight server + Docker monitoring — CPU/RAM/disk/network, auto-discovers running containers via the Docker socket; complements Gatus rather than replacing it — Gatus is a black-box HTTP check, Beszel is white-box host/process monitoring), `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, dedicated MariaDB per site — blogs, business sites, e-commerce via WooCommerce) | | `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | | `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` | diff --git a/services/beszel.sh b/services/beszel.sh new file mode 100644 index 0000000..9a02041 --- /dev/null +++ b/services/beszel.sh @@ -0,0 +1,371 @@ +#!/bin/bash +# services/beszel.sh — Beszel lightweight server + Docker monitoring (hub + agent). +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash beszel.sh +# (Docker must already be installed when run standalone) +# +# Beszel is a hub+agent monitor: the hub is the web dashboard, the agent runs +# on each box you want metrics from and reports host resources (CPU, RAM, +# disk, network) plus per-container Docker stats — read directly off +# /var/run/docker.sock (mounted read-only), so it picks up whatever +# containers are currently running automatically. No per-container config; +# add/remove a service on this box and the agent just reports what it sees +# on its next poll. +# +# This installs both hub and agent on THIS box (the "same-system" layout — +# see supplemental/docker/same-system/docker-compose.yml in beszel's own +# repo, which this mirrors). The agent can also be pointed at additional +# remote boxes later; that's a from-the-hub-UI step, not something this +# installer sets up. +# +# Complements Gatus, doesn't replace it: Gatus is a black-box HTTP check — +# is the site actually responding, from the outside, like a visitor would +# see it. Beszel is white-box host/process monitoring — is the box itself +# under memory/disk pressure, is a specific container actually running vs. +# crash-looping. A container can be "Up" in Docker (Beszel sees it as +# healthy) while the app inside it is serving errors or hanging (only +# Gatus's actual HTTP probe would catch that) — they answer different +# questions, worth running both. +# +# Two-phase install, unavoidably: the hub generates its own SSH keypair and +# (once you enable it in Settings → Tokens & Fingerprints) a universal +# token, both only available after you've logged into the hub's web UI at +# least once — there's no way to generate or guess these ahead of time. +# This installer starts the hub first, waits for you to grab those two +# values, then wires up the agent. Re-run this installer later if you skip +# the agent step now — it'll pick up right where you left off instead of +# re-asking everything. + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + [[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; } + + _SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + _COMMON="$_SELF_DIR/../lib/common.sh" + + if [[ -f "$_COMMON" ]]; then + # shellcheck source=../lib/common.sh + source "$_COMMON" + else + log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; } + log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } + log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } + log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; } + + require_docker() { + command -v docker &>/dev/null || { + log_error "Docker not found. Install it first:" + log_error " curl -fsSL https://get.docker.com | sudo sh" + return 1 + } + docker compose version &>/dev/null || { + log_error "Docker Compose plugin missing:" + log_error " sudo apt-get install -y docker-compose-plugin" + return 1 + } + } + + ensure_docker_dir_ownership() { + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true + } + + port_in_use() { + local _port="$1" _proto="${2:-tcp}" + local _flag="-tlnH" + [ "$_proto" = "udp" ] && _flag="-ulnH" + ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . + } + + find_free_port() { + local _varname="$1" _port="$2" _proto="${3:-tcp}" + while port_in_use "$_port" "$_proto"; do + _port=$((_port + 1)) + done + eval "$_varname='$_port'" + } + + prompt_text() { + local _q="$1" _def="$2" _var="$3" _r + [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } + read -r -p " $_q " _r + eval "$_var='${_r:-$_def}'" + } + + prompt_yn() { + local _q="$1" _def="$2" _var="$3" _r + [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } + read -r -p " $_q " _r + eval "$_var='${_r:-$_def}'" + } + + prompt_reinstall_mode() { + local _var="$1" _r + [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='cancel'"; return; } + echo " 1) Update (refresh image, keep everything else)" + echo " 2) Fresh reinstall" + echo " 3) Cancel" + read -r -p " Choice [1/2/3]: " _r + case "$_r" in + 2) eval "$_var='fresh'" ;; + 1|"") eval "$_var='update'" ;; + *) eval "$_var='cancel'" ;; + esac + } + + register_service() { :; } + write_readme() { + local _dir="$1"; mkdir -p "$_dir"; cat > "$_dir/README.md" + local _companion; _companion="$(dirname "${BASH_SOURCE[0]}")/beszel.md" + [ -f "$_companion" ] && cat "$_companion" >> "$_dir/README.md" + } + fi + + ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" + ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")" + DRY_RUN="${DRY_RUN:-false}" + UNATTENDED="${UNATTENDED:-false}" + DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" + + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service beszel utilities "Lightweight server + Docker monitoring (Beszel) — CPU/RAM/disk/network, auto-discovers running containers" 8090 + +_beszel_configure_agent() { + local dir="$1" web_port="$2" + + echo "" + echo " To finish connecting the agent, log into the hub first:" + echo " http://localhost:${web_port} (or its Caddy domain, once configured)" + echo "" + echo " Create the admin account, then go to Settings → Tokens & Fingerprints:" + echo " - Enable the universal token and copy its value" + echo " - Copy the public key shown there too" + echo "" + + local AGENT_KEY="" AGENT_TOKEN="" + prompt_text " Paste the public key (blank to skip and do this later):" "" AGENT_KEY + if [ -z "$AGENT_KEY" ]; then + log_info "Skipping agent setup for now — re-run this installer once you have the key and token." + return 0 + fi + prompt_text " Paste the universal token:" "" AGENT_TOKEN + if [ -z "$AGENT_TOKEN" ]; then + log_info "No token entered — skipping agent setup for now. Re-run this installer to finish." + return 0 + fi + + { + echo "AGENT_KEY=$AGENT_KEY" + echo "AGENT_TOKEN=$AGENT_TOKEN" + } >> "$dir/.env" + chmod 600 "$dir/.env" + + ( cd "$dir" && docker compose up -d beszel-agent ) \ + && log_success "Agent connected — it should appear in the hub within a few seconds." \ + || log_warning "Agent failed to start — check: docker compose -f $dir/docker-compose.yml logs beszel-agent" +} + +install_beszel() { + require_docker || return 1 + log_info "Installing Beszel..." + + local DIR="$DOCKER_DIR/beszel" + local WEB_PORT="8090" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $DIR (beszel_data/, beszel_socket/, beszel_agent_data/)" + echo "[DRY-RUN] Would deploy henrygd/beszel (hub) and henrygd/beszel-agent (agent, network_mode: host)" + echo "[DRY-RUN] Port 8090 published for the hub (auto-scanned for a free host port)" + echo "[DRY-RUN] Agent connects to the hub over a shared unix socket, not a TCP port" + echo "[DRY-RUN] Would pause for you to log into the hub and provide its key + universal token to finish the agent" + return 0 + fi + + # ── Existing install? Offer update/fresh/cancel ───────────────────────── + if [[ -f "$DIR/docker-compose.yml" && -f "$DIR/.env" ]]; then + local MODE="" + prompt_reinstall_mode MODE + case "$MODE" in + update) + log_info "Refreshing images only — existing config, port, and Caddy setup are left as-is." + ( cd "$DIR" && docker compose pull && docker compose up -d ) \ + && log_success "Beszel images refreshed" \ + || log_warning "Refresh failed — check: docker compose -f $DIR/docker-compose.yml logs" + if ! grep -q '^AGENT_KEY=' "$DIR/.env" 2>/dev/null; then + local FINISH_AGENT="" + prompt_yn " The agent was never connected — set it up now? (y/n):" "y" FINISH_AGENT + [[ "$FINISH_AGENT" =~ ^[Yy]$ ]] && _beszel_configure_agent "$DIR" "$(grep '^BESZEL_WEB_PORT=' "$DIR/.env" 2>/dev/null | cut -d= -f2-)" + fi + return 0 + ;; + cancel) + log_info "Leaving the existing install as-is." + return 0 + ;; + fresh) ;; # fall through to the full install flow below + esac + fi + + # A second hub on this same box wouldn't mean anything — additional + # boxes get their own agent pointed at this one hub, not a second hub + # instance. Skip the multi-instance pattern entirely (same reasoning + # CLAUDE.md gives for caddy/crowdsec). + find_free_port WEB_PORT "$WEB_PORT" + + mkdir -p "$DIR/beszel_data" "$DIR/beszel_socket" "$DIR/beszel_agent_data" + ensure_docker_dir_ownership "$DIR" + cd "$DIR" || return 1 + + # Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh). + local _CADDY_MODE="${CADDY_MODE:-none}" + [ "$_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local" + [ "$_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _CADDY_MODE="remote" + + local _CADDY_NET_BLOCK="" + local _CADDY_NET_SECTION="" + if [ "$_CADDY_MODE" = "local" ]; then + _CADDY_NET_BLOCK=" networks: + - caddy_net +" + _CADDY_NET_SECTION=" +networks: + caddy_net: + external: true + name: ${SITE_CADDY_NET:-caddy_net} +" + fi + + # Agent uses network_mode: host (for accurate network-interface stats, + # matching beszel's own reference same-system compose) — that's mutually + # exclusive with a `networks:` block in Compose, so it never joins + # caddy_net regardless of Caddy mode; it doesn't need to, since it talks + # to the hub over the shared beszel_socket volume, not a published port. + cat > docker-compose.yml << BESZEL_COMPOSE +name: beszel + +services: + beszel: + image: henrygd/beszel:latest + container_name: beszel + hostname: beszel + restart: unless-stopped + env_file: .env + ports: + - "${WEB_PORT}:8090" + volumes: + - ./beszel_data:/beszel_data + - ./beszel_socket:/beszel_socket +${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION} + beszel-agent: + image: henrygd/beszel-agent:latest + container_name: beszel-agent + restart: unless-stopped + network_mode: host + env_file: .env + environment: + - LISTEN=/beszel_socket/beszel.sock + - HUB_URL=http://localhost:${WEB_PORT} + - TOKEN=\${AGENT_TOKEN:-} + - KEY=\${AGENT_KEY:-} + volumes: + - ./beszel_agent_data:/var/lib/beszel-agent + - ./beszel_socket:/beszel_socket + - /var/run/docker.sock:/var/run/docker.sock:ro +BESZEL_COMPOSE + + # APP_URL only matters for the hub's own generated links and origin + # checks — http://localhost is fine until you front this with Caddy, + # at which point update it to the real https:// domain and restart (see + # README). Not threaded through automatically here because Caddy setup + # (below) happens after this file is written, same ordering every + # other service in this repo uses for its own Caddy prompt. + cat > .env << BESZEL_ENV +TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)} +CADDY_NET=$SITE_CADDY_NET +BESZEL_WEB_PORT=$WEB_PORT +APP_URL=http://localhost:${WEB_PORT} +BESZEL_ENV + chmod 600 .env + + log_info "Starting the hub (agent connects after you grab its key/token from the hub UI)..." + if docker compose up -d beszel; then + log_success "Hub started" + declare -F check_container_health >/dev/null 2>&1 && check_container_health beszel 8 + else + log_warning "Hub failed to start — check: docker compose logs" + return 1 + fi + + configure_caddy_for_service "Beszel" "beszel:8090" "beszel" + if [ "${CADDY_SERVICE_CONFIGURED:-false}" != "true" ] || [ "${CADDY_SERVICE_MODE:-}" = "remote" ]; then + if command -v ufw &>/dev/null; then + ufw allow "${WEB_PORT}/tcp" comment "Beszel" >/dev/null 2>&1 + declare -F ensure_ufw_enabled >/dev/null 2>&1 && ensure_ufw_enabled + fi + fi + + _beszel_configure_agent "$DIR" "$WEB_PORT" + + write_readme "$DIR" << 'BESZEL_README' +# Beszel — lightweight server + Docker monitoring + +Hub (web dashboard) + agent (reports host and container stats), both on +this box. The agent reads `/var/run/docker.sock` (mounted read-only) to +report every currently-running container automatically — nothing to +configure per-service; install or remove a container on this box and the +agent's next poll just reflects it. + +## First login + +No default account — open the hub and register the first user, which +becomes admin: + +- URL: printed at the end of the install (Caddy domain if configured, + otherwise `http://localhost:`) + +## Connecting the agent (if you skipped it during install) + +The agent needs the hub's public key and a universal token, both only +available after logging in: + +1. Settings → Tokens & Fingerprints → enable the universal token, copy it +2. Copy the public key shown on the same page +3. Re-run `sudo ./setup.sh beszel` — it'll detect the agent isn't + connected yet and prompt for both values + +## Monitoring additional boxes + +Add another agent from the hub's UI ("Add System") — that generates the +connection details for a *remote* box's agent. This installer only sets +up the agent for the box it's run on. + +## Fronting this with Caddy + +If you configured Caddy for Beszel, also update `APP_URL` in `.env` to +the real `https://` domain (it defaults to `http://localhost:`, +which is what the hub uses for its own generated links and origin +checks) and restart: + +```bash +docker compose restart beszel +``` + +## Manage + +```bash +docker compose up -d +docker compose logs -f +docker compose pull && docker compose up -d +docker compose down +``` +BESZEL_README + + echo "" + log_success "Beszel configured at $DIR" +} + +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_beszel