#!/usr/bin/env bash # # deploy.sh -- deploy the beszel stack (caddy + beszel hub) on Alpine. # # Single-node: runs as root. # # What this does: # 1. Installs docker + docker-cli-compose if missing. # 2. Lays down docker-compose.yml, Caddyfile, .env.example in $STACK_DIR. # 3. Generates .env on first run; existing .env is never overwritten. # 4. Prompts for required values not preset (BESZEL_DOMAIN, ACME_EMAIL). # 5. Opens TCP 80/443 in UFW if active. # 6. Pulls images, brings the stack up, waits for healthchecks. # # Idempotent: re-run to apply config changes / pull new images. # # Self-contained: docker-compose.yml, Caddyfile, .env.example are embedded # as a base64-encoded tar.gz at the bottom of this file. Rebuild with # build.sh after editing the loose source files. # # Usage: # bash deploy.sh # interactive prompts # BESZEL_DOMAIN=monitoring.example.com \ # ACME_EMAIL=admin@example.com FORCE=1 bash deploy.sh # STACK_DIR=/opt/beszel bash deploy.sh # SKIP_DOCKER_INSTALL=1 bash deploy.sh set -euo pipefail : "${STACK_DIR:=/srv/beszel}" : "${SKIP_DOCKER_INSTALL:=0}" : "${FORCE:=0}" : "${SKIP_PROMPTS:=0}" # non-interactive: require values via env, no prompts [[ "$SKIP_PROMPTS" == "1" ]] && FORCE=1 : "${BESZEL_DOMAIN:=}" : "${ACME_EMAIL:=}" log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; } [[ $EUID -eq 0 ]] || die "Run as root." # --------------------------------------------------------------------------- # OS detection + Docker install (Alpine / Debian / Alma). This deploy.sh is # self-contained (scp'd standalone), so the OS logic is inlined here instead # of sourced from scripts/oslib.sh. # --------------------------------------------------------------------------- osfam() { local id="" like="" if [[ -r /etc/os-release ]]; then id="$(. /etc/os-release 2>/dev/null && echo "${ID:-}")" like="$(. /etc/os-release 2>/dev/null && echo "${ID_LIKE:-}")" fi case " $id $like " in *" alpine "*) echo alpine ;; *" debian "*|*" ubuntu "*) echo debian ;; *" rhel "*|*" fedora "*|*" centos "*) echo rhel ;; *) echo "${id:-unknown}" ;; esac } install_docker() { if command -v docker >/dev/null 2>&1; then log "Docker already installed: $(docker --version)" else log "Installing Docker (OS: $(osfam))..." case "$(osfam)" in alpine) apk add -q docker docker-cli-compose openrc ;; debian|rhel) command -v curl >/dev/null 2>&1 || \ { command -v apt-get >/dev/null 2>&1 && apt-get install -y -qq curl; } || \ { command -v dnf >/dev/null 2>&1 && dnf install -y -q curl; } curl -fsSL https://get.docker.com | sh ;; *) die "Unsupported OS for auto Docker install. Set SKIP_DOCKER_INSTALL=1 and install Docker yourself." ;; esac fi if command -v rc-update >/dev/null 2>&1; then rc-update add docker default >/dev/null 2>&1 || true rc-service docker status >/dev/null 2>&1 || rc-service docker start elif command -v systemctl >/dev/null 2>&1; then systemctl enable --now docker >/dev/null 2>&1 || systemctl start docker || true fi } open_web_ports() { # Register 80/443 for this stack. Prefer the host firewall (harden-firewall.sh) # when present: drop in a rule file and re-apply. Else fall back to ufw/ # firewalld if active (no-op when neither is). # # NOTE: Caddy publishes 80/443 via Docker, which reaches the host through the # nat/FORWARD chains and BYPASSES the INPUT firewall -- so this is harmless # belt-and-braces for any host-bound bind and self-documents the stack ports. if [[ -d /etc/firewall/ports.d && -x /usr/local/sbin/firewall-apply ]]; then log "Registering 80,443/tcp with host firewall..." printf '80/tcp\n443/tcp\n' > /etc/firewall/ports.d/beszel.rule /usr/local/sbin/firewall-apply elif command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then log "ufw active -- allowing 80,443/tcp..." ufw allow 80/tcp >/dev/null; ufw allow 443/tcp >/dev/null elif command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then log "firewalld active -- allowing http,https..." firewall-cmd -q --add-service=http --permanent firewall-cmd -q --add-service=https --permanent firewall-cmd -q --reload fi } # ---------------------------------------------------------------------------- # Extract embedded archive # ---------------------------------------------------------------------------- SCRIPT_DIR=$(mktemp -d -t beszel-deploy.XXXXXX) trap 'rm -rf "$SCRIPT_DIR"' EXIT extract_archive() { grep -a -A 9999999 '^__ARCHIVE_BELOW__$' "$0" \ | tail -n +2 \ | base64 -d \ | tar -xz -C "$SCRIPT_DIR" } if grep -q -a '^__ARCHIVE_BELOW__$' "$0"; then log "Extracting embedded deployment files..." extract_archive else die "No embedded archive found. Run build.sh to embed deployment files." fi for f in docker-compose.yml Caddyfile .env.example; do [[ -f "$SCRIPT_DIR/$f" ]] || die "Embedded archive missing $f" done # ---------------------------------------------------------------------------- # Prompt for required vars # ---------------------------------------------------------------------------- prompt() { local varname="$1" message="$2" local -n ref="$varname" if [[ -z "${ref:-}" ]]; then [[ "$SKIP_PROMPTS" == "1" ]] && die "$varname required (set it in the environment; running with SKIP_PROMPTS=1)." read -r -p "$message: " ref [[ -n "$ref" ]] || die "$varname required." fi } prompt BESZEL_DOMAIN "Public hostname for beszel (e.g. monitoring.example.com)" prompt ACME_EMAIL "Let's Encrypt email" # ---------------------------------------------------------------------------- # Docker # ---------------------------------------------------------------------------- if [[ "$SKIP_DOCKER_INSTALL" != "1" ]]; then install_docker fi # Open 80/443 on whichever host firewall is active. open_web_ports # ---------------------------------------------------------------------------- # Stack directory + files # ---------------------------------------------------------------------------- log "Setting up $STACK_DIR..." install -d -m 0750 "$STACK_DIR" install -m 0640 "$SCRIPT_DIR/docker-compose.yml" "$STACK_DIR/docker-compose.yml" install -m 0640 "$SCRIPT_DIR/Caddyfile" "$STACK_DIR/Caddyfile" ENV_FILE="$STACK_DIR/.env" if [[ ! -f "$ENV_FILE" ]]; then log "Seeding $ENV_FILE..." install -m 0600 "$SCRIPT_DIR/.env.example" "$ENV_FILE" sed -i \ -e "s|^BESZEL_DOMAIN=.*|BESZEL_DOMAIN=${BESZEL_DOMAIN}|" \ -e "s|^ACME_EMAIL=.*|ACME_EMAIL=${ACME_EMAIL}|" \ "$ENV_FILE" else log ".env exists; leaving it alone." fi # Validate missing=() for var in BESZEL_DOMAIN ACME_EMAIL; do grep -E "^${var}=.+$" "$ENV_FILE" >/dev/null || missing+=("$var") done (( ${#missing[@]} == 0 )) || die "Missing values in $ENV_FILE: ${missing[*]}" # ---------------------------------------------------------------------------- # Bring up the stack # ---------------------------------------------------------------------------- if [[ "$FORCE" != "1" ]]; then cat </dev/null || true) unhealthy=$(echo "$status" | awk '$2 != "healthy" && $2 != "" {print $1}') if [[ -z "$unhealthy" && -n "$status" ]]; then log "All services healthy." break fi sleep 5 done echo log "Stack status:" docker compose ps echo cat < Options tab -> enable OAuth2 - Add OIDC provider, redirect URI: https://${BESZEL_DOMAIN}/api/oauth2-redirect - Toggle controls back on Then in pocket-id admin UI, register a new OIDC client with that redirect URI and paste the credentials into Beszel. 3. (Optional) Once OIDC works, lock out password login: edit docker-compose.yml, uncomment DISABLE_PASSWORD_AUTH=true, then docker compose up -d. Add a monitored host: Beszel UI -> "Add System" -> follow the agent install snippet. The agent connects back to this hub at https://${BESZEL_DOMAIN}. Manage: docker compose logs -f docker compose pull && docker compose up -d # update docker compose down # stop, keep volumes docker compose down -v # stop, WIPE data Or just re-run this script -- it's idempotent. ================================================================ EOF # IMPORTANT: do not put any code below this exit. Everything after the # __ARCHIVE_BELOW__ marker is the embedded tar.gz payload (base64). exit 0 __ARCHIVE_BELOW__ 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 8Y8AKAAA