Skip to content

Host maintenance

Containers are updated through Git and the deployment tool. This page is about the hosts underneath them: OS patches, reboots, disk health, disk space and boot behaviour. These are plain systemd units, apt config and shell scripts on each server (all Debian 13 "trixie" + Docker CE). Everything reports to ntfy, and all of it lives in the same Git repo as the compose files, installed with one script (below).

Component What it does Alerts to
notify fleet-notify: publish to ntfy as a write-only host user —
updates unattended-upgrades (Docker excluded), apt reports, only-if-required weekly reboots hosts, alerts, updates
smart smartd self-tests + disk problems alerts
panic reboot 10 s after a kernel panic, log kept by pstore —
diskspace hourly filesystem fill check, alerts on level change alerts
boot DNS wait don't start Docker until the router answers DNS —

Notify helper

Every host script sends through one tiny wrapper, so the ntfy URL, token and fallback logic live in one place.

/usr/local/bin/fleet-notify
#!/bin/sh
# fleet-notify <topic> <priority 1-5> <tags> <title> [message|-]
# Publish to ntfy as the write-only `host-publisher` user. Token: /etc/ntfy/host-publisher.token (root 600).
# LAN address first so alerts don't depend on DNS or the tunnel, then the public name.
# Message "-" (or omitted) reads the body from stdin. Never fails the caller.
topic="$1"; prio="${2:-3}"; tags="$3"; title="$4"; msg="$5"
[ -z "$msg" ] || [ "$msg" = "-" ] && msg="$(cat)"
tok="$(cat /etc/ntfy/host-publisher.token 2>/dev/null)"
[ -n "$tok" ] || { logger -t fleet-notify "no token, dropped: $title"; exit 0; }
for base in http://<YOUR_SERVER_IP>:88 https://ntfy.<YOUR_DOMAIN>; do
  if printf '%s' "$msg" | curl -fsS --max-time 15 -o /dev/null \
      -H "Authorization: Bearer $tok" -H "Title: [$(hostname -s)] $title" \
      -H "Priority: $prio" -H "Tags: $tags" --data-binary @- "$base/$topic"; then
    exit 0
  fi
done
logger -t fleet-notify "delivery failed: $title"
exit 0

Always exit 0. It's called from smartd hooks, apt hooks and backup hooks. A failed notification must never turn into a failed backup or a failed upgrade.


OS updates: unattended-upgrades

Debian security updates, stable point releases and third-party repos you trust (here Tailscale) install automatically every morning. Docker CE and containerd are excluded.

Why exclude Docker: upgrading docker-ce or containerd.io restarts the daemon, and that restarts every container on the host, mid-day, unannounced. Instead the apt report (below) posts once when a new Docker version is available, and I upgrade it by hand in a quiet window.

sudo apt install unattended-upgrades needrestart
/etc/apt/apt.conf.d/20auto-upgrades
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
/etc/apt/apt.conf.d/52unattended-upgrades-local
// Local overrides (don't edit 50unattended-upgrades; package updates would conflict).
// Docker CE / containerd (origin=Docker) are deliberately NOT listed: an upgrade restarts every container.
#clear Unattended-Upgrade::Origins-Pattern;
Unattended-Upgrade::Origins-Pattern {
        "origin=Debian,codename=${distro_codename},label=Debian";
        "origin=Debian,codename=${distro_codename}-updates,label=Debian";
        "origin=Debian,codename=${distro_codename}-security,label=Debian-Security";
        "origin=Tailscale,codename=${distro_codename},label=Tailscale";
};

// Belt and braces in case a Docker package ever appears in an allowed origin.
Unattended-Upgrade::Package-Blacklist {
        "docker-ce";
        "docker-ce-cli";
        "docker-ce-rootless-extras";
        "containerd.io";
        "docker-buildx-plugin";
        "docker-compose-plugin";
};

// Keep local config files when a package ships a new default (no interactive conffile prompt).
Dpkg::Options {
        "--force-confdef";
        "--force-confold";
};

Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Remove-New-Unused-Dependencies "true";
Unattended-Upgrade::SyslogEnable "true";

// Reboots are NOT done by unattended-upgrades: a separate timer handles them (below).
Unattended-Upgrade::Automatic-Reboot "false";

Check what would be picked up, without installing anything:

sudo unattended-upgrade --dry-run --debug 2>&1 | grep -E 'Allowed origins|Checking|pkgs that look like'

