Files
Varaverk/Rsync/README-Rsync_Setup.md
T

47 KiB

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

🔄 RSYNC SETUP GUIDE

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Complete setup guide for the two-server rsync ecosystem. By the end of this guide both servers will have SSH keys configured, Tailscale connected, the git repository cloned, and all scheduled operations running automatically.

This is a setup guide, not a script reference. For how rsync.sh works internally, profiles, safety checks, and operational details — those belong in the orchestrator and rsync script documentation. This guide is about standing the ecosystem up from scratch and verifying it works.


━━━ WHAT YOU'RE BUILDING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

HOST1 (unRAID-Gmer4Lfe)                    HOST2 (unRAID-Jayred365)
──────────────────────                     ──────────────────────
Source of truth:                           Source of truth:
  Movies, Tv_Shows, Music                    Anime_Shows, Anime_Movies
  Critical-Data (auth stack)
  Emby userdata

Pushes to HOST2 daily:           ──────→   Receives:
  All HOST1 shares                            Mirror of HOST1 shares
  Personal encrypted shares                   Personal (encrypted blocks)

Receives from HOST2 daily:       ←──────   Pushes:
  Anime_Shows, Anime_Movies                   All HOST2 shares

Weekly clean sync (both sides stopped):
  Emby — full clean mirror       ←──────→  Emby
  Critical-Data                  ──────→   Auth stack (HOST1 → HOST2)

Every 30 minutes — dirty sync:
  Emby watch states              ──────→   HOST2 stays current on playback

━━━ PREREQUISITES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Both servers need these before starting:

# ─────────────────────────────────────────────────────────────────────────────
# Required on both servers:
  unRAID 7.x
  Community Applications plugin     — search "Community Applications" in unRAID plugins
  User Scripts plugin               — install via Community Applications
  Tailscale plugin                  — install via Community Applications
  Terminal access                   — unRAID UI → Tools → Terminal, or SSH

# Optional but recommended:
  Gitea (Docker container on HOST1)  — self-hosted git for the script repository
  Working Emby installation          — for transcode management and failover
# ─────────────────────────────────────────────────────────────────────────────

━━━ STEP 1 — TAILSCALE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Tailscale provides the encrypted mesh network between servers. Scripts resolve the remote server's IP via Tailscale at runtime — no hardcoded IPs, no VPN configuration, no open ports. All server-to-server communication goes through Tailscale.


── Install on Both Servers ────────────────────────────────────────────────

# On each server:
# ─────────────────────────────────────────────────────────────────────────────
# 1. Open Apps in the unRAID UI
# 2. Search for "Tailscale" — install the plugin
# 3. Settings → Tailscale → Connect
# 4. Authenticate with your Tailscale account (browser opens on your machine)
# 5. Verify both servers appear: https://login.tailscale.com/admin/machines
# ─────────────────────────────────────────────────────────────────────────────

── Verify Connectivity ─────────────────────────────────────────────────────

# From HOST1 — should return HOST2's 100.x.x.x Tailscale IP:
tailscale ip -4 unRAID-Jayred365

# From HOST2 — should return HOST1's 100.x.x.x Tailscale IP:
tailscale ip -4 unRAID-Gmer4Lfe

# Test actual connectivity:
tailscale ping unRAID-Jayred365    # run from HOST1

Critical: The hostnames in master.conf (HOST1 and HOST2) must match the Tailscale machine names exactly — case sensitive. The ecosystem resolves all remote IPs via tailscale ip -4 HOSTNAME at runtime. A name mismatch means every script that touches the remote will fail at the IP resolution step.


━━━ STEP 2 — ENABLE SSH ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

unRAID 7.x has SSH disabled by default. Enable it on both servers.

# On each server:
# ─────────────────────────────────────────────────────────────────────────────
# Settings → Management Access → Secure Shell
#   SSH: Enabled
#   SSH port: 22
# Apply
# ─────────────────────────────────────────────────────────────────────────────

SSH is only exposed on your local network and Tailscale interface. Scripts connect via Tailscale IP — all traffic is encrypted end-to-end. No ports are opened to the public internet.


━━━ STEP 3 — SSH KEYS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Two sets of keys needed: server-to-server for rsync and failover, and Gitea access for script repository pull. Generate all keys before configuring anything else.


── 3a — Server-to-Server Keys ─────────────────────────────────────────────

# On HOST1 — generate HOST1's key pair:
ssh-keygen -t ed25519 -f /root/.ssh/Gmer4Lfe-rsync-key -C "gmer4lfe-rsync" -N ""

