Files
ubuntu-post-install/services/beszel.sh
T
Claude 879cb24d3b Wire Authelia SSO into Immich, Audiobookshelf, and Beszel
_authelia_provision_oidc_client gains an optional PKCE flag (new 5th
positional arg; every existing caller updated to pass "n", producing an
identical client block to before) — Audiobookshelf and Beszel's own
Authelia integration docs both require require_pkce/pkce_challenge_method,
which Authelia doesn't turn on by default.

immich.sh: _immich_offer_authelia_oidc() is real server-side automation,
not just paste-in instructions — confirmed the exact system-config "oauth"
JSON field names against Immich's own config-file.md and source (not
guessed, closing out the "needs one more verification pass" note this
repo's own CLAUDE.md already had on file). GET/PUT exchange the whole
config object, so it round-trips everything else unchanged. Needs an
admin API key that doesn't exist until first web-UI visit, so it's wired
into both the fresh-install path and the "update" rerun path.

audiobookshelf.sh, beszel.sh: both apps' OIDC config is UI-only (checked
against audiobookshelf.org and beszel.dev directly — no config API or env
var for the provider fields), so their new offers automate the Authelia
side and print exact paste-in values. Beszel also gets a real, separate
DISABLE_PASSWORD_AUTH/USER_CREATION toggle to fully replace its login,
gated behind a warning to register a working account first.

Also adds Audiobookshelf and Beszel as presets in authelia.sh's own
generic "Register another app" menu, and updates CLAUDE.md's OIDC
verification table to match reality (Immich now wired, Audiobookshelf
was wrongly listed as "high-confidence no", Beszel added).
2026-08-24 16:08:33 +00:00

730 lines
32 KiB
Bash