needrestart: restart services, but never Docker

After a library upgrade, needrestart restarts the system services still using the old library, except the container runtime:

/etc/needrestart/conf.d/50-local.conf
$nrconf{restart} = 'a';            # restart affected services automatically
$nrconf{kernelhints} = 0;
$nrconf{ucodehints} = 0;
$nrconf{override_rc}{qr(^docker)} = 0;      # never restart docker...
$nrconf{override_rc}{qr(^containerd)} = 0;  # ...or containerd: that restarts every container

Docker and containerd pick up new libraries at the next (weekly, only-if-required) reboot. On a workstation use $nrconf{restart} = 'l'; (list only) and leave out the reboot timer: you decide when your own desktop reboots.

apt report

A drop-in on apt-daily-upgrade.service runs a report after each upgrade run:

/etc/systemd/system/apt-daily-upgrade.service.d/report.conf
[Service]
ExecStartPost=-/usr/local/sbin/fleet-apt-report

The leading - means a failing report never marks the upgrade unit failed. The script reads /var/log/unattended-upgrades/unattended-upgrades.log since the last run and posts:

  • hosts, priority 2: "Upgraded N packages" with the list, plus "reboot pending" and the packages from /var/run/reboot-required.pkgs when there is one.
  • alerts, priority 4: any ERROR/WARNING lines from unattended-upgrades.
  • updates, priority 3, once per new version: "Docker engine update available (manual)", with the exact command to run in a quiet window:
# Spot a waiting Docker update (what the report checks)
apt-cache policy docker-ce containerd.io | grep -E '^[a-z]|Installed|Candidate'

# Upgrade by hand when it suits you (restarts every container on this host)
sudo apt install --only-upgrade docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Reboots: only if required, weekly, with notice

Debian marks a needed reboot by creating /var/run/reboot-required (on Debian, the unattended-upgrades package installs the kernel hook /etc/kernel/postinst.d/unattended-upgrades that does this, and lists the packages in /var/run/reboot-required.pkgs). A weekly timer reboots only when that file exists, never interrupts a long job, and tells ntfy first.

/etc/systemd/system/fleet-reboot-if-required.timer
[Unit]
Description=Weekly only-if-required reboot window (per-host time in the drop-in)

[Timer]
OnCalendar=Sun *-*-* 05:00:00
Persistent=false

[Install]
WantedBy=timers.target
/etc/systemd/system/fleet-reboot-if-required.timer.d/time.conf (one per host)
[Timer]
OnCalendar=
OnCalendar=Sun *-*-* 05:30:00
/etc/systemd/system/fleet-reboot-if-required.service
[Unit]
Description=Reboot for updates if /var/run/reboot-required (waits for long jobs; notifies ntfy)
After=network-online.target
ConditionPathExists=/var/run/reboot-required

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/fleet-reboot-if-required
TimeoutStartSec=6h
  • Stagger the hosts with a per-host drop-in (the empty OnCalendar= clears the default first). Reboot the app servers first and the control host (deployment tool, Git server, reverse proxy, ntfy) last, so the host that reports the others is up while they reboot.
  • Persistent=false: a missed window (host was off) is skipped, not run as soon as it boots on a Tuesday afternoon.
  • ConditionPathExists makes the unit a no-op when there's nothing to do.

The script waits for anything that must not be cut off, then notifies and reboots:

/usr/local/sbin/fleet-reboot-if-required
#!/bin/bash
# Reboot for updates only when Debian says one is required.
# Waits for: SnapRAID (max 4 h), a borg backup (max 2 h, own run or another host backing up INTO this one), dpkg (max 1 h).
# DRYRUN=1: run every check and send the notification marked [dry run], but don't reboot.
set -u
DRY="${DRYRUN:-0}"
MAXWAIT_SNAPRAID=$((4*3600)); MAXWAIT_BACKUP=$((2*3600)); MAXWAIT_DPKG=3600
log(){ logger -t fleet-reboot "$*"; echo "$*"; }

[ -f /var/run/reboot-required ] || [ "$DRY" = 1 ] || { log "no reboot required"; exit 0; }
pkgs="$(sort -u /var/run/reboot-required.pkgs 2>/dev/null | tr '\n' ' ')"