# On HOST2 — generate HOST2's key pair:
ssh-keygen -t ed25519 -f /root/.ssh/Jayred365-rsync-key -C "jayred365-rsync" -N ""

── 3b — Authorise Keys Bidirectionally ─────────────────────────────────────

# HOST1's public key must be authorised on HOST2 (so HOST1 can SSH into HOST2):
# ─────────────────────────────────────────────────────────────────────────────

# On HOST1 — print the public key:
cat /root/.ssh/Gmer4Lfe-rsync-key.pub

# On HOST2 — create authorized_keys and paste HOST1's public key:
mkdir -p /root/.ssh
echo "PASTE_HOST1_PUBLIC_KEY_HERE" >> /root/.ssh/authorized_keys
chmod 600 /root/.ssh/authorized_keys

# HOST2's public key must be authorised on HOST1 (so HOST2 can SSH into HOST1):
# ─────────────────────────────────────────────────────────────────────────────

# On HOST2 — print the public key:
cat /root/.ssh/Jayred365-rsync-key.pub

# On HOST1 — append HOST2's public key:
echo "PASTE_HOST2_PUBLIC_KEY_HERE" >> /root/.ssh/authorized_keys

── 3c — Test Both Directions ────────────────────────────────────────────────

# From HOST1 — should print "connected" without a password prompt:
ssh -i /root/.ssh/Gmer4Lfe-rsync-key \
    root@$(tailscale ip -4 unRAID-Jayred365) \
    "echo connected"

# From HOST2 — should print "connected" without a password prompt:
ssh -i /root/.ssh/Jayred365-rsync-key \
    root@$(tailscale ip -4 unRAID-Gmer4Lfe) \
    "echo connected"
If prompted for a password: the key was not authorised correctly.
  → Recheck Step 3b — the public key content must be on one line
  → Check permissions: chmod 600 /root/.ssh/authorized_keys
  → Check the key file referenced in the SSH command matches what was generated

── 3d — Gitea SSH Key ───────────────────────────────────────────────────────

# On BOTH servers — generate a key for Gitea access:
ssh-keygen -t ed25519 -f /root/.ssh/unraid_gitea -C "unraid-gitea" -N ""

# Print the public key to add to Gitea:
cat /root/.ssh/unraid_gitea.pub

# In Gitea: Settings → SSH / GPG Keys → Add Key → paste the output above
# Do this for both servers if they have separate Gitea accounts, or once if shared

━━━ STEP 4 — CLONE THE REPOSITORY ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Both servers clone from the same Gitea repository. Updates pushed to the repo propagate to both servers automatically via git_pull_execute.sh at the start of each daily maintenance window.


── On Both Servers ─────────────────────────────────────────────────────────

# Create the target directory:
mkdir -p /mnt/user/appdata/unraid_scripts

# Clone the repository:
GIT_SSH_COMMAND="ssh -i /root/.ssh/unraid_gitea" \
    git clone git@YOUR_GITEA_HOST:FailedProxy/Unraid_Scripts.git \
    /mnt/user/appdata/unraid_scripts

# Replace YOUR_GITEA_HOST with your Gitea server address and port
# Example: git@192.168.50.2:221

── Verify the Structure ─────────────────────────────────────────────────────

ls /mnt/user/appdata/unraid_scripts/
Expected output:
  master.conf          ← all user configuration — the only file you edit
  master_host1.conf    ← HOST1-specific configuration
  master_host2.conf    ← HOST2-specific configuration
  common.sh            ← shared library — functions used by all scripts
  load_config.sh       ← config loader
  Orchestrators/
  Rsync/
  Failover/
  Docker_Essentials/
  unRAID_Essentials/
  Media/
  Transcodes/
  Monitors/
  Tools/
  Partnership/

── Make Scripts Executable ─────────────────────────────────────────────────

# Execute permission on all scripts — required once after clone:
find /mnt/user/appdata/unraid_scripts -name "*.sh" -exec chmod +x {} \;

array_start.sh auto-fixes permissions on scripts that lost the execute bit — but this initial chmod ensures the first run works before that safeguard is active.


━━━ STEP 5 — CONFIGURE MASTER.CONF ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

All user configuration lives in master.conf. Every value with a comment # REQUIRED must be set before the first run. Everything else has working defaults.

nano /mnt/user/appdata/unraid_scripts/master.conf

── Host Identity ─────────────────────────────────────────────────────────────

