Confirmed live: install_frigate()'s fresh-install path overwrote a working, hand-crafted docker-compose.yml (Frigate + mosquitto + frigate-notify) with zero backup, because that file's shape didn't match what frigate.sh's own "existing install" detection knew how to recognize. Every service's own detection is a judgment call about what counts as "already installed" and can miss a real setup built outside this repo's conventions. lib/common.sh gains backup_if_exists(FILE) — copies FILE to FILE.bak.<timestamp> if it exists, no-ops otherwise (including DRY_RUN). Applied before every service's own `cat > docker-compose.yml`/`cat > .env` write across all 60 services that do one (115 call sites), plus a matching standalone-mode stub added to every service's own bootstrap block, same convention already used for port_in_use/find_free_port. This doesn't replace a service's own update/fresh-reinstall detection — it's the safety net underneath it, so a wrong detection costs a .bak file to restore from instead of the original silently disappearing. Also fixes the actual gap that surfaced this: services/frigate.sh's Authelia offer only checked for Authelia installed locally on Frigate's own box, which is never true for a dedicated NVR box with no local Caddy either (the common shape — Caddy lives elsewhere, snippet-generation mode already handles that). Now offers Authelia protection unconditionally and, when Authelia isn't local, asks whether it lives on the same machine as Caddy (still "import authelia", since that's local to wherever Caddy ends up) or on a genuinely separate third machine (the explicit header-pinned forward_auth form, per CLAUDE.md's "forward_auth to a remote Authelia" note, needed because a bare authelia:9091 shortcut only works one hop).
278 lines
9.7 KiB
Bash
278 lines
9.7 KiB
Bash
#!/bin/bash
|
|
# services/watchtower.sh — Watchtower automatic container update monitoring.
|
|
# Part of the modular post-install system (sourced by setup.sh).
|
|
#
|
|
# Can also be run standalone on any machine:
|
|
# sudo bash watchtower.sh
|
|
# (Docker must already be installed when run standalone)
|
|
|
|
# ── Standalone bootstrap ──────────────────────────────────────────────────────
|
|
# Detected when the script is executed directly rather than sourced by setup.sh.
|
|
# Sets up helpers and globals, then defers execution until after the function
|
|
# definition at the bottom of this file.
|
|
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
|
|
# Full repo present — use the real helpers (picks up ~/docker/.config too)
|
|
# shellcheck source=../lib/common.sh
|
|
source "$_COMMON"
|
|
else
|
|
# One-off copy — inline minimal stubs so the script works without the repo
|
|
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
|
|
}
|
|
|
|
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
|
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}'"
|
|
}
|
|
|
|
write_readme() {
|
|
local _dir="$1"; shift
|
|
mkdir -p "$_dir"
|
|
cat > "$_dir/README.md"
|
|
}
|
|
backup_if_exists() {
|
|
local _file="$1"
|
|
[ -f "$_file" ] || return 0
|
|
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
|
|
}
|
|
fi
|
|
|
|
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
|
# ($HOME under sudo is /root, not the real user's home)
|
|
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
|
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
|
|
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
|
|
DRY_RUN="${DRY_RUN:-false}"
|
|
UNATTENDED="${UNATTENDED:-false}"
|
|
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
|
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
|
|
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
|
|
CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}"
|
|
|
|
register_service() { :; } # no-op — no wizard to register into
|
|
_RUN_STANDALONE=1
|
|
fi
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
register_service watchtower utilities "Automatic container updates (Watchtower)"
|
|
|
|
install_watchtower() {
|
|
require_docker || return 1
|
|
log_info "Installing Watchtower..."
|
|
local WT_DIR="$DOCKER_DIR/watchtower"
|
|
|
|
if [ "$DRY_RUN" = true ]; then
|
|
echo "[DRY-RUN] Would create $WT_DIR"
|
|
return 0
|
|
fi
|
|
|
|
mkdir -p "$WT_DIR" 2>/dev/null || true
|
|
ensure_docker_dir_ownership "$WT_DIR"
|
|
cd "$WT_DIR" 2>/dev/null || cd "$DOCKER_DIR" || return 1
|
|
|
|
# Ask about mode
|
|
echo ""
|
|
echo "Watchtower Mode:"
|
|
echo " [M] Monitor only - Get notifications about available updates (SAFE)"
|
|
echo " [A] Auto-update - Automatically pull and restart containers (RISKY)"
|
|
echo ""
|
|
echo " ⚠️ Auto-update can break apps like Immich that need DB migrations!"
|
|
echo " Recommendation: Use monitor mode, update manually when ready."
|
|
echo ""
|
|
local WT_MODE="M"
|
|
prompt_text "Mode [M/A]:" "M" WT_MODE
|
|
WT_MODE=$(echo "$WT_MODE" | tr '[:lower:]' '[:upper:]')
|
|
|
|
local MONITOR_ONLY
|
|
if [ "$WT_MODE" = "A" ]; then
|
|
MONITOR_ONLY="false"
|
|
echo " Mode: Auto-update (containers will be updated automatically)"
|
|
else
|
|
MONITOR_ONLY="true"
|
|
echo " Mode: Monitor only (you'll be notified of updates)"
|
|
fi
|
|
|
|
# Check for ntfy
|
|
local NTFY_URL=""
|
|
if [ -d "$DOCKER_DIR/ntfy" ]; then
|
|
echo " ✓ ntfy detected - configuring notifications"
|
|
NTFY_URL="http://ntfy/watchtower"
|
|
fi
|
|
|
|
# Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh):
|
|
# explicit CADDY_MODE from the site config wins, then a local ~/docker/caddy,
|
|
# then the legacy CADDY_REMOTE_HOST var. Only "local" joins caddy_net — a
|
|
# remote Caddy box can't resolve container names on this host's bridge
|
|
# network anyway; it reaches this service via the host's published port.
|
|
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
|
|
|
|
backup_if_exists docker-compose.yml
|
|
cat > docker-compose.yml << WT_COMPOSE
|
|
name: watchtower
|
|
|
|
services:
|
|
watchtower:
|
|
image: containrrr/watchtower:latest
|
|
container_name: watchtower
|
|
hostname: watchtower
|
|
restart: unless-stopped
|
|
environment:
|
|
# Check for updates daily at 4 AM
|
|
- WATCHTOWER_SCHEDULE=0 0 4 * * *
|
|
# Monitor only - don't auto-update (change to false for auto-update)
|
|
- WATCHTOWER_MONITOR_ONLY=${MONITOR_ONLY}
|
|
# Cleanup old images after update
|
|
- WATCHTOWER_CLEANUP=true
|
|
# Include stopped containers
|
|
- WATCHTOWER_INCLUDE_STOPPED=true
|
|
# Notification URL (ntfy, Discord, Slack, etc.)
|
|
- WATCHTOWER_NOTIFICATION_URL=\${NOTIFICATION_URL:-}
|
|
# Show debug info
|
|
- WATCHTOWER_DEBUG=false
|
|
volumes:
|
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
|
WT_COMPOSE
|
|
|
|
# Create .env
|
|
backup_if_exists .env
|
|
cat > .env << WT_ENV
|
|
# Watchtower Configuration
|
|
# =========================
|
|
#
|
|
# Monitor-only mode: Watchtower checks for updates but doesn't apply them.
|
|
# This is SAFER because some apps (Immich, Mealie) have database migrations
|
|
# that can break if you update without proper procedures.
|
|
#
|
|
# To update manually:
|
|
# cd ~/docker/{app}
|
|
# docker compose pull
|
|
# docker compose up -d
|
|
|
|
# Set to "false" to enable auto-updates (RISKY!)
|
|
MONITOR_ONLY=$MONITOR_ONLY
|
|
|
|
# Notification URL (optional)
|
|
# Examples:
|
|
# ntfy: ntfy://ntfy.example.com/watchtower
|
|
# Discord: discord://token@id
|
|
# Slack: slack://hook-url
|
|
# Gotify: gotify://hostname/token
|
|
#
|
|
# Full list: https://containrrr.dev/shoutrrr/services/overview/
|
|
NOTIFICATION_URL=$NTFY_URL
|
|
CADDY_NET=$SITE_CADDY_NET
|
|
WT_ENV
|
|
|
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WT_DIR" 2>/dev/null || true
|
|
|
|
echo ""
|
|
log_success "Watchtower installed at $WT_DIR"
|
|
|
|
write_readme "$WT_DIR" << MD
|
|
# Watchtower
|
|
|
|
Monitors running containers for image updates. Defaults to NOTIFY-ONLY mode,
|
|
because apps like Immich can have breaking DB migrations on auto-update.
|
|
|
|
## No web interface
|
|
Watchtower has no web UI/port. It runs in the background and checks for updates
|
|
daily at 4 AM.
|
|
|
|
## Configuration
|
|
- Mode: $([ "$MONITOR_ONLY" = "true" ] && echo "Monitor only" || echo "Auto-update") (set MONITOR_ONLY in .env; "false" = auto-update)
|
|
- Notifications: set NOTIFICATION_URL in .env (ntfy, Discord, Slack, Gotify, ...)
|
|
See https://containrrr.dev/shoutrrr/services/overview/
|
|
|
|
## Exclude a container
|
|
Add this label to any container you want Watchtower to ignore:
|
|
\`com.centurylinklabs.watchtower.enable=false\`
|
|
|
|
## Update an app manually
|
|
\`\`\`
|
|
cd ~/docker/<app>
|
|
docker compose pull
|
|
docker compose up -d
|
|
\`\`\`
|
|
|
|
## Manage
|
|
\`\`\`
|
|
cd $WT_DIR
|
|
docker compose up -d # start
|
|
docker compose down # stop
|
|
docker compose logs -f # logs
|
|
\`\`\`
|
|
MD
|
|
|
|
local START_WATCHTOWER=""
|
|
prompt_yn "Start Watchtower now? (y/n):" "y" START_WATCHTOWER
|
|
if [ "$START_WATCHTOWER" = "y" ] || [ "$START_WATCHTOWER" = "Y" ]; then
|
|
docker compose up -d 2>/dev/null && log_success "Watchtower started" || log_warning "Failed to start"
|
|
fi
|
|
|
|
echo " Mode: $([ "$MONITOR_ONLY" = "true" ] && echo "Monitor only" || echo "Auto-update")"
|
|
echo ""
|
|
echo " Checks for updates daily at 4 AM."
|
|
if [ -n "$NTFY_URL" ]; then
|
|
echo " Notifications: $NTFY_URL"
|
|
else
|
|
echo " Configure NOTIFICATION_URL in .env for alerts."
|
|
fi
|
|
echo ""
|
|
echo " To exclude a container from Watchtower:"
|
|
echo " Add label: com.centurylinklabs.watchtower.enable=false"
|
|
echo ""
|
|
}
|
|
|
|
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_watchtower
|