waited=0
while pgrep -x snapraid >/dev/null || pgrep -f snapraid-aio-script >/dev/null; do
  [ $waited -ge $MAXWAIT_SNAPRAID ] && {
    fleet-notify hosts 4 warning,hourglass "Reboot postponed" "SnapRAID still running after 4h. Needs: ${pkgs:-unknown}"
    exit 0; }
  sleep 60; waited=$((waited+60))
done

waited=0
while pgrep -x borgmatic >/dev/null || pgrep -f 'borg serve' >/dev/null; do
  [ $waited -ge $MAXWAIT_BACKUP ] && {
    fleet-notify hosts 4 warning,hourglass "Reboot postponed" "A borg backup was still running after 2h. Needs: ${pkgs:-unknown}"
    exit 0; }
  sleep 60; waited=$((waited+60))
done

waited=0
while fuser /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock >/dev/null 2>&1; do
  [ $waited -ge $MAXWAIT_DPKG ] && { fleet-notify hosts 4 warning "Reboot postponed" "dpkg busy for 1h. Needs: ${pkgs:-unknown}"; exit 0; }
  sleep 30; waited=$((waited+30))
done

if [ "$DRY" = 1 ]; then
  fleet-notify hosts 2 test_tube "[dry run] Reboot check passed" "Would reboot now for: ${pkgs:-nothing pending}"
  exit 0
fi
log "rebooting for updates: ${pkgs:-unknown}"
fleet-notify hosts 4 arrows_counterclockwise "Rebooting for updates" \
  "Rebooting now to finish updates (${pkgs:-packages unknown}). Expect ~3 min of downtime. A 'back up' message follows."
sleep 20
systemctl reboot

Why wait instead of just rebooting: a reboot in the middle of a SnapRAID sync or scrub, or while another host is writing its backup into this one, costs far more than a week's delay. If a job is still running after the limit, the reboot is skipped for the week and ntfy says so.

"Back up" message

A timer fires 3 minutes after every boot (planned or not) and reports the kernel and container health, so an unplanned reboot (power cut, panic) shows up too:

/etc/systemd/system/fleet-boot-notify.timer
[Timer]
OnBootSec=3min

[Install]
WantedBy=timers.target

The script counts containers with a restart policy, lists any that are unhealthy, Restarting or Exited (…), and posts "Host is back up" to hosts (priority 3, or 4 if anything needs attention).


SMART monitoring and self-tests

smartd (from smartmontools) watches every disk, runs scheduled self-tests and calls a hook on problems. The hook forwards to ntfy alerts. The Debian default config mails root, and on a server without a mail transfer agent those mails go nowhere.

sudo apt install smartmontools
/etc/smartd.conf
#  -a                     health, attributes, error log and self-test log
#  -o on -S on            automatic offline data collection + attribute autosave (ATA)
#  -n standby,24,q        don't spin up sleeping disks for a check, but check at least every 24 polls (~12 h)
#  -s (S/../.././02|L/../01/./04)
#                         short self-test daily at 02:00; long self-test on the 1st of each month at 04:00
#  -W 4,50,60             log temperature changes >= 4 C and >= 50 C; alert at >= 60 C
#  -m <nomailer> -M exec  no mail; run the hook instead. -M daily repeats an unresolved problem once a day.
DEVICESCAN -a -o on -S on -n standby,24,q -s (S/../.././02|L/../01/./04) -W 4,50,60 -m <nomailer> -M daily -M exec /usr/local/sbin/fleet-smartd-notify
/usr/local/sbin/fleet-smartd-notify
#!/bin/sh
# smartd -M exec hook. smartd sets SMARTD_DEVICE, SMARTD_DEVICEINFO, SMARTD_FAILTYPE, SMARTD_MESSAGE, SMARTD_FULLMESSAGE.
case "$SMARTD_FAILTYPE" in
  EmailTest) prio=2; tags=test_tube ;;
  Health|FailedHealthCheck|CurrentPendingSector|OfflineUncorrectableSector|SelfTest) prio=5; tags=rotating_light,floppy_disk ;;
  *) prio=4; tags=warning,floppy_disk ;;
esac
printf '%s\n\n%s\n\n%s\n' "$SMARTD_MESSAGE" "$SMARTD_DEVICEINFO" "$SMARTD_FULLMESSAGE" | head -c 3500 |
  /usr/local/bin/fleet-notify alerts "$prio" "$tags" "Disk ${SMARTD_FAILTYPE}: ${SMARTD_DEVICE}" -