# master.conf
# ─────────────────────────────────────────────────────────────────────────────
# These must match Tailscale machine names exactly — case sensitive.
# The ecosystem uses these to resolve remote IPs at runtime.
#
    HOST1="unRAID-Gmer4Lfe"       # REQUIRED — must match tailscale machine name
    HOST2="unRAID-Jayred365"       # REQUIRED — same

# SSH key paths — each server's key for authenticating to the other:
    HOST1_SSH_KEY="/root/.ssh/Gmer4Lfe-rsync-key"   # HOST1 uses this to SSH to HOST2
    HOST2_SSH_KEY="/root/.ssh/Jayred365-rsync-key"   # HOST2 uses this to SSH to HOST1

── Emby API Keys ─────────────────────────────────────────────────────────────

# master_host1.conf
# ─────────────────────────────────────────────────────────────────────────────
# Used by: emby_session_report.sh, sunday_morning_coffee_report.sh,
#          sonarr/radarr cleanup (notify_emby_scan after deletion)
#
# Get from: Emby Dashboard → Settings → API Keys → + New API Key
#
    HOST1_EMBY_URL="http://192.168.50.2:8096"
    HOST1_EMBY_API_KEY="your-host1-emby-api-key"    # REQUIRED for Emby features
    HOST1_EMBY_CONTAINER="Emby"

# master_host2.conf
    HOST2_EMBY_URL="http://localhost:8096"
    HOST2_EMBY_API_KEY="your-host2-emby-api-key"
    HOST2_EMBY_CONTAINER="Emby"

── Git Repository ────────────────────────────────────────────────────────────

# master.conf
# ─────────────────────────────────────────────────────────────────────────────
# Used by git_pull_execute.sh — pulls latest scripts at start of each daily window.
#
    GITEA_CONTAINER="Gitea"                              # exact Docker container name
    GITEA_REPO_PATH="FailedProxy/Unraid_Scripts.git"
    TARGET_DIR="/mnt/user/appdata/unraid_scripts"
    GITEA_SSH_KEY="/root/.ssh/unraid_gitea"
    SSH_PORT=221                                          # your Gitea SSH port

── Daily Sync Shares ────────────────────────────────────────────────────────

# master_host1.conf
# ─────────────────────────────────────────────────────────────────────────────
# Shares HOST1 is source of truth for — pushed to HOST2 every night at 1am.
# HOST2 treats these as read-only mirrors. Never put the same share in both lists.
#
HOST1_DAILY_SYNC_SHARES=(
    "/mnt/user/Movies"           # HOST1 manages this — Radarr runs here
    "/mnt/user/Tv_Shows"         # HOST1 manages this — Sonarr runs here
    "/mnt/user/Music"            # HOST1 manages this — Lidarr runs here
    "/mnt/user/Kids_Movies"
    "/mnt/user/Kids_Tv_Shows"
    "/mnt/user/Sports"
    "/mnt/user/stand-up_comedy"
)

# master_host2.conf
HOST2_DAILY_SYNC_SHARES=(
    "/mnt/user/Anime_Shows"      # HOST2 manages this — his Sonarr runs here
    "/mnt/user/Anime_Movies"     # HOST2 manages this — his Radarr runs here
)

── Weekly Sync Shares ───────────────────────────────────────────────────────

# master.conf
# ─────────────────────────────────────────────────────────────────────────────
# Synced during the Sunday 2:30am window — containers stopped both sides.
# Do NOT add these to a separate cron schedule — they run via weekly_sync_maintenance.sh.
#
WEEKLY_SYNC_SHARES=(
    "/mnt/user/Media_Server/Emby"                   # full clean Emby mirror
    "/mnt/user/appdata-Failover/Critical-Data"      # auth stack clean state
)

━━━ STEP 6 — MASTER_HOST*.CONF ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Per-host configuration lives in master_host1.conf and master_host2.conf. detect_hosts() in common.sh reads which server is running and aliases the correct HOST*_ prefixed variables to their unprefixed names. Scripts only ever reference the unprefixed name — they work identically on both servers.

# master_host1.conf is only sourced on HOST1
# master_host2.conf is only sourced on HOST2
# Changes go in the right file for the right server

nano /mnt/user/appdata/unraid_scripts/master_host1.conf    # on HOST1
nano /mnt/user/appdata/unraid_scripts/master_host2.conf    # on HOST2

See each conf file's comments — every variable is documented with its purpose and the reasoning behind the value.


━━━ STEP 7 — RSYNC PROFILES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Profiles control per-share behaviour — which containers to stop, rsync flags, bandwidth limits, what to exclude. Profile is matched by directory basename (lowercased). Override with --profile=name.


── How Profile Matching Works ──────────────────────────────────────────────

