back to scripts

docker-watchdog.sh

bash 148 lines secrets redacted

Detects stale NAS/CIFS mounts, stops affected containers, remounts, and only restarts once storage is readable again.

Note Live script from my home-lab server. Tokens, IDs, phone numbers and other secrets have been replaced with placeholders like <WHATSAPP_GROUP_ID> — everything else is the real, running code.
#!/usr/bin/env bash
set -u
REPORT_DIR=/home/lanky/reports
LOG="$REPORT_DIR/docker-watchdog.log"
LOCK=/run/lanky-docker-watchdog.lock
PROTECTED_RESTART=" homeassistant cloudflared pihole-edge wireguard "
NAS_BACKED_CONTAINERS=" jellyfin jellyseerr qbittorrent radarr sonarr "
mkdir -p "$REPORT_DIR"
exec >> "$LOG" 2>&1
exec 9>"$LOCK"
if ! flock -n 9; then
  echo "[$(date -Is)] watchdog already running; skipping overlapping run"
  exit 0
fi

is_protected_restart() {
  case "$PROTECTED_RESTART" in
    *" $1 "*) return 0 ;;
    *) return 1 ;;
  esac
}

container_state() {
  docker inspect "$1" --format '{{.State.Status}} exit={{.State.ExitCode}} {{if .State.Health}}{{.State.Health.Status}}{{end}}' 2>/dev/null || echo unknown
}

readable_mount() {
  local mount_path=$1
  local probe_path=$mount_path
  case "$mount_path" in
    /mnt/jelly) probe_path=/mnt/jelly/Cartoons ;;
    /mnt/nas) probe_path=/mnt/nas/Backups ;;
  esac
  timeout 25s find "$probe_path" -maxdepth 1 -mindepth 1 -print -quit >/dev/null 2>&1
}

jellyfin_media_visible() {
  docker inspect jellyfin >/dev/null 2>&1 || return 0
  [ "$(docker inspect jellyfin --format '{{.State.Status}}' 2>/dev/null)" = "running" ] || return 0
  timeout 8s docker exec jellyfin sh -c 'test -d /data/media/Movies && test -d /data/media/Cartoons && test -d "/data/media/TV Shows"' >/dev/null 2>&1
}

recover_mounts() {
  echo "[$(date -Is)] stopping NAS-backed containers for mount recovery"
  for c in $NAS_BACKED_CONTAINERS; do
    if docker inspect "$c" >/dev/null 2>&1; then
      docker stop -t 15 "$c" >/dev/null || docker kill "$c" >/dev/null || true
    fi
  done

  echo "[$(date -Is)] refreshing CIFS mounts"
  systemctl stop mnt-jelly.mount mnt-nas.mount 2>/dev/null || true
  umount -l /mnt/jelly 2>/dev/null || true
  umount -l /mnt/nas 2>/dev/null || true
  sleep 3
  systemctl daemon-reload || true
  timeout 30s mount /mnt/jelly || echo "[$(date -Is)] failed to mount /mnt/jelly"
  timeout 30s mount /mnt/nas || echo "[$(date -Is)] failed to mount /mnt/nas"

  mounts_recovered=1
  for m in /mnt/nas /mnt/jelly; do
    if readable_mount "$m"; then
      echo "[$(date -Is)] $m readable after recovery"
    else
      echo "[$(date -Is)] $m still unreadable after recovery"
      mounts_recovered=0
    fi
  done

  if [ "$mounts_recovered" -ne 1 ]; then
    echo "[$(date -Is)] leaving NAS-backed containers stopped until mounts recover"
    return
  fi

  echo "[$(date -Is)] starting NAS-backed containers after mount recovery"
  for c in $NAS_BACKED_CONTAINERS; do
    if docker inspect "$c" >/dev/null 2>&1; then
      docker start "$c" >/dev/null || echo "[$(date -Is)] failed to start $c"
    fi
  done
}

echo "[$(date -Is)] watchdog start"
needs_mount_recovery=0
for m in /mnt/nas /mnt/jelly; do
  if ! mountpoint -q "$m"; then
    echo "[$(date -Is)] $m missing; attempting mount"
    needs_mount_recovery=1
    mount "$m" || echo "[$(date -Is)] failed to mount $m"
  fi
  if mountpoint -q "$m" && readable_mount "$m"; then
    echo "[$(date -Is)] $m mounted and readable"
  elif mountpoint -q "$m"; then
    echo "[$(date -Is)] $m mounted but unreadable/stale"
    needs_mount_recovery=1
  else
    echo "[$(date -Is)] $m still missing"
    needs_mount_recovery=1
  fi
done

if ! systemctl is-active --quiet docker; then
  echo "[$(date -Is)] docker inactive; starting docker"
  systemctl start docker || echo "[$(date -Is)] failed to start docker"
fi

if ! command -v docker >/dev/null 2>&1; then
  echo "[$(date -Is)] docker command missing"
  exit 0
fi

if [ "$needs_mount_recovery" -eq 1 ]; then
  recover_mounts
fi

problem_containers=$(docker ps -a --filter status=exited --filter status=dead --filter status=restarting --format '{{.Names}}' 2>/dev/null; docker ps --filter health=unhealthy --format '{{.Names}}' 2>/dev/null)
echo "$problem_containers" | awk 'NF' | sort -u | while read -r c; do
  case "$c" in
    '') continue ;;
    *)
      if is_protected_restart "$c"; then
        state=$(container_state "$c")
        echo "[$(date -Is)] protected problem container, report only: $c state=$state"
        continue
      fi
      state=$(container_state "$c")
      case "$state" in
        "exited exit=0 "*)
          echo "[$(date -Is)] ignoring successful exited container: $c state=$state"
          ;;
        *unhealthy*|exited*|dead*|restarting*)
          echo "[$(date -Is)] restarting problem container: $c state=$state"
          docker restart "$c" >/dev/null || echo "[$(date -Is)] failed to restart $c"
          ;;
        *)
          echo "[$(date -Is)] skip restart, state no longer failed: $c state=$state"
          ;;
      esac
      ;;
  esac
done

if readable_mount /mnt/jelly && ! jellyfin_media_visible; then
  echo "[$(date -Is)] jellyfin running without visible media paths; restarting"
  docker restart jellyfin >/dev/null || echo "[$(date -Is)] failed to restart jellyfin"
fi

echo "[$(date -Is)] watchdog done"

back to scripts