sudo systemctl enable --now smartmontools
sudo systemctl restart smartmontools
# Test the hook end to end: add "-M test" to the DEVICESCAN line, restart, then remove it again
sudo smartctl -l selftest /dev/sda        # self-test history per disk
  • Schedule the long test around your big jobs. A long self-test on a 12 TB disk takes about 20 hours and slows the disk. Start it right after the nightly parity sync, on a day no scrub is planned, so the two don't fight.
  • Self-tests are the disk check. On XFS, Debian's xfs_scrub_all.timer may be doing nothing useful. Check with systemctl status xfs_scrub_all.service and disable it if so, rather than trusting it.
  • Beszel shows SMART trends. smartd is the one that alerts.

Reboot after a kernel panic

The kernel default kernel.panic = 0 means a panicked host hangs forever until someone power-cycles it. On the host that carries remote access, that means no way back in.

/etc/sysctl.d/90-panic.conf
# Reboot 10 s after a kernel panic instead of hanging.
kernel.panic = 10
sudo sysctl -p /etc/sysctl.d/90-panic.conf
sysctl kernel.panic
systemctl is-enabled systemd-pstore      # should be "enabled" (default on Debian 13)

Keep the evidence: on UEFI machines the kernel writes the panic log to EFI variables (efi_pstore), and systemd-pstore moves it to /var/lib/systemd/pstore/ on the next boot. After an unexplained reboot, look there first. Empty after an unexplained reset points at power or hardware, not the kernel. Combined with the "back up" message, an unplanned reboot never goes unnoticed.


Disk-space alerts

An hourly timer checks every local filesystem and alerts only when a filesystem changes level (ok → warning → critical, and back to ok), plus a daily reminder while one stays critical. A full disk gets one message, not one per hour.

/etc/systemd/system/fleet-diskspace-check.timer
[Unit]
Description=Hourly filesystem fill check

[Timer]
OnCalendar=hourly
RandomizedDelaySec=5m
Persistent=true

[Install]
WantedBy=timers.target
/usr/local/sbin/fleet-diskspace-check
#!/bin/bash
# Hourly filesystem fill check -> ntfy `alerts`, on level change only (+ daily reminder while critical).
# Default: warn 85% used / crit 95% used. Overrides in /etc/fleet/diskspace.conf, one per line, first match wins:
#   <mount|glob> <warn> <crit>     "NN%" = used percent at/above, "NNG" = free GiB at/below
#   <mount|glob> skip
# Network filesystems are skipped: the host that owns the disks checks them.
# DRYRUN=1 prints instead of notifying; CONF/STATE env vars override the paths (for testing).
set -u
CONF="${CONF:-/etc/fleet/diskspace.conf}"; STATE="${STATE:-/var/lib/fleet/diskspace.state}"
now=$(date +%s); mkdir -p "$(dirname "$STATE")"; touch "$STATE"

rule(){ # rule <mount> -> "<warn> <crit>" or "skip"
  if [ -r "$CONF" ]; then
    while read -r pat w c; do
      case "$pat" in ''|\#*) continue ;; esac
      # shellcheck disable=SC2254
      case "$1" in $pat) echo "$w ${c:-}"; return ;; esac
    done < "$CONF"
  fi
  echo "85% 95%"; }
hit(){ # hit <threshold> <used%> <freeGiB>
  case "$1" in *%) [ "$2" -ge "${1%\%}" ] ;; *G) [ "$3" -le "${1%G}" ] ;; *) return 1 ;; esac; }

new_state=""
while read -r _ type _ _ avail pct mnt; do
  set -- $(rule "$mnt"); [ "$1" = skip ] && continue
  warn="$1"; crit="${2:-$1}"; used="${pct%\%}"; free=$((avail / 1073741824))
  lvl=0; hit "$warn" "$used" "$free" && lvl=1; hit "$crit" "$used" "$free" && lvl=2
  read -r plvl plast <<<"$(awk -v m="$mnt" '$1==m{print $2, $3}' "$STATE")"
  plvl=${plvl:-0}; plast=${plast:-0}; last=$plast
  if [ "$lvl" != "$plvl" ] || { [ "$lvl" = 2 ] && [ $((now - plast)) -ge 86400 ]; }; then
    case $lvl in 2) p=5; t=rotating_light,floppy_disk; s=CRITICAL ;; 1) p=4; t=warning,floppy_disk; s=warning ;; 0) p=2; t=white_check_mark; s=ok ;; esac
    msg="$mnt ($type): ${used}% used, ${free} GiB free. Thresholds: warn $warn, crit $crit."
    if [ "${DRYRUN:-0}" = 1 ]; then echo "NOTIFY p$p [$s] $msg"
    else /usr/local/bin/fleet-notify alerts "$p" "$t" "Disk space $s: $mnt" "$msg"; fi
    last=$now
  fi
  new_state+="$mnt $lvl $last"$'\n'