# ─────────────────────────────────────────────────────────────────────────────
# rsync.sh /mnt/user/appdata-Failover/Arrs_Stack
#   basename: Arrs_Stack
#   lowercased: arrs_stack
#   matched profile: [arrs_stack]
#
# rsync.sh /mnt/user/Movies
#   basename: Movies
#   lowercased: movies
#   no matching profile → global defaults apply (no containers stopped)
#
# rsync.sh /mnt/user/appdata-Failover/Critical-Data --profile=critical-failover
#   explicit override → uses [critical-failover] profile regardless of path
# ─────────────────────────────────────────────────────────────────────────────

── Current Profiles ─────────────────────────────────────────────────────────

# master.conf — profile definitions
# ─────────────────────────────────────────────────────────────────────────────
# Each profile defines which containers to stop, rsync flags, excludes, etc.
# Containers in PROFILE_CRITICAL_CONTAINER_NAMES are stopped on BOTH servers.
# PROFILE_DELAYED_CONTAINERS restart after PROFILE_CONTAINER_DELAY seconds.

# ── arrs_stack ─────────────────────────────────────────────────────────────
# Arr databases — stopped for clean SQLite snapshot
    PROFILES["arrs_stack_CRITICAL_CONTAINER_NAMES"]=(
        "Sonarr" "Radarr" "Lidarr" "Prowlarr" "Bazarr" "Pinchflat"
    )

# ── critical-data ──────────────────────────────────────────────────────────
# Auth stack — stopped for clean database snapshot, delayed restart
    PROFILES["critical-data_CRITICAL_CONTAINER_NAMES"]=(
        "Mariadb-Authelia" "Redis-Authelia"
        "NginxProxyManager" "Lldap-Gmer4Lfe"
    )
    PROFILES["critical-data_DELAYED_CONTAINERS"]=(
        "Authelia" "Authelia-Secondary"    # auth services restart after delay
    )
    PROFILES["critical-data_CONTAINER_DELAY"]=30    # seconds before delayed containers start

# ── important-data ─────────────────────────────────────────────────────────
# NextCloud + Postgres — stopped for clean snapshot
    PROFILES["important-data_CRITICAL_CONTAINER_NAMES"]=(
        "Postgres-NextCloud"
    )
    PROFILES["important-data_DELAYED_CONTAINERS"]=("NextCloud")

# ── emby ───────────────────────────────────────────────────────────────────
# Weekly full clean sync — both Emby instances stopped
    PROFILES["emby_CRITICAL_CONTAINER_NAMES"]=("Emby")
    PROFILES["emby_EXCLUDE_DIRS"]=(
        "transcodes/" "logs/" "crash*" "cache/"
    )

# ── emby-failover ──────────────────────────────────────────────────────────
# Every 30 minutes, Emby STAYS RUNNING — dirty sync of critical state only
# WAL and SHM excluded — safe to copy while Emby is writing
    PROFILES["emby-failover_CRITICAL_CONTAINER_NAMES"]=()    # empty — nothing stops
    PROFILES["emby-failover_EXCLUDE_DIRS"]=(
        "*.wal" "*.shm"                            # WAL files — unsafe mid-write
        "transcodes/" "logs/" "crash*" "cache/"    # volatile data — skip
    )
    PROFILES["emby-failover_REMOTE_RESTART_CONTAINERS"]=("Emby")
    # Emby on HOST2 restarts after sync to pick up config changes

── Two Emby Profiles — Why Both Exist ──────────────────────────────────────

# ─────────────────────────────────────────────────────────────────────────────
# emby-failover — every 30 minutes, Emby stays running:
#   What syncs:   users.db, library.db, authentication.db, config/
#   What skips:   *.wal *.shm transcodes/ logs/ cache/
#   Why:          WAL files are being written while Emby runs — copying them
#                 would produce a corrupt database on HOST2
#   Result:       HOST2 is always within 30 minutes of HOST1 on watch state
#                 and user activity. Failover is seamless — nobody notices.
#
# emby — Sunday 2:30am, both Emby instances stopped:
#   What syncs:   everything except transcodes, logs, cache, crash files
#   What includes: metadata, plugins, full database state, all config
#   Why:          WAL is checkpointed on clean shutdown — safe to copy everything
#                 Full consistent mirror including metadata and plugin state
#   Result:       HOST2 has a gold-standard Emby state once per week
#                 Image cache warm for 6 days — only reset Sunday when users sleep
#
# The two profiles work together:
#   emby-failover: keeps HOST2 current on what matters for immediate failover
#   emby:         gives HOST2 full fidelity once per week
#   Neither alone is sufficient — both are needed.
# ─────────────────────────────────────────────────────────────────────────────