#!/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
# Self-heals an ALREADY-INSTALLED agent's docker-compose.yml with the
# systemd/dbus/sensor mounts and apparmor:unconfined — both added to the
# fresh-install template after plenty of boxes already existed, and
# "update" mode otherwise never touches docker-compose.yml at all (a plain
# pull+restart), so those boxes would stay missing both fixes forever
# without this. Anchors on `network_mode: host` and the docker.sock mount
# line — both unique to the beszel-agent service and present in either
# compose shape (combined hub+agent, or agent-only), so one function
# safely covers both install_beszel()'s and install_beszel-agent()'s
# update paths. Each of the two fixes is checked and applied independently
# (idempotent either way), so a box that already picked up one but not the
# other — e.g. updated between the two commits that added them — still
# gets exactly the one it's missing, not a duplicate of the one it has.
_beszel_patch_agent_compose() {
local compose_file="$1"
[ -f "$compose_file" ] || return 0
if ! grep -q 'apparmor:unconfined' "$compose_file"; then
awk '
{ print }
/^ network_mode: host$/ && !done {
print " security_opt:"
print " - apparmor:unconfined"
done=1
}
' "$compose_file" > "$compose_file.tmp" && mv "$compose_file.tmp" "$compose_file"
log_success "Added security_opt: apparmor:unconfined to $compose_file"
fi
if ! grep -q '/var/run/dbus/system_bus_socket' "$compose_file"; then
awk '
{ print }
/^ - \/var\/run\/docker\.sock:\/var\/run\/docker\.sock:ro$/ && !done {
print " - /var/run/systemd/private:/var/run/systemd/private:ro"
print " - /var/run/dbus/system_bus_socket:/var/run/dbus/system_bus_socket:ro"
print " - /sys/class/hwmon:/sys/class/hwmon:ro"
print " - /sys/class/thermal:/sys/class/thermal:ro"
done=1
}
' "$compose_file" > "$compose_file.tmp" && mv "$compose_file.tmp" "$compose_file"
log_success "Added systemd/dbus/sensor mounts to $compose_file"
fi
}
# Pulls a NAME's value out of a pasted blob regardless of which shape it
# arrives in — Beszel's own "copy for docker compose" button (the most
# prominent option next to the key/token fields, confirmed live to be
# what people actually click) hands you YAML (`KEY: 'value'` or
# `- KEY=value`), not a bare string — matches "KEY"/"TOKEN" with either
# `:` or `=`, optional leading `- ` list marker, and strips a single
# layer of surrounding quotes if present.
_beszel_extract_field() {
local field="$1" blob="$2"
echo "$blob" | grep -iE "^[[:space:]]*-?[[:space:]]*${field}[[:space:]]*[:=]" | head -1 \
| sed -E "s/^[[:space:]]*-?[[:space:]]*${field}[[:space:]]*[:=][[:space:]]*//I" \
| sed -E "s/^[\"']//; s/[\"'][[:space:]]*\$//"
}
_beszel_configure_agent() {
local dir="$1" login_url="$2"
echo ""
echo " To finish connecting the agent, log into the hub first:"
echo " ${login_url}"
echo ""
echo " Create the admin account, then go to Settings → Tokens & Fingerprints:"
echo " - Enable the universal token"
echo " - The key/token fields there usually offer a 'copy for docker"
echo " compose' shortcut — paste whatever that gives you below as-is,"
echo " whole snippet or just the two lines, doesn't matter which."
echo ""
if [ "${UNATTENDED:-false}" = "true" ]; then
log_info "Skipping agent setup (unattended) — re-run this installer to finish it."
return 0
fi
echo " Paste it below, then an empty line to finish (blank first line to skip for now):"
local PASTE="" LINE
while IFS= read -r LINE; do
[ -z "$LINE" ] && break
PASTE="${PASTE}${LINE}"$'\n'
done
if [ -z "$PASTE" ]; then
log_info "Skipping agent setup for now — re-run this installer once you have the key and token."
return 0
fi
local AGENT_KEY AGENT_TOKEN
AGENT_KEY="$(_beszel_extract_field KEY "$PASTE")"
AGENT_TOKEN="$(_beszel_extract_field TOKEN "$PASTE")"
# Didn't find a labeled KEY/TOKEN line at all — treat the paste as a
# single bare value and ask for the other one directly, so a UI that
# really does just show plain strings (no YAML) still works.
if [ -z "$AGENT_KEY" ] && [ -z "$AGENT_TOKEN" ]; then
AGENT_KEY="$(echo "$PASTE" | head -1)"
prompt_text " Paste the universal token:" "" AGENT_TOKEN
fi
if [ -z "$AGENT_KEY" ] || [ -z "$AGENT_TOKEN" ]; then
log_warning "Couldn't find both a key and a token in that — skipping agent setup. Re-run this installer to try again."
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"
}
# Registers Beszel as an Authelia OIDC client and prints exactly what to
# paste into the hub's own settings. Checked against beszel.dev's own OAuth
# docs directly: Beszel is PocketBase-based, and its OAuth2 provider config
# is a PocketBase admin-UI setting (Settings -> Auth providers), not
# something exposed by any documented API or env var — so, like
# Audiobookshelf, this only automates the Authelia half. Beszel's own
# Authelia integration doc (authelia.com) requires PKCE.
#
# Args: DIR (the .env holding DISABLE_PASSWORD_AUTH/USER_CREATION lives there)
_beszel_offer_authelia_oidc() {
local dir="$1"
declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || return 0
[ -d "$DOCKER_DIR/authelia" ] || return 0
echo ""
local USE_SSO=""
prompt_yn " Add \"Sign in with Authelia\" (OpenID Connect) to Beszel? (y/n):" "n" USE_SSO
[[ "$USE_SSO" =~ ^[Yy]$ ]] || return 0
local APP_DOMAIN
APP_DOMAIN="$(_authelia_pick_domain "Domain Beszel is reachable at (number or domain)")"
if [ -z "$APP_DOMAIN" ]; then
log_warning "No domain entered — skipping SSO setup."
return 0
fi
local _2fa="" AUTH_POLICY="two_factor"
prompt_yn " Require two-factor for Beszel logins via Authelia too? (y/n):" "y" _2fa
[[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor"
if ! _authelia_provision_oidc_client "Beszel" "beszel" "$AUTH_POLICY" "y" "y" \
"https://${APP_DOMAIN}/api/oauth2-redirect"; then
log_warning "Couldn't register Beszel as an OIDC client in Authelia — skipping SSO setup."
return 0
fi
echo ""
echo " In the hub: Settings -> Auth providers -> OpenID Connect:"
echo " Client ID: beszel"
echo " Client Secret: $OIDC_CLIENT_SECRET_PLAIN"
echo " Auth URL: ${OIDC_AUTHELIA_PORTAL_URL}/api/oidc/authorization"
echo " Token URL: ${OIDC_AUTHELIA_PORTAL_URL}/api/oidc/token"
echo " User Info URL: ${OIDC_AUTHELIA_PORTAL_URL}/api/oidc/userinfo"
echo ""
log_warning "The Client Secret above is shown once — save it now."
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "beszel" "$APP_DOMAIN"
# DISABLE_PASSWORD_AUTH/USER_CREATION are real, documented env vars
# (beszel.dev's own OAuth guide) — but Beszel has no default account:
# the FIRST person to register becomes admin (see this file's own
# README section), and there's no signup-fallback like Mealie's
# OIDC_SIGNUP_ENABLED convenience wording to lean on if that hasn't
# happened yet. Off by default and gated behind an explicit warning —
# flipping this before a working login exists risks locking the hub's
# UI out with no account able to reach it at all.
echo ""
local _disable_local=""
prompt_yn " Also disable Beszel's own password login, so Authelia is the only way in? Only do this AFTER you've registered a working account. (y/n):" "n" _disable_local
if [[ "$_disable_local" =~ ^[Yy]$ ]]; then
local _auto_register=""
prompt_yn " Auto-create Beszel accounts for new Authelia logins? (y/n):" "n" _auto_register
sed -i '/^DISABLE_PASSWORD_AUTH=/d; /^USER_CREATION=/d' "$dir/.env"
{
echo "DISABLE_PASSWORD_AUTH=true"
[[ "$_auto_register" =~ ^[Yy]$ ]] && echo "USER_CREATION=true"
} >> "$dir/.env"
chown "$ACTUAL_USER:$ACTUAL_USER" "$dir/.env" 2>/dev/null || true
( cd "$dir" && docker compose up -d beszel ) \
&& log_success "Beszel's own password login is now disabled — Authelia is the only way in." \
|| log_warning "Restart failed — check: docker compose -f $dir/docker-compose.yml logs beszel"
fi
}
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 mount host systemd/dbus sockets + sensor paths read-only (Services/Temp columns)"
echo "[DRY-RUN] Would set security_opt: apparmor:unconfined on the agent (needed for dbus to work at all on an AppArmor host)"
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."
_beszel_patch_agent_compose "$DIR/docker-compose.yml"
( 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="" _WP
_WP="$(grep '^BESZEL_WEB_PORT=' "$DIR/.env" 2>/dev/null | cut -d= -f2-)"
prompt_yn " The agent was never connected — set it up now? (y/n):" "y" FINISH_AGENT
[[ "$FINISH_AGENT" =~ ^[Yy]$ ]] && _beszel_configure_agent "$DIR" "http://localhost:${_WP} (or its Caddy domain, once configured)"
fi
_beszel_offer_authelia_oidc "$DIR"
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.
#
# The systemd/dbus/sensor mounts below aren't optional extras — without
# them the agent silently reports empty Services and Temp columns for
# this box, with no error anywhere pointing at why. A container is
# isolated from the host's systemd/dbus and most of /sys by default;
# docker.sock alone (already needed for the Docker-container-stats
# feature) doesn't grant any of that. Confirmed live: a native
# (non-Docker) agent install gets both for free just by virtue of
# running as a normal host process, which is what first surfaced this
# gap — a Docker-deployed agent sitting right next to it showed nothing
# in either column until these were added.
#
# The mounts alone aren't enough on an AppArmor-enabled host (Ubuntu/
# Debian by default): the dbus connection attempt fails with "An
# AppArmor policy prevents this sender from sending this message to
# this recipient" (visible only at LOG_LEVEL=debug — silent otherwise),
# since the container has no AppArmor label the host's dbus-daemon
# profile recognizes. security_opt: apparmor:unconfined below is
# Beszel's own documented fix (beszel.dev/guide/systemd#apparmor-error)
# — confirmed live, this exact error on a real box.
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}
beszel-agent:
image: henrygd/beszel-agent:latest
container_name: beszel-agent
restart: unless-stopped
network_mode: host
security_opt:
- apparmor:unconfined
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
- /var/run/systemd/private:/var/run/systemd/private:ro
- /var/run/dbus/system_bus_socket:/var/run/dbus/system_bus_socket:ro
- /sys/class/hwmon:/sys/class/hwmon:ro
- /sys/class/thermal:/sys/class/thermal:ro
${_CADDY_NET_SECTION}
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" "http://localhost:${WEB_PORT} (or its Caddy domain, once configured)"
_beszel_offer_authelia_oidc "$DIR"
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.
Also mounts the host's systemd/dbus sockets and sensor paths (read-only),
plus `security_opt: apparmor:unconfined` (required on Ubuntu/Debian for the
dbus connection to work at all — otherwise AppArmor silently blocks it), so
the hub's **Services** (systemd units) and **Temp** (hardware sensors)
columns work for this box — without both, a Docker-deployed agent silently
shows both empty, no error anywhere pointing at why (visible only at
`LOG_LEVEL=debug`).
## 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:<port>`)
## 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") to generate its
connection details, then on the OTHER box (a homelab machine, not this
VPS) run `sudo ./setup.sh beszel-agent` — a separate, agent-only install
(no hub, no web UI) built for exactly this. It connects OUTBOUND to this
hub over HTTPS, so no VPN, port-forwarding, or FQDN is needed for that box
— only this hub needs to be reachable, which it already is. See
`services/beszel.sh`'s `install_beszel-agent()`.
## 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:<port>`,
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"
}
# ── Agent-only install, for a box that ISN'T the hub ────────────────────────
# A second register_service in this same file — precedented by base.sh's
# base+glow — rather than a separate services/beszel-agent.sh, since this
# shares _beszel_extract_field/_beszel_configure_agent with install_beszel()
# above and would otherwise duplicate that paste/parse logic across two
# files. Run this on a homelab/remote box (a plain VPN peer isn't needed —
# see the README this writes for why) pointed at a hub running on THIS repo's
# main box (install_beszel() above, or any other Beszel hub).
register_service beszel-agent utilities "Beszel monitoring AGENT only, for a remote/homelab box reporting to a hub running elsewhere"
install_beszel-agent() {
require_docker || return 1
log_info "Installing the Beszel agent (reports to a hub running on another machine)..."
local DIR="$DOCKER_DIR/beszel-agent"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $DIR"
echo "[DRY-RUN] Would deploy henrygd/beszel-agent only (no hub, no web UI on this box)"
echo "[DRY-RUN] network_mode: host, /var/run/docker.sock mounted read-only"
echo "[DRY-RUN] plus host systemd/dbus sockets + sensor paths read-only (Services/Temp columns)"
echo "[DRY-RUN] plus security_opt: apparmor:unconfined (needed for dbus to work at all on an AppArmor host)"
echo "[DRY-RUN] Would prompt for the hub's public URL, then its key + universal token"
echo "[DRY-RUN] (same paste flow as the hub-side installer)"
echo "[DRY-RUN] No inbound port opened — the agent connects OUTBOUND to the hub, so no"
echo "[DRY-RUN] VPN, port-forwarding, or FQDN is needed for THIS box"
return 0
fi
if [[ -f "$DIR/docker-compose.yml" && -f "$DIR/.env" ]]; then
local MODE=""
prompt_reinstall_mode MODE
case "$MODE" in
update)
log_info "Refreshing the image only — existing hub URL/key/token are left as-is."
_beszel_patch_agent_compose "$DIR/docker-compose.yml"
( cd "$DIR" && docker compose pull && docker compose up -d ) \
&& log_success "Beszel agent image 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="" _HUB_URL
_HUB_URL="$(grep '^HUB_URL=' "$DIR/.env" 2>/dev/null | cut -d= -f2-)"
prompt_yn " The agent was never connected — set it up now? (y/n):" "y" FINISH_AGENT
[[ "$FINISH_AGENT" =~ ^[Yy]$ ]] && _beszel_configure_agent "$DIR" "$_HUB_URL"
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
mkdir -p "$DIR/beszel_agent_data"
ensure_docker_dir_ownership "$DIR"
cd "$DIR" || return 1
echo ""
echo " This box's agent connects OUTBOUND to a Beszel hub running elsewhere"
echo " (e.g. your VPS) — no VPN, no router port-forwarding, and no FQDN needed"
echo " for THIS box. Only the hub itself needs to already be reachable."
echo ""
local HUB_URL=""
while [ -z "$HUB_URL" ]; do
prompt_text " Hub URL (e.g. https://beszel.yourdomain.com, or http://vps-ip:port):" "" HUB_URL
[ -z "$HUB_URL" ] && log_warning " A hub URL is required."
done
# No network_mode: host caveat here beyond what install_beszel() already
# documents — same reasoning (accurate host network-interface stats),
# and there's no caddy_net to conditionally join since this box never
# runs a web UI of its own. See that function's own comment for why the
# systemd/dbus/sensor mounts below matter (Services/Temp columns).
cat > docker-compose.yml << AGENT_COMPOSE
name: beszel-agent
services:
beszel-agent:
image: henrygd/beszel-agent:latest
container_name: beszel-agent
restart: unless-stopped
network_mode: host
security_opt:
- apparmor:unconfined
env_file: .env
environment:
- HUB_URL=\${HUB_URL}
- TOKEN=\${AGENT_TOKEN:-}
- KEY=\${AGENT_KEY:-}
volumes:
- ./beszel_agent_data:/var/lib/beszel-agent
- /var/run/docker.sock:/var/run/docker.sock:ro
- /var/run/systemd/private:/var/run/systemd/private:ro
- /var/run/dbus/system_bus_socket:/var/run/dbus/system_bus_socket:ro
- /sys/class/hwmon:/sys/class/hwmon:ro
- /sys/class/thermal:/sys/class/thermal:ro
AGENT_COMPOSE
cat > .env << AGENT_ENV
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
HUB_URL=$HUB_URL
AGENT_ENV
chmod 600 .env
_beszel_configure_agent "$DIR" "$HUB_URL"
write_readme "$DIR" << AGENT_README_MD
# Beszel agent (remote/homelab box)
Reports this box's host resources and Docker container stats to a Beszel
HUB running elsewhere — no hub, no web UI, nothing web-facing on this box
at all.
Also mounts the host's systemd/dbus sockets and sensor paths (read-only),
plus \`security_opt: apparmor:unconfined\` (required on Ubuntu/Debian for the
dbus connection to work at all — otherwise AppArmor silently blocks it), so
the hub's **Services** (systemd units) and **Temp** (hardware sensors)
columns work for this box too — without both, a Docker-deployed agent
silently shows both empty, no error anywhere pointing at why (visible only
at \`LOG_LEVEL=debug\`).
## Connecting (if you skipped it during install)
Same flow as the hub side, just on a different machine: log into the hub at
\`$HUB_URL\`, go to Settings → Tokens & Fingerprints, enable the universal
token, and paste whatever it gives you (the "copy for docker compose"
shortcut works fine) by re-running:
\`\`\`bash
sudo ./setup.sh beszel-agent
\`\`\`
## Why no VPN or port-forwarding
This agent connects OUTBOUND to the hub over HTTPS/WebSocket using the key
and token above — nothing on this box ever needs to be reached FROM the
hub, so there's no inbound port to open, no router port-forward, no VPN
tunnel, and no FQDN needed for this box itself. Only the hub needs to
already be reachable at the URL you gave it.
## Manage
\`\`\`bash
docker compose up -d
docker compose logs -f
docker compose pull && docker compose up -d
docker compose down
\`\`\`
AGENT_README_MD
echo ""
log_success "Beszel agent configured at $DIR — connecting to $HUB_URL"
}
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_beszel