done < <(df -PT -B1 -x tmpfs -x devtmpfs -x overlay -x squashfs -x efivarfs -x vfat -x cifs -x nfs -x nfs4 -x fuse.sshfs | tail -n +2)
printf '%s' "$new_state" > "$STATE"

On the NAS the pool needs a different rule:

/etc/fleet/diskspace.conf (NAS)
# mergerfs pool: mergerfs stops placing new files below minfreespace, so alert on free space, not percent
/mnt/storage 500G 250G
# pool branches + SnapRAID parity run near-full by design; the pool line above is what matters
/mnt/hdd/* skip

Why free GiB for the pool: a big mergerfs pool can sit at 95% for months and still have hundreds of GB free. A percentage alert would be red all the time, so you'd stop reading it. What matters is the absolute space left before mergerfs refuses new files (minfreespace).

sudo DRYRUN=1 STATE=/tmp/s /usr/local/sbin/fleet-diskspace-check   # test without sending
cat /var/lib/fleet/diskspace.state

Wait for DNS before Docker starts

Docker copies the host's /etc/resolv.conf into each container once, at container start. If Docker starts before the router answers DNS (typical after a power cut, when server and router boot together), the early containers are stuck with no working DNS until someone restarts them. Once, 14 containers were still broken more than a day after a boot, including ntfy and Uptime Kuma, the two things meant to notice.

Two fixes, both in place:

  1. auto <INTERFACE> in /etc/network/interfaces, so network-online.target actually waits for the NIC. With only allow-hotplug it doesn't. See Containers can't resolve names after a reboot.
  2. A oneshot unit before network-online.target that blocks until the default gateway answers a real DNS query. Having an address isn't enough; the resolver has to be up.
/etc/systemd/system/wait-for-router-dns.service
[Unit]
Description=Wait until the LAN gateway (router) is actually answering DNS
Requires=networking.service
After=networking.service
Before=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/wait-for-router-dns.sh
TimeoutStartSec=5min

[Install]
WantedBy=network-online.target
/usr/local/sbin/wait-for-router-dns.sh
#!/bin/sh
# Block network-online.target (and so docker.service) until the default gateway resolves a name.
# Bounded: gives up after MAX_WAIT and lets boot continue (degraded) rather than risk an unbootable host.
set -eu
MAX_WAIT="${WAIT_FOR_ROUTER_DNS_MAX_WAIT:-280}"   # seconds; keep < TimeoutStartSec
INTERVAL=2
PROBE_NAME="${WAIT_FOR_ROUTER_DNS_PROBE_NAME:-router.<YOUR_LOCAL_DOMAIN>}"
elapsed=0

while [ "$elapsed" -lt "$MAX_WAIT" ]; do
    gw=$(ip route show default 0.0.0.0/0 2>/dev/null | awk '/^default/ {print $3; exit}')
    if [ -n "$gw" ] && timeout 4 dig "@${gw}" "$PROBE_NAME" +short +time=2 +tries=1 >/dev/null 2>&1; then
        echo "wait-for-router-dns: gateway $gw answered DNS for $PROBE_NAME after ${elapsed}s"
        exit 0
    fi
    sleep "$INTERVAL"
    elapsed=$((elapsed + INTERVAL))
done

echo "wait-for-router-dns: gave up after ${MAX_WAIT}s, continuing boot DEGRADED. Restart containers once DNS works."
exit 0
sudo apt install bind9-dnsutils     # dig
sudo install -m 0755 wait-for-router-dns.sh /usr/local/sbin/
sudo install -m 0644 wait-for-router-dns.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable wait-for-router-dns.service
journalctl -b -u wait-for-router-dns     # after the next boot: how long it waited

Also pin Docker's upstream DNS

Setting { "dns": ["<YOUR_ROUTER_IP>"] } in /etc/docker/daemon.json gives containers a fixed upstream no matter what rewrites the host's resolv.conf later (for example a VPN client's DNS takeover). Applying it restarts Docker, so every container restarts: do it in a maintenance window.


Keeping host config in git: install-host.sh

Everything above lives in the repo next to the compose files (hosts/_fleet/<component>/), and one idempotent script pushes it to a host over SSH. No config-management tool, just install over ssh. Re-running it is safe, and the repo shows exactly what each host has.

hosts/_fleet/
├── install-host.sh
├── bin/fleet-notify
├── updates/       unattended-upgrades/, needrestart.conf, fleet-*.sh, systemd/ (+ timer.d/<host>.conf)
├── smart/         smartd.conf, fleet-smartd-notify
├── system/        wait-for-router-dns.{sh,service}, sysctl/90-panic.conf
└── diskspace/     fleet-diskspace-check, <host>.conf, systemd/
install-host.sh (shape)
#!/bin/bash
# install-host.sh <ssh-target|local> <host> [component...]   (default: all components)
set -euo pipefail
T="$1"; H="$2"; shift 2; COMPS="${*:-notify updates smart panic diskspace}"
F="$(cd "$(dirname "$0")" && pwd)"
rx(){ if [ "$T" = local ]; then bash -c "$1"; else ssh "$T" "$1"; fi; }
put(){ rx "sudo install -D -m $3 -o root -g root /dev/stdin '$2'" < "$1"; }   # put <src> <dest> <mode>

for c in $COMPS; do case "$c" in
  notify)
    put "$F/bin/fleet-notify" /usr/local/bin/fleet-notify 755
    # Token never in git: the caller prints it from the secret store, it's piped over stdin
    [ -n "${TOKEN_FROM:-}" ] && eval "$TOKEN_FROM" | rx 'sudo install -D -m 600 /dev/stdin /etc/ntfy/host-publisher.token' ;;
  smart)
    rx 'sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -q smartmontools >/dev/null'
    put "$F/smart/fleet-smartd-notify" /usr/local/sbin/fleet-smartd-notify 755
    put "$F/smart/smartd.conf" /etc/smartd.conf 644
    rx 'sudo systemctl enable -q smartmontools && sudo systemctl restart smartmontools' ;;
  panic)
    put "$F/system/sysctl/90-panic.conf" /etc/sysctl.d/90-panic.conf 644
    rx 'sudo sysctl -q -p /etc/sysctl.d/90-panic.conf' ;;
  diskspace)
    put "$F/diskspace/fleet-diskspace-check" /usr/local/sbin/fleet-diskspace-check 755
    # per-host override if one exists, otherwise remove a stale one
    if [ -f "$F/diskspace/$H.conf" ]; then put "$F/diskspace/$H.conf" /etc/fleet/diskspace.conf 644
    else rx 'sudo rm -f /etc/fleet/diskspace.conf'; fi
    # ...units, then: daemon-reload, enable --now the timer, run once, print the state file
    ;;
  updates) : ;;   # apt config, needrestart, reboot/boot/report scripts + units, per-host timer drop-in
  *) echo "unknown component $c"; exit 1 ;;
esac; done
echo "installed [$COMPS] on $T ($H)"
hosts/_fleet/install-host.sh server2 server2 smart panic        # two components on one host
TOKEN_FROM='<command that prints the host-publisher token>' \
  hosts/_fleet/install-host.sh server1 server1                  # everything, incl. the ntfy token
hosts/_fleet/install-host.sh local workstation updates          # the workstation's own (no-reboot) variant

The pattern that makes it work:

  • install -D -m <mode> -o root -g root /dev/stdin <dest> copies a file over SSH, creates parent directories and sets owner and mode in one step. Same result every run.
  • Per-host differences are files, not ifs: timer.d/<host>.conf, diskspace/<host>.conf. The script picks <host>.conf if it exists.
  • Secrets are never in the repo. They come from your secret store at install time (TOKEN_FROM) and land as root-only 0600 files on the host.
  • Each component is self-contained and can be installed on its own, so a change to one file means re-running one component.
  • Comment every file with its install path and why it exists. Six months later, the comment is the only documentation you'll actually read.

When the fleet outgrows this, the same layout maps directly onto an Ansible role per component.


Sources & further reading