━━━ STEP 8 — PERSONAL ENCRYPTED SHARES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Personal shares are synced to the remote server for offsite backup. ZFS encrypts at the dataset level — the remote server receives encrypted blocks and cannot read the content without your passphrase or keyfile.


── Create an Encrypted ZFS Dataset ────────────────────────────────────────

# In the unRAID UI:
# ─────────────────────────────────────────────────────────────────────────────
# Main → click your ZFS pool name → + Dataset
#   Name: Gmer4Lfe-Personal
#   Encryption: Enabled
#   Passphrase: [your passphrase]
#   ⚠️  Write your passphrase down — if lost, data is completely unrecoverable
#
# Settings → Shares → Add Share
#   Share path: point to the new encrypted dataset
#   Use cache: Only — keeps data on ZFS pool, not array
# ─────────────────────────────────────────────────────────────────────────────

# Verify encryption is active before syncing:
zfs get encryption poolname/Gmer4Lfe-Personal
# Should show: encryption  aes-256-gcm

── Auto-Unlock on Boot (Optional) ─────────────────────────────────────────

# ─────────────────────────────────────────────────────────────────────────────
# Keyfile approach — passphrase stored in a file, loaded at boot.
# More convenient but the keyfile is a secret that must be protected.
# Never sync the keyfile to the remote server.
#
# Create keyfile — on HOST1 only:
dd if=/dev/urandom bs=32 count=1 | base64 > /root/.zfs-keys/personal.key
chmod 600 /root/.zfs-keys/personal.key

# Set dataset to use keyfile instead of passphrase:
zfs change-key \
    -o keylocation=file:///root/.zfs-keys/personal.key \
    -o keyformat=raw \
    poolname/Gmer4Lfe-Personal

# Add to ramdisk_setup.sh or array_start.sh custom scripts:
zfs load-key poolname/Gmer4Lfe-Personal
zfs mount poolname/Gmer4Lfe-Personal
# ─────────────────────────────────────────────────────────────────────────────
# Manual unlock alternative (most secure — passphrase only in your head):
zfs load-key poolname/Gmer4Lfe-Personal    # prompts for passphrase
zfs mount poolname/Gmer4Lfe-Personal

── Add to master_host1.conf ────────────────────────────────────────────────

# master_host1.conf
# ─────────────────────────────────────────────────────────────────────────────
# Personal shares append to the daily sync after DAILY_SYNC_SHARES.
# Remote server receives encrypted blocks — cannot read content without your key.
#
HOST1_PERSONAL_SHARES=(
    "/mnt/user/Gmer4Lfe-Personal"
)

━━━ STEP 9 — USER SCRIPTS SETUP ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

The ecosystem is designed so the User Scripts plugin has only a small number of entries — each one an orchestrator. Individual scripts are never scheduled directly.


── At Startup of Array ─────────────────────────────────────────────────────

# Create one script entry named "array start":
# ─────────────────────────────────────────────────────────────────────────────
#!/bin/bash
bash /mnt/user/appdata/unraid_scripts/Orchestrators/array_start.sh
# ─────────────────────────────────────────────────────────────────────────────
# Schedule: At Startup of Array
# Run as: Background Task
#
# This is the ONLY "At Startup of Array" entry needed.
# It launches everything in ARRAY_START_SCRIPTS from master.conf:
#   inotify_tuning.sh       — raise inotify limits before containers start
#   docker_syslog_filter.sh — suppress veth log noise
#   php_fpm_max_children.sh — WebGUI tuning
#   ramdisk_setup.sh        — create ramdisk before Emby starts
#   docker_network_connect.sh — connect containers to extra networks
#   system_watchdog.sh      — continuous system health monitor
#   docker_watchdog.sh      — continuous container health monitor
#   failover.sh             — continuous mutual failover

── Cron Schedule ────────────────────────────────────────────────────────────

# Create one script entry per cron schedule below.
# All entries: Run as Background Task
# ─────────────────────────────────────────────────────────────────────────────

# Every 3 minutes — transcode cleanup + manager:
*/3 * * * *
bash /mnt/user/appdata/unraid_scripts/Orchestrators/transcode_management.sh

# Every 30 minutes — Emby dirty sync (watch states, library delta):
*/30 * * * *
bash /mnt/user/appdata/unraid_scripts/Rsync/rsync.sh \
    /mnt/user/Media_Server/Emby --profile=emby-failover

# Every 6 hours — failed import + stalled download recovery:
0 */6 * * *
bash /mnt/user/appdata/unraid_scripts/Orchestrators/arrs_failed_stalled_recovery.sh

# 1am daily — full maintenance window:
#   git pull → rsync all shares → permissions → cleaners → arr cleanup → docker restart
0 1 * * *
bash /mnt/user/appdata/unraid_scripts/Orchestrators/daily_sync_maintenance.sh

# 2:30am Sunday — weekly maintenance window:
#   stop containers → pull updates → clean sync → start containers → weekly restarts
30 2 * * 0
bash /mnt/user/appdata/unraid_scripts/Orchestrators/weekly_sync_maintenance.sh

# 8am daily — health digest (DIGEST_PROFILE in master.conf controls when it notifies):
0 8 * * *
bash /mnt/user/appdata/unraid_scripts/Monitors/weekly_health_digest.sh

# Every 6 hours — inotify + php-fpm utilisation snapshot:
0 */6 * * *
bash /mnt/user/appdata/unraid_scripts/Monitors/system_tuning_monitor.sh

# Sunday morning — weekly reports:
0 6 * * 0   bash .../Monitors/zfs_memory_snapshot.sh
0 7 * * 0   bash .../Monitors/smart_health.sh
0 9 * * 0   bash .../Monitors/cert_monitor.sh
0 10 * * 0  bash .../Monitors/backup_verify.sh
0 11 * * 0  bash .../Monitors/emby_session_report.sh
0 11 * * 0  bash .../Monitors/bandwidth_monitor.sh --report
# ─────────────────────────────────────────────────────────────────────────────

Set all entries to "Background Task" — output streams correctly to the User Scripts log rather than buffering in the browser tab. Non-background tasks can appear to hang on long-running scripts.


━━━ STEP 10 — VERIFY THE SETUP ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Before relying on scheduled jobs, test manually from the terminal on HOST1. Test with --dry-run first — no changes made, but the full pre-flight and configuration resolution runs.


── Test a Single Profile Sync ─────────────────────────────────────────────

# Dry run with verbose output — shows every decision the script makes:
bash /mnt/user/appdata/unraid_scripts/Rsync/rsync.sh \
    /mnt/user/appdata-Failover/Arrs_Stack --dry-run --log
Expected output (healthy):
  ━━━ ⚙️ Setup ━━━
    Host: HOST1 (unRAID-Gmer4Lfe) → HOST2 (unRAID-Jayred365)
    Remote IP: 100.x.x.x
    Profile: arrs_stack

  ━━━ 🛡️ Pre-flight Checks ━━━
    ✅ Remote reachable
    ✅ version parity — both on unRAID X.Y.Z
    ✅ Remote Docker daemon responding
    ✅ Remote rootfs: 12% (threshold: 75%)
    ✅ Remote share exists and not empty
    ✅ All pre-flight checks passed

── Test the Daily Orchestrator ─────────────────────────────────────────────

# Dry run of the full daily window — shows every job that would run:
bash /mnt/user/appdata/unraid_scripts/Orchestrators/daily_sync_maintenance.sh --dry-run
If any pre-flight check fails, the script aborts with a clear error message
before touching anything. Fix the reported issue and re-run --dry-run.

Common pre-flight failures and their causes:
  "Remote not reachable"     → Tailscale not connected on HOST2
  "Version mismatch"         → different unRAID versions — update before syncing
  "Remote rootfs above X%"   → HOST2's root filesystem nearly full
  "Remote share missing"     → share doesn't exist on HOST2 yet (see Step 11)
  "Docker daemon not responding" → HOST2's Docker service not started

── Check the Configuration Resolved Correctly ──────────────────────────────

# --status shows how master.conf resolved for this server and profile:
bash /mnt/user/appdata/unraid_scripts/Rsync/rsync.sh \
    /mnt/user/appdata-Failover/Arrs_Stack --status

━━━ STEP 11 — INITIAL HOST2 SYNC ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

If HOST2 is being set up from scratch with empty shares:


── Create Share Structure on HOST2 ────────────────────────────────────────

# On HOST2 — start the array and create shares via the unRAID UI.
# Or use the share recreation tool to create disk directories from HOST1's cfg files:
bash /mnt/user/appdata/unraid_scripts/Tools/recreate_shares.sh

── Initial Push From HOST1 ─────────────────────────────────────────────────

# On HOST1 — push all shares to HOST2 for the first time:
# Use --log for verbose output on first run
bash /mnt/user/appdata/unraid_scripts/Orchestrators/daily_sync_maintenance.sh --log
First run may take several hours for large libraries — this is normal.
The scheduled nightly sync will be incremental after the initial push.
Progress shows per-share throughout.

━━━ NAMING CONSISTENCY — THIS IS REQUIRED ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

The ecosystem uses one codebase on both servers. This only works if containers and shares have identical names on both servers. This is not configurable — it is a design requirement.

# ─────────────────────────────────────────────────────────────────────────────
# Container names must match exactly on both servers:
    "Emby"                ← both HOST1 and HOST2
    "NginxProxyManager"   ← both HOST1 and HOST2
    "Mariadb-Authelia"    ← both HOST1 and HOST2

# Share paths must match exactly on both servers:
    /mnt/user/Movies      ← both HOST1 and HOST2 (HOST2 has a mirror)
    /mnt/user/Tv_Shows    ← both HOST1 and HOST2
# ─────────────────────────────────────────────────────────────────────────────
# If a container has a different name on one server: the script skips it
# without error. It silently does the wrong thing. You only notice when
# the container is not stopped during a sync that requires it to stop.
#
# If a share has a different path: rsync.sh aborts with "remote share missing".
# Easier to catch — but still requires renaming the share to fix.
#
# Keep names consistent and one codebase covers both servers automatically.
# Diverge and every script that touches containers or shares needs custom logic.
# ─────────────────────────────────────────────────────────────────────────────

━━━ REPOSITORY STRUCTURE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Unraid_Scripts/
├── master.conf                          ← All shared configuration — edit this file
├── master_host1.conf                    ← HOST1-specific configuration
├── master_host2.conf                    ← HOST2-specific configuration
├── common.sh                            ← Shared library — functions used by all scripts
├── load_config.sh                       ← Config loader — sources all conf files
│
├── Orchestrators/
│   ├── array_start.sh                   ← Single "At Startup of Array" entry point
│   ├── daily_sync_maintenance.sh        ← 1am daily window orchestrator
│   ├── weekly_sync_maintenance.sh       ← Sunday 2:30am window orchestrator
│   ├── critical_sync_maintenance.sh     ← Every 15 minutes — critical sync + partnership
│   ├── media_management.sh              ← Permissions + cleaners + arr cleanup
│   ├── transcode_management.sh          ← Transcode cleanup then manager
│   └── arrs_failed_stalled_recovery.sh  ← Failed import + stalled download recovery
│
├── Rsync/
│   └── rsync.sh                         ← Core rsync script — called per share
│
├── Failover/
│   ├── failover.sh                      ← Mutual container failover — continuous loop
│   ├── failover_test.sh                 ← Controlled iptables failover simulation
│   └── failover_state_reset.sh          ← Reset failover state manually
│
├── Docker_Essentials/
│   ├── docker_watchdog.sh               ← Two-tier container monitor — continuous loop
│   ├── docker_daily_restart.sh          ← Nightly container restarts
│   ├── docker_weekly_restart.sh         ← Weekly container restarts
│   ├── docker_network_connect.sh        ← Ensure networks + connections at array start
│   └── watchdog_skip_list_manager.sh    ← Skip list inspection and recovery
│
├── unRAID_Essentials/
│   ├── system_watchdog.sh               ← Three-tier system health monitor — continuous
│   ├── ramdisk_setup.sh                 ← Creates ramdisk + symlink at array start
│   ├── inotify_tuning.sh                ← Raise inotify limits at array start
│   ├── docker_syslog_filter.sh          ← Suppress veth log noise
│   ├── php_fpm_max_children.sh          ← WebGUI performance tuning
│   ├── server_reboot.sh                 ← Graceful reboot with pre-flight warnings
│   ├── mover_stop.sh                    ← Stop mover cleanly with wall warning
│   ├── clear_logs.sh                    ← Size-threshold log cleanup
│   ├── webgui_restart.sh                ← nginx → php-fpm → emhttp escalation
│   └── git_pull_execute.sh              ← Pull latest scripts from Gitea
│
├── Media/
│   ├── media_shares_permissions.sh      ← Apply permissions to media shares
│   ├── media_cleaner.sh                 ← Remove junk files from media shares
│   ├── lidarr_cleanup.sh                ← Remove orphaned music files (HOST1 only)
│   ├── sonarr_cleanup.sh                ← Remove orphaned TV files (host-aware)
│   └── radarr_cleanup.sh                ← Remove orphaned movie files (host-aware)
│
├── Transcodes/
│   ├── transcode_manager.sh             ← Ramdisk/SSD symlink management
│   └── transcode_cleanup.sh             ← Remove stale segment files
│
├── Monitors/
│   ├── cert_monitor.sh                  ← SSL cert expiry via live TLS connection
│   ├── backup_verify.sh                 ← rsync mirror MD5 checksum verification
│   ├── smart_health.sh                  ← Drive SMART attribute monitoring
│   ├── zfs_memory_snapshot.sh           ← ZFS health + ARC + memory report
│   ├── bandwidth_monitor.sh             ← rsync transfer logging + weekly report
│   ├── weekly_health_digest.sh          ← Full ecosystem health aggregation
│   ├── emby_session_report.sh           ← Emby streaming usage statistics
│   ├── system_tuning_monitor.sh         ← inotify + php-fpm utilisation tracking
│   └── continuous_scripts_status.sh     ← Live dashboard for background processes
│
├── Partnership/
│   └── partnership_manage.sh            ← Two-server relationship lifecycle manager
│
└── Tools/
    ├── recreate_shares.sh               ← Create share directories from cfg files
    ├── bulk_permissions_repair.sh       ← One-shot permission repair
    ├── rsync_stop.sh                    ← Stop active rsync jobs cleanly
    ├── user_scripts_stop.sh             ← Stop running user script processes
    ├── server_reboot.sh                 ← Graceful scheduled reboot
    ├── zfs_pool_scrub.sh                ← Trigger ZFS pool scrub
    └── container_data_export.sh         ← Export container configuration

━━━ TROUBLESHOOTING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━


🔴 SSH Connection Refused / Timeout

# Verify SSH is enabled on the remote:
#   Settings → Management Access → Secure Shell → Enabled

# Verify Tailscale is connected:
tailscale ip -4 unRAID-Jayred365    # should return 100.x.x.x

# Test SSH manually with the key:
ssh -i /root/.ssh/Gmer4Lfe-rsync-key root@$(tailscale ip -4 unRAID-Jayred365) "hostname"
# Expected: unRAID-Jayred365
# If password prompted: key not authorised — recheck Step 3b

🔴 Pre-flight Aborts on Remote Rootfs

# Remote rootfs above ROOTFS_WARN threshold
# Check current usage on remote:
ssh -i /root/.ssh/Gmer4Lfe-rsync-key root@[HOST2-ip] "df /"

# Common cause: array not started, drives not mounted
# Verify array is started on HOST2 before running syncs

🔴 Remote Share Missing

# Share exists locally but not on remote
# Verify the share exists on HOST2:
ssh -i /root/.ssh/Gmer4Lfe-rsync-key root@[HOST2-ip] "ls /mnt/user/"

# If missing — create the share on HOST2 first, then run initial sync (Step 11)
# Or run recreate_shares.sh on HOST2 to create directories from HOST1's cfg files

🔴 Containers Not Stopping / Starting

# Verify container names in master_host*.conf match Docker exactly — case sensitive
# Check what Docker actually calls the container:
docker ps --format "{{.Names}}"

# Test Docker commands to remote manually:
ssh -i /root/.ssh/Gmer4Lfe-rsync-key root@[HOST2-ip] "docker ps --format '{{.Names}}'"

🔴 Profile Not Matching

# Profile key = directory basename lowercased
# /mnt/user/appdata-Failover/Arrs_Stack → basename: Arrs_Stack → key: arrs_stack

# Override explicitly if basename doesn't match a profile name:
bash rsync.sh /mnt/user/appdata-Failover/My_Stuff --profile=arrs_stack

# Verify what profile resolved for a path:
bash rsync.sh /mnt/user/appdata-Failover/Arrs_Stack --status

🔴 Script Not Found

# Verify repo was cloned to the correct location:
ls /mnt/user/appdata/unraid_scripts/master.conf

# Make scripts executable:
find /mnt/user/appdata/unraid_scripts -name "*.sh" -exec chmod +x {} \;

━━━ AVAILABLE FLAGS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

All scripts support these flags. Use --dry-run before any live operation.

# ─────────────────────────────────────────────────────────────────────────────
    --dry-run    run without making any changes — pre-flight still runs ✅
    --log        verbose output — show every decision made
    --status     show resolved configuration and exit — no rsync, no sync
    --no-log     suppress verbose output (some scripts)

# Examples:
bash rsync.sh /mnt/user/Movies --dry-run --log        # preview a media sync
bash rsync.sh /mnt/user/appdata-Failover/Arrs_Stack --status  # check profile resolution
bash daily_sync_maintenance.sh --dry-run              # preview full daily window
bash docker_watchdog.sh --status                      # check watchdog state
# ─────────────────────────────────────────────────────────────────────────────