true bidirectional with split truth modle set up. all scripts are fully bidirectional
This commit is contained in:
@@ -485,7 +485,12 @@ run_handback() {
|
||||
[[ -z "$job" ]] && continue
|
||||
info "Syncing: $job"
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
bash "$SCRIPT_DIR/../Rsync/rsync.sh" "$job"
|
||||
# Emby writeback uses emby-failover profile — critical data only, Emby stays running
|
||||
if [[ "$(basename "$job")" == "Emby" ]]; then
|
||||
bash "$SCRIPT_DIR/../Rsync/rsync.sh" "$job" --profile=emby-failover
|
||||
else
|
||||
bash "$SCRIPT_DIR/../Rsync/rsync.sh" "$job"
|
||||
fi
|
||||
else
|
||||
warn "DRY RUN — would rsync: $job"
|
||||
fi
|
||||
|
||||
+51
-23
@@ -280,7 +280,14 @@ declare -A PROFILE_RSYNC_OPTS=(
|
||||
[critical-data]="-av --human-readable --bwlimit=$BW_LIMIT --delete"
|
||||
[gmer4lfe]="-av --info=progress2 --bwlimit=$BW_LIMIT"
|
||||
[important-data]="-av --human-readable --bwlimit=$BW_LIMIT"
|
||||
[emby]="-av --human-readable --bwlimit=$BW_LIMIT"
|
||||
# emby — nightly clean sync, both Emby instances stopped
|
||||
# WAL checkpointed on shutdown — full consistent mirror, minimal excludes
|
||||
[emby]="-av --human-readable --bwlimit=$BW_LIMIT --delete --inplace --no-whole-file"
|
||||
# emby-failover — frequent dirty sync every 30-60min, Emby stays running
|
||||
# Only critical failover data — what users need immediately on failover
|
||||
# WAL excluded — safe dirty write while Emby is running
|
||||
# This is also the list written back during failover handback
|
||||
[emby-failover]="-av --human-readable --bwlimit=$BW_LIMIT --delete --inplace --no-whole-file"
|
||||
)
|
||||
|
||||
# Bandwidth limit in KB/s per profile — overrides global BW_LIMIT for this profile only
|
||||
@@ -289,7 +296,8 @@ declare -A PROFILE_BW_LIMIT=(
|
||||
[critical-data]=9500 # high — small data, get it synced fast
|
||||
[gmer4lfe]=8000 # medium
|
||||
[important-data]=9500 # high — database sync needs to be fast and clean
|
||||
[emby]=8000 # medium — large files, steady sustained transfer
|
||||
[emby]=8000 # medium — full mirror, steady transfer
|
||||
[emby-failover]=9500 # high — small critical dataset, get it synced fast
|
||||
)
|
||||
|
||||
# Retry attempts per profile before giving up — overrides global RETRY_COUNT
|
||||
@@ -299,6 +307,7 @@ declare -A PROFILE_RETRY_COUNT=(
|
||||
[gmer4lfe]=3
|
||||
[important-data]=3
|
||||
[emby]=3
|
||||
[emby-failover]=3
|
||||
)
|
||||
|
||||
# Seconds between retry attempts — overrides global SLEEP
|
||||
@@ -308,64 +317,83 @@ declare -A PROFILE_SLEEP=(
|
||||
[gmer4lfe]=300
|
||||
[important-data]=300
|
||||
[emby]=300
|
||||
[emby-failover]=120 # shorter — frequent sync, retry faster
|
||||
)
|
||||
|
||||
# Containers stopped on REMOTE before rsync and restarted after completion.
|
||||
# Only include containers that need to be stopped for data consistency.
|
||||
# Databases and auth stacks need clean state — media servers generally do not.
|
||||
# Containers stopped on BOTH LOCAL and REMOTE servers before rsync.
|
||||
# Local stops first — flushes databases cleanly before push.
|
||||
# Remote stops next — prevents writes while receiving.
|
||||
# Only containers that were running get restarted — stopped containers stay stopped.
|
||||
# Same container names used on both HOST1 and HOST2 — naming scheme is consistent.
|
||||
# If a container is not found on a server it is skipped gracefully, not errored.
|
||||
# SPACE-SEPARATED STRINGS — converted to array at runtime
|
||||
declare -A PROFILE_CRITICAL_CONTAINER_NAMES=(
|
||||
[arrs_stack]="Sonarr Lidarr Readarr Radarr Prowlarr Bazarr Pinchflat"
|
||||
[critical-data]="Mariadb-Authelia Redis-Authelia Lldap-Gmer4Lfe NginxProxyManager Authelia"
|
||||
[critical-data]="Mariadb-Authelia Mariadb-Authelia-Secondary Redis-Authelia Redis-Authelia-Secondary Lldap-Gmer4Lfe NginxProxyManager Authelia Authelia-Secondary"
|
||||
[gmer4lfe]="Organizrv2-Gmer4Lfe UptimeKuma-Gmer4Lfe VaultWarden-Gmer4Lfe"
|
||||
[important-data]="Postgres-NextCloud NextCloud"
|
||||
[emby]=""
|
||||
[emby]="Emby" # nightly clean sync — Emby stopped both sides, WAL checkpointed
|
||||
[emby-failover]="" # dirty sync — Emby stays running both sides
|
||||
)
|
||||
|
||||
# Containers that need a delay before starting after rsync completes.
|
||||
# Used when a container depends on another that was also stopped — it needs its
|
||||
# dependency to be ready before it can start successfully.
|
||||
# Used when a container depends on another that was also stopped.
|
||||
# Startup order for critical-data:
|
||||
# Immediate: Mariadb x2, Redis x2, Lldap, NginxProxyManager
|
||||
# Delayed: Authelia, Authelia-Secondary (need databases ready)
|
||||
# SPACE-SEPARATED STRINGS — converted to array at runtime
|
||||
declare -A PROFILE_DELAYED_CONTAINERS=(
|
||||
[arrs_stack]=""
|
||||
[critical-data]="Authelia" # Authelia needs Mariadb + Redis ready before starting
|
||||
[critical-data]="Authelia Authelia-Secondary"
|
||||
[gmer4lfe]=""
|
||||
[important-data]="NextCloud" # NextCloud needs Postgres accepting connections first
|
||||
[emby]=""
|
||||
[emby-failover]=""
|
||||
)
|
||||
|
||||
# Seconds to wait before starting delayed containers — gives dependencies time to initialise
|
||||
# Seconds to wait before starting delayed containers
|
||||
declare -A PROFILE_CONTAINER_DELAY=(
|
||||
[arrs_stack]=5
|
||||
[critical-data]=10 # 10s gives Mariadb and Redis time to accept connections
|
||||
[critical-data]=15 # 15s gives Mariadb, Redis, and LLDAP time to accept connections
|
||||
[gmer4lfe]=5
|
||||
[important-data]=10 # 10s gives Postgres time to accept connections
|
||||
[important-data]=10
|
||||
[emby]=5
|
||||
[emby-failover]=5
|
||||
)
|
||||
|
||||
# Directories excluded from rsync transfer per profile.
|
||||
# logs and *.tmp are safe to exclude — they are ephemeral and regenerated on container start.
|
||||
# SPACE-SEPARATED STRINGS — converted to array at runtime
|
||||
declare -A PROFILE_EXCLUDE_DIRS=(
|
||||
[arrs_stack]="logs *.tmp"
|
||||
[critical-data]="logs *.tmp"
|
||||
[gmer4lfe]="logs *.tmp"
|
||||
[important-data]="logs *.tmp"
|
||||
[emby]="logs *.tmp"
|
||||
# Critical-Data — auth stack
|
||||
# Containers stopped during sync — databases flush cleanly
|
||||
# Generated configs, logs, and temp files excluded
|
||||
[critical-data]="logs *.tmp *.log nginx/temp nginx/cache __pycache__ notification.txt"
|
||||
# Emby nightly clean sync — both Emby instances stopped
|
||||
# WAL checkpointed on shutdown — safe to push everything except true junk
|
||||
# Full faithful mirror: metadata, plugins, config all included
|
||||
[emby]="logs transcodes cache crash*"
|
||||
# Emby failover dirty sync — Emby stays running
|
||||
# Only what users need immediately on failover:
|
||||
# users.db ← watch states, continue watching, next up, played state
|
||||
# library.db ← library structure
|
||||
# authentication.db ← API keys, sessions
|
||||
# config/ ← server settings
|
||||
# WAL and SHM excluded — unsafe while Emby is running
|
||||
# Also the list written back during failover handback
|
||||
[emby-failover]="logs transcodes cache metadata *.db-wal *.db-shm crash* plugins root"
|
||||
)
|
||||
|
||||
# Per-disk check toggle — controls whether rsync.sh runs check_remote_disks() for this profile.
|
||||
# true = skip per-disk check — use when remote share lives on a ZFS pool
|
||||
# ZFS pools don't have /mnt/disk* structure so the check always fails incorrectly
|
||||
# false = run per-disk check — use for traditional unRAID array with individual disk mounts
|
||||
# Verifies all disks backing the share are online before syncing
|
||||
# Note: rootfs and share existence checks always run regardless of this setting
|
||||
# Per-disk check toggle
|
||||
declare -A PROFILE_SKIP_DISK_CHECK=(
|
||||
[arrs_stack]=true # remote uses ZFS pool — no individual disk mounts
|
||||
[arrs_stack]=true
|
||||
[critical-data]=true
|
||||
[gmer4lfe]=true
|
||||
[important-data]=true
|
||||
[emby]=true
|
||||
[emby-failover]=true
|
||||
)
|
||||
|
||||
# ==============================================================================================
|
||||
|
||||
@@ -135,6 +135,68 @@ The same share lists are used by `failover.sh` for Tier 4 writeback — but in t
|
||||
|
||||
---
|
||||
|
||||
### `nightly_critical_full_sync.sh`
|
||||
|
||||
Runs a clean nightly sync for Emby and the auth stack (Critical-Data) with containers stopped. This is the companion to the hourly dirty sync — it provides a fully consistent state on HOST2 once per night.
|
||||
|
||||
```bash
|
||||
# Scheduled as: 30 2 * * * (2:30am daily — after daily_sync.sh finishes)
|
||||
/mnt/user/appdata/unraid_scripts/Orchestrators/nightly_critical_full_sync.sh
|
||||
```
|
||||
|
||||
**Why two Emby syncs:**
|
||||
|
||||
The hourly dirty sync runs with Emby up — WAL files excluded, watch states pushed continuously. This means HOST2 is never more than an hour behind on watch state. But it's not a clean database snapshot.
|
||||
|
||||
The nightly clean sync stops Emby, syncs the full clean database state, then restarts. HOST2 gets a fully consistent Emby state every night. The two syncs work together:
|
||||
|
||||
```
|
||||
Hourly dirty sync (Emby running):
|
||||
users.db, library.db, authentication.db, config/
|
||||
WAL excluded — safe mid-write
|
||||
HOST2 always within 1hr of HOST1 on watch state
|
||||
|
||||
Nightly clean sync (Emby stopped):
|
||||
Full clean snapshot — all databases flushed
|
||||
No WAL files in flight
|
||||
HOST2 gets gold-standard state once per night
|
||||
```
|
||||
|
||||
**Why clean auth sync matters:**
|
||||
|
||||
The auth stack runs warm on both servers continuously. During normal operation HOST2's auth stack serves its own domain — it doesn't receive dirty updates from HOST1. The nightly clean sync is the only time auth state propagates.
|
||||
|
||||
This means:
|
||||
- New user added on HOST1 → propagates to HOST2 overnight automatically
|
||||
- Proxy rule changes → propagated overnight
|
||||
- No manual intervention needed for most auth changes
|
||||
|
||||
For users who just want failover to work — this script handles it. No thinking required about dirty writes, WAL files, or when to sync.
|
||||
|
||||
**What it syncs:**
|
||||
|
||||
```
|
||||
Emby appdata:
|
||||
users.db, library.db, authentication.db, config/
|
||||
Containers stopped → clean flush → safe copy
|
||||
|
||||
Critical-Data (auth stack):
|
||||
NPM proxy rules + SSL certs
|
||||
Authelia config + database
|
||||
Mariadb-Authelia data
|
||||
Redis-Authelia session store
|
||||
LLDAP users and groups database
|
||||
All auth containers stopped → clean databases → safe copy
|
||||
Authelia delayed start on restart — Mariadb + Redis must be ready first
|
||||
```
|
||||
|
||||
**What it excludes (per rsync profile):**
|
||||
|
||||
```
|
||||
Emby: logs, transcodes, cache, metadata, *.db-wal, *.db-shm
|
||||
Auth: logs, *.tmp, nginx/temp, nginx/cache, notification.txt
|
||||
```
|
||||
|
||||
### `media_management.sh`
|
||||
|
||||
Runs all media maintenance scripts sequentially in the order defined in `Master.conf`. Scheduled once daily, typically after the nightly sync.
|
||||
@@ -240,12 +302,11 @@ This pattern means:
|
||||
# Recommended schedule
|
||||
*/3 * * * * transcode_management.sh # cleanup then manager — every 3 minutes
|
||||
0 1 * * * daily_sync.sh # 1am — media shares to remote
|
||||
0 2 * * * media_management.sh # 2am — after sync completes
|
||||
0 2 * * * media_management.sh # 2am — permissions, cleaners, arr cleanup
|
||||
30 2 * * * nightly_critical_full_sync.sh # 2:30am — clean Emby + auth stack sync
|
||||
```
|
||||
|
||||
The 1 hour gap between them is intentional. `daily_sync.sh` can take 30-60 minutes on a large library. Starting `media_management.sh` before it finishes risks permission and cleanup operations running on files that are mid-transfer.
|
||||
|
||||
If your sync consistently finishes well under an hour, reduce the gap. If it regularly runs long, increase it.
|
||||
The gaps are intentional. `daily_sync.sh` can take 30-60 minutes on a large library. `media_management.sh` starts at 2am giving daily_sync an hour to finish. `nightly_critical_full_sync.sh` starts at 2:30am giving media_management time to complete its permission and cleanup pass before Emby is stopped for the clean sync.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
#!/bin/bash
|
||||
# -----------------------------------------------------------------------------------------------
|
||||
# --------------------------------- Nightly Critical Full Sync Orchestrator ----------------------------------
|
||||
# -----------------------------------------------------------------------------------------------
|
||||
# Runs clean nightly sync for Emby and the auth stack (Critical-Data).
|
||||
# Both require containers stopped for a consistent, safe state sync.
|
||||
#
|
||||
# Why this exists as a separate orchestrator from daily_sync.sh:
|
||||
# daily_sync.sh handles media shares — large, runs at 1am, no container stops needed
|
||||
# This script handles appdata that needs containers stopped for clean state:
|
||||
#
|
||||
# Emby:
|
||||
# Hourly dirty sync runs continuously (containers up, WAL excluded)
|
||||
# Nightly clean sync stops Emby → syncs full clean state → restarts Emby
|
||||
# Ensures HOST2 has a fully consistent Emby database nightly
|
||||
#
|
||||
# Critical-Data (auth stack):
|
||||
# NPM, Authelia, Mariadb-Authelia, Redis-Authelia, LLDAP
|
||||
# Containers stopped during sync — databases flush cleanly
|
||||
# HOST2 gets a clean auth state nightly
|
||||
# Users, groups, proxy rules, SSL certs all consistent
|
||||
# Adding a user on HOST1 → propagates to HOST2 overnight automatically
|
||||
#
|
||||
# Schedule: 2:30am daily — after daily_sync.sh (1am) finishes
|
||||
# Container stop time is brief — Emby ~30s, auth stack ~15s
|
||||
# Users experience a short Emby interruption at 2:30am — acceptable tradeoff
|
||||
#
|
||||
# For most users: this script ensures failover always has a clean working auth state
|
||||
# without needing to think about dirty writes or WAL files.
|
||||
#
|
||||
# All configuration in Master.conf — rsync profiles handle container stops automatically.
|
||||
# Supports --dry-run to walk through without stopping containers or syncing.
|
||||
# -----------------------------------------------------------------------------------------------
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
source "$SCRIPT_DIR/../Master.conf"
|
||||
source "$SCRIPT_DIR/../common.sh"
|
||||
|
||||
RSYNC_SCRIPT="$SCRIPT_DIR/../Rsync/rsync.sh"
|
||||
|
||||
parse_args "$@"
|
||||
|
||||
# -----------------------------------------------------------------------------------------------
|
||||
# ━━━ $ICON_GEAR Setup ━━━
|
||||
# -----------------------------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "━━━ $ICON_GEAR Setup ━━━"
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
error "Must be run as root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
success "Running as root"
|
||||
|
||||
acquire_lock
|
||||
|
||||
detect_hosts
|
||||
resolve_remote_ip
|
||||
|
||||
# -----------------------------------------------------------------------------------------------
|
||||
# ━━━ $ICON_SHIELD Pre-flight Checks ━━━
|
||||
# -----------------------------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "━━━ $ICON_SHIELD Pre-flight Checks ━━━"
|
||||
|
||||
check_connectivity
|
||||
check_remote_rootfs
|
||||
|
||||
# -----------------------------------------------------------------------------------------------
|
||||
# Tracking
|
||||
# -----------------------------------------------------------------------------------------------
|
||||
PASS=()
|
||||
FAIL=()
|
||||
SHARE_TIMES=()
|
||||
TOTAL_START=$(date +%s)
|
||||
|
||||
SYNC_JOBS=(
|
||||
"/mnt/user/appdata-Failover/Emby"
|
||||
"/mnt/user/appdata-Failover/Critical-Data"
|
||||
)
|
||||
|
||||
SHARE_COUNT=${#SYNC_JOBS[@]}
|
||||
|
||||
# -----------------------------------------------------------------------------------------------
|
||||
# ━━━ $ICON_SYNC Nightly Clean Sync ━━━
|
||||
# -----------------------------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "━━━ $ICON_SYNC Nightly Clean Sync Starting — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
|
||||
echo "$ICON_SUMMARY Jobs: $SHARE_COUNT"
|
||||
echo ""
|
||||
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — containers will not be stopped"
|
||||
|
||||
JOB_NUM=0
|
||||
for JOB in "${SYNC_JOBS[@]}"; do
|
||||
((JOB_NUM++))
|
||||
JOB_NAME=$(basename "$JOB")
|
||||
echo "━━━ [$JOB_NUM/$SHARE_COUNT] $JOB_NAME ━━━"
|
||||
|
||||
JOB_START=$(date +%s)
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
bash "$RSYNC_SCRIPT" "$JOB" --dry-run
|
||||
else
|
||||
bash "$RSYNC_SCRIPT" "$JOB"
|
||||
fi
|
||||
|
||||
EXIT_CODE=$?
|
||||
JOB_END=$(date +%s)
|
||||
JOB_DURATION=$(format_duration $(( JOB_END - JOB_START )))
|
||||
|
||||
if [[ "$EXIT_CODE" -eq 0 ]]; then
|
||||
PASS+=("$JOB_NAME")
|
||||
success "$JOB_NAME — $ICON_SUCCESS done in $JOB_DURATION"
|
||||
else
|
||||
FAIL+=("$JOB_NAME")
|
||||
error "$JOB_NAME — $ICON_ERROR failed after $JOB_DURATION"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
done
|
||||
|
||||
TOTAL_END=$(date +%s)
|
||||
TOTAL_DURATION=$(format_duration $(( TOTAL_END - TOTAL_START )))
|
||||
|
||||
# -----------------------------------------------------------------------------------------------
|
||||
# ━━━ $ICON_SUMMARY Summary ━━━
|
||||
# -----------------------------------------------------------------------------------------------
|
||||
echo "━━━━━ $ICON_SUMMARY NIGHTLY SYNC SUMMARY ━━━━━"
|
||||
echo "$ICON_TIME Duration: $TOTAL_DURATION"
|
||||
echo "$ICON_SUCCESS Passed: ${#PASS[@]} $ICON_ERROR Failed: ${#FAIL[@]}"
|
||||
echo ""
|
||||
|
||||
if [[ ${#PASS[@]} -gt 0 ]]; then
|
||||
for job in "${PASS[@]}"; do echo " $ICON_SUCCESS $job"; done
|
||||
fi
|
||||
if [[ ${#FAIL[@]} -gt 0 ]]; then
|
||||
for job in "${FAIL[@]}"; do echo " $ICON_ERROR $job"; done
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
echo "$ICON_WARN Status: DRY RUN — no changes made"
|
||||
elif [[ ${#FAIL[@]} -eq 0 ]]; then
|
||||
echo "$ICON_DONE Status: $ICON_SUCCESS ALL JOBS COMPLETE"
|
||||
notify "Nightly sync complete on $(hostname) — Emby + auth stack synced cleanly" "Nightly Sync" "normal"
|
||||
else
|
||||
echo "$ICON_ERROR Status: $ICON_ERROR ${#FAIL[@]} JOB(S) FAILED"
|
||||
notify "Nightly sync failed on $(hostname) — failed: ${FAIL[*]}" "Nightly Sync" "warning"
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
@@ -180,9 +180,36 @@ Remote returns → FAILOVER_HANDBACK_STRIKES consecutive stable checks
|
||||
|
||||
**The hardware doesn't need to match.** `/mnt/user/` abstracts everything. A share called `Movies` is `/mnt/user/Movies` on both servers regardless of what drives or pools back it. rsync syncs the content. Container mounts use the same path. The hardware underneath is irrelevant.
|
||||
|
||||
**The shared media library and split source of truth:**
|
||||
**Media library configurations — multiple valid setups:**
|
||||
|
||||
Each server owns specific shares as source of truth — managed by their own arrs. The other server mirrors those shares and treats them as read only in normal operation.
|
||||
The ecosystem supports several library arrangements. The right choice depends on how the two servers and their users relate to each other. All configurations use the same scripts — `HOST1_DAILY_SYNC_SHARES` and `HOST2_DAILY_SYNC_SHARES` in `Master.conf` define who owns what.
|
||||
|
||||
---
|
||||
|
||||
**Configuration A — Single source, one owner (simplest):**
|
||||
|
||||
One server owns all media. The other mirrors everything and piggybacks.
|
||||
|
||||
```
|
||||
HOST1 owns everything:
|
||||
Movies, Tv_Shows, Music, Anime — all managed by HOST1's arrs
|
||||
HOST2 mirrors all shares — read only in normal operation
|
||||
HOST2 arrs disabled or not running
|
||||
|
||||
HOST1_DAILY_SYNC_SHARES=(Movies Tv_Shows Music Anime_Shows Anime_Movies ...)
|
||||
HOST2_DAILY_SYNC_SHARES=() ← nothing — HOST2 owns nothing
|
||||
|
||||
Best for:
|
||||
One person running both servers
|
||||
Secondary server is pure failover + piggybacking
|
||||
Simpler arr management — one set of arrs, one library
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Configuration B — Split source of truth (this setup):**
|
||||
|
||||
Each server owns specific shares — managed by their own arrs. The other mirrors those shares read only.
|
||||
|
||||
```
|
||||
HOST1 source of truth: HOST2 source of truth:
|
||||
@@ -197,7 +224,7 @@ Both servers run arrs simultaneously — no conflict because they manage complet
|
||||
|
||||
```
|
||||
HOST2 arrs: midnight → noon downloading and managing anime
|
||||
HOST1 Tdarr: 12:30 → 23:00 transcoding anime, syncs as source of truth
|
||||
HOST1 Tdarr: 12:30 → 23:00 transcoding, syncs as source of truth
|
||||
```
|
||||
|
||||
**The rule is not "don't run arrs on both servers" — it's:**
|
||||
@@ -212,6 +239,51 @@ HOST2 /mnt/user/Anime_Shows → rsync → HOST1 /mnt/user/Anime_Shows
|
||||
|
||||
At Tier 4 failover (18hr+ outage) each server's arr copies spin up to cover the other's shares — but only when the truth holder has genuinely been down long enough to need it.
|
||||
|
||||
---
|
||||
|
||||
**Configuration C — Weighted split:**
|
||||
|
||||
One server owns most shares, the other owns a smaller subset. Same mechanics as B — just an unequal split based on what each person actually manages.
|
||||
|
||||
```
|
||||
HOST1 owns: Movies, Tv_Shows, Music, Kids_Movies, Stand-up, Sports
|
||||
HOST2 owns: Anime_Shows, Anime_Movies
|
||||
|
||||
HOST1_DAILY_SYNC_SHARES=(Movies Tv_Shows Music Kids_Movies ...)
|
||||
HOST2_DAILY_SYNC_SHARES=(Anime_Shows Anime_Movies)
|
||||
```
|
||||
|
||||
Most real-world split setups will land here — one person has a larger collection, the other a specific niche.
|
||||
|
||||
---
|
||||
|
||||
**Configuration D — Fully shared, no split:**
|
||||
|
||||
Both servers have access to all media but neither "owns" it in a managed sense. No arrs running on either for the shared shares. Content added manually or by one designated manager.
|
||||
|
||||
```
|
||||
HOST1_DAILY_SYNC_SHARES=(Movies Tv_Shows Music ...)
|
||||
HOST2_DAILY_SYNC_SHARES=()
|
||||
|
||||
One-way push — HOST1 is the single truth holder
|
||||
HOST2 is a read-only mirror for all media
|
||||
Simpler than split — no arr coordination needed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**What all configurations have in common:**
|
||||
|
||||
```
|
||||
Same share names on both servers ← /mnt/user/Movies everywhere
|
||||
Same container names on both servers ← Emby, NginxProxyManager everywhere
|
||||
HOST*_DAILY_SYNC_SHARES defines truth ← who pushes what, automatically
|
||||
daily_sync.sh reads the correct list ← direction determined at runtime
|
||||
Failover Tier 4 uses opposing list ← writeback always correct direction
|
||||
```
|
||||
|
||||
The scripts don't need to know which configuration you're running. The arrays in `Master.conf` define the truth holders — everything else follows automatically.
|
||||
|
||||
**Container naming convention:**
|
||||
|
||||
```
|
||||
@@ -441,6 +513,47 @@ Unraid_Scripts/
|
||||
|
||||
**Bidirectional.** Both servers run identical scripts. `detect_hosts()` determines local vs remote at runtime. One codebase covers both directions.
|
||||
|
||||
**Exact naming consistency across both servers — non-negotiable.**
|
||||
|
||||
This is the most important operational principle and the one most likely to cause problems if ignored. The ecosystem is built on the assumption that containers and shares have identical names on both servers.
|
||||
|
||||
```
|
||||
Container names:
|
||||
Emby ← HOST1 and HOST2
|
||||
Mariadb-Authelia ← HOST1 and HOST2
|
||||
Lldap-Gmer4Lfe ← HOST1 and HOST2
|
||||
NginxProxyManager ← HOST1 and HOST2
|
||||
|
||||
Share names:
|
||||
/mnt/user/Movies ← HOST1 and HOST2
|
||||
/mnt/user/Tv_Shows ← HOST1 and HOST2
|
||||
/mnt/user/Anime_Shows ← HOST1 and HOST2
|
||||
```
|
||||
|
||||
Why this matters:
|
||||
|
||||
```
|
||||
rsync.sh: pushes /mnt/user/Movies → remote /mnt/user/Movies
|
||||
same path, both sides — no mapping, no translation
|
||||
|
||||
Container stops:
|
||||
PROFILE_CRITICAL_CONTAINER_NAMES stops "Emby" locally
|
||||
then stops "Emby" on remote
|
||||
one list, both servers, no duplication
|
||||
|
||||
failover.sh: starts "Emby" on covering server
|
||||
same name it always has everywhere
|
||||
|
||||
detect_hosts(): compares hostname against HOST1/HOST2 in Master.conf
|
||||
exact string match — hostnames must match exactly
|
||||
```
|
||||
|
||||
Years of running with custom per-user share paths (`/mnt/user/me/movies`, `/mnt/user/jayred/movies`) taught the hard lesson — every difference becomes a maintenance burden. Every script needs to know about it. Every rsync needs a custom path. Every container stop list diverges.
|
||||
|
||||
Shared media shares should be shared — identical paths, identical names, no ownership prefix. User-specific data belongs in personal encrypted shares that are explicitly separate from the shared ecosystem. That boundary keeps the common infrastructure simple and the private data private.
|
||||
|
||||
If a container or share has a different name on one server — the script either skips it gracefully or fails to find it. It will not error catastrophically, but it also will not do what you expect. Consistent naming is what makes one codebase work on both servers without modification.
|
||||
|
||||
**Self-healing layers.** Problems are addressed at the most targeted level first:
|
||||
```
|
||||
docker_watchdog.sh — container level, minimal disruption
|
||||
|
||||
+334
-106
@@ -1,215 +1,438 @@
|
||||
This guide is a work in progress and generated by ai, dont have time for guides. so this works
|
||||
# Rsync Setup Guide
|
||||
> **Status:** Work in Progress
|
||||
> For the unRAID Rsync Ecosystem — `common.sh` · `Master.conf` · `rsync.sh` · `daily_sync.sh`
|
||||
|
||||
Rsync Setup Guide
|
||||
---
|
||||
|
||||
Status: Work in Progress
|
||||
For the unRAID Rsync Ecosystem — common.sh · Master.conf · rsync.sh · daily_sync.sh
|
||||
|
||||
Overview
|
||||
## Overview
|
||||
|
||||
This guide walks through setting up the rsync ecosystem on both your primary and secondary unRAID 7.x servers. By the end you will have:
|
||||
|
||||
SSH keys configured for server-to-server communication
|
||||
Tailscale running on both servers for secure networking
|
||||
Scripts scheduled and running via the User Scripts plugin
|
||||
Automated daily sync of media shares and appdata profiles
|
||||
Prerequisites
|
||||
- A Gitea repository cloned to both servers
|
||||
- SSH keys configured for server-to-server communication
|
||||
- Tailscale running on both servers for secure networking
|
||||
- Scripts scheduled and running via the User Scripts plugin
|
||||
- Automated daily sync of media shares and appdata profiles
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Both servers need the following before starting:
|
||||
|
||||
unRAID 7.x
|
||||
User Scripts plugin installed via Community Applications
|
||||
Tailscale plugin installed via Community Applications
|
||||
Terminal access to both servers (via unRAID UI → Tools → Terminal, or SSH)
|
||||
Step 1 — Tailscale Setup
|
||||
- unRAID 7.x
|
||||
- [User Scripts plugin](https://forums.unraid.net/topic/48286-plugin-user-scripts/) installed via Community Applications
|
||||
- [Tailscale plugin](https://forums.unraid.net/topic/136889-tailscale-plugin/) installed via Community Applications
|
||||
- Access to a Gitea instance (self-hosted or remote)
|
||||
- Terminal access to both servers (via unRAID UI → Tools → Terminal, or SSH)
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Tailscale Setup
|
||||
|
||||
Tailscale provides the secure network tunnel between your two servers. The scripts resolve the remote server's IP via Tailscale at runtime.
|
||||
|
||||
On Both Servers
|
||||
### On Both Servers
|
||||
|
||||
Open Apps in the unRAID UI
|
||||
Search for Tailscale and install the plugin
|
||||
Once installed, go to Settings → Tailscale
|
||||
Click Connect and authenticate with your Tailscale account
|
||||
Verify both servers appear in your Tailscale admin console
|
||||
1. Open **Apps** in the unRAID UI
|
||||
2. Search for **Tailscale** and install the plugin
|
||||
3. Once installed go to **Settings → Tailscale**
|
||||
4. Click **Connect** and authenticate with your Tailscale account
|
||||
5. Verify both servers appear in your [Tailscale admin console](https://login.tailscale.com/admin/machines)
|
||||
|
||||
Verify Connectivity
|
||||
### Verify Connectivity
|
||||
|
||||
Run this on the primary to confirm it can see the secondary:
|
||||
|
||||
```bash
|
||||
tailscale ip -4 unRAID-Jayred365
|
||||
```
|
||||
|
||||
You should get back a 100.x.x.x IP. If not, check both servers in the Tailscale admin console.
|
||||
You should get back a `100.x.x.x` IP. If not, check that both machines are authenticated and connected in the Tailscale admin console.
|
||||
|
||||
Note: The hostnames used in Master.conf (HOST1 and HOST2) must match the Tailscale machine names exactly — these are case sensitive.
|
||||
> **Note:** The hostnames used in `Master.conf` (`HOST1` and `HOST2`) must match the Tailscale machine names exactly — these are case sensitive.
|
||||
|
||||
Step 2 — Generate SSH Keys (Server-to-Server)
|
||||
---
|
||||
|
||||
The scripts use SSH keys for server-to-server rsync.
|
||||
## Step 2 — Generate SSH Keys
|
||||
|
||||
On Primary (unRAID-Gmer4Lfe):
|
||||
The scripts use SSH keys for two purposes:
|
||||
- **Server-to-server rsync** — primary authenticates to secondary (and vice versa)
|
||||
- **Gitea access** — both servers pull from the git repository
|
||||
|
||||
### 2a — Server-to-Server Keys
|
||||
|
||||
Run the following on **each server** to generate its rsync key. Replace the filename with the appropriate server name.
|
||||
|
||||
**On Primary (unRAID-Gmer4Lfe):**
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -f /root/.ssh/Gmer4Lfe-rsync-key -C "gmer4lfe-rsync" -N ""
|
||||
```
|
||||
|
||||
On Secondary (unRAID-Jayred365):
|
||||
**On Secondary (unRAID-Jayred365):**
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -f /root/.ssh/Jayred365-rsync-key -C "jayred365-rsync" -N ""
|
||||
Copy Public Keys to Each Server
|
||||
```
|
||||
|
||||
Primary → Secondary
|
||||
### 2b — Copy Public Keys to Each Server
|
||||
|
||||
# On primary
|
||||
The primary's public key must be authorised on the secondary, and vice versa.
|
||||
|
||||
**Copy primary key → secondary:**
|
||||
|
||||
```bash
|
||||
# Run on primary
|
||||
cat /root/.ssh/Gmer4Lfe-rsync-key.pub
|
||||
```
|
||||
|
||||
Copy the output. Then on secondary:
|
||||
Copy the output. Then on the secondary:
|
||||
|
||||
```bash
|
||||
# Run on secondary
|
||||
mkdir -p /root/.ssh
|
||||
echo "PASTE_PUBLIC_KEY_HERE" >> /root/.ssh/authorized_keys
|
||||
chmod 600 /root/.ssh/authorized_keys
|
||||
```
|
||||
|
||||
Secondary → Primary
|
||||
**Copy secondary key → primary:**
|
||||
|
||||
# On secondary
|
||||
```bash
|
||||
# Run on secondary
|
||||
cat /root/.ssh/Jayred365-rsync-key.pub
|
||||
```
|
||||
|
||||
Copy the output. Then on primary:
|
||||
Copy the output. Then on the primary:
|
||||
|
||||
```bash
|
||||
# Run on primary
|
||||
mkdir -p /root/.ssh
|
||||
echo "PASTE_PUBLIC_KEY_HERE" >> /root/.ssh/authorized_keys
|
||||
chmod 600 /root/.ssh/authorized_keys
|
||||
Test the Connection
|
||||
```
|
||||
|
||||
From the primary, test SSH access to the secondary:
|
||||
### 2c — Test the Connection
|
||||
|
||||
From the primary, test that it can SSH to the secondary without a password prompt:
|
||||
|
||||
```bash
|
||||
ssh -i /root/.ssh/Gmer4Lfe-rsync-key root@$(tailscale ip -4 unRAID-Jayred365) "echo connected"
|
||||
```
|
||||
|
||||
You should see connected. If prompted for a password, recheck the key authorization.
|
||||
You should see `connected`. If prompted for a password the key was not authorised correctly — recheck Step 2b.
|
||||
|
||||
Step 3 — Enable SSH on unRAID
|
||||
### 2d — Gitea SSH Key
|
||||
|
||||
unRAID 7.x has SSH disabled by default. Enable it on both servers so the scripts can connect:
|
||||
Generate a separate key for Gitea access on each server:
|
||||
|
||||
Go to Settings → Management Access
|
||||
Under Secure Shell, set SSH to Enabled
|
||||
Set SSH port to 22 (default)
|
||||
Click Apply
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -f /root/.ssh/id_gitea_rsync -C "unraid-gitea" -N ""
|
||||
```
|
||||
|
||||
Security note: SSH is only exposed on your local network and Tailscale interface. The rsync scripts connect via Tailscale IP, so traffic is encrypted end-to-end.
|
||||
Add the public key to your Gitea account:
|
||||
|
||||
Step 4 — Configure Master.conf
|
||||
```bash
|
||||
cat /root/.ssh/id_gitea_rsync.pub
|
||||
```
|
||||
|
||||
All user configuration lives in Master.conf. Open it and adjust the following to match your setup:
|
||||
Copy the output and add it in Gitea under **Settings → SSH / GPG Keys → Add Key**.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Enable SSH on unRAID
|
||||
|
||||
unRAID 7.x has SSH disabled by default. Enable it on both servers so the scripts can connect between them.
|
||||
|
||||
1. Go to **Settings → Management Access**
|
||||
2. Under **Secure Shell** set **SSH** to `Enabled`
|
||||
3. Set **SSH port** to `22` (default)
|
||||
4. Click **Apply**
|
||||
|
||||
> **Security note:** SSH is only exposed on your local network and Tailscale interface. The rsync scripts connect via the Tailscale IP so traffic is encrypted end-to-end.
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Clone the Git Repository
|
||||
|
||||
The scripts live in a Gitea repository. Both servers clone from the same repo so updates propagate everywhere via a single git pull.
|
||||
|
||||
### On Both Servers
|
||||
|
||||
```bash
|
||||
# Create the target directory
|
||||
mkdir -p /mnt/user/appdata/unraid_scripts
|
||||
|
||||
# Clone the repository
|
||||
GIT_SSH_COMMAND="ssh -i /root/.ssh/id_gitea_rsync" \
|
||||
git clone git@YOUR_GITEA_HOST:YOUR_USER/Unraid_Scripts.git \
|
||||
/mnt/user/appdata/unraid_scripts
|
||||
```
|
||||
|
||||
Replace `YOUR_GITEA_HOST` and `YOUR_USER` with your Gitea server address and username.
|
||||
|
||||
### Verify the Structure
|
||||
|
||||
```bash
|
||||
ls /mnt/user/appdata/unraid_scripts
|
||||
```
|
||||
|
||||
You should see:
|
||||
|
||||
```
|
||||
Master.conf
|
||||
common.sh
|
||||
Rsync/
|
||||
rsync.sh
|
||||
Orchestrators/
|
||||
daily_sync.sh
|
||||
Tools/
|
||||
recreate_shares.sh
|
||||
```
|
||||
|
||||
### Make Scripts Executable
|
||||
|
||||
```bash
|
||||
chmod +x /mnt/user/appdata/unraid_scripts/Rsync/rsync.sh
|
||||
chmod +x /mnt/user/appdata/unraid_scripts/Orchestrators/daily_sync.sh
|
||||
chmod +x /mnt/user/appdata/unraid_scripts/Tools/recreate_shares.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Configure Master.conf
|
||||
|
||||
All user configuration lives in `Master.conf`. Open it and adjust the following to match your setup:
|
||||
|
||||
```bash
|
||||
nano /mnt/user/appdata/unraid_scripts/Master.conf
|
||||
```
|
||||
|
||||
Required Changes:
|
||||
### Required Changes
|
||||
|
||||
Variable Description Example
|
||||
HOST1 Hostname of your primary server unRAID-Gmer4Lfe
|
||||
HOST2 Hostname of your secondary server unRAID-Jayred365
|
||||
HOST1_SSH_KEY Path to primary's rsync private key /root/.ssh/Gmer4Lfe-rsync-key
|
||||
HOST2_SSH_KEY Path to secondary's rsync private key /root/.ssh/Jayred365-rsync-key
|
||||
BW_LIMIT Global bandwidth limit in KB/s 12500
|
||||
ROOTFS_WARN Remote rootfs % threshold before aborting 75
|
||||
| Variable | Description | Example |
|
||||
|---|---|---|
|
||||
| `HOST1` | Hostname of your primary server | `unRAID-Gmer4Lfe` |
|
||||
| `HOST2` | Hostname of your secondary server | `unRAID-Jayred365` |
|
||||
| `HOST1_SSH_KEY` | Path to primary's rsync private key | `/root/.ssh/Gmer4Lfe-rsync-key` |
|
||||
| `HOST2_SSH_KEY` | Path to secondary's rsync private key | `/root/.ssh/Jayred365-rsync-key` |
|
||||
| `REPO_SSH` | SSH URL of your Gitea repository | `git@192.168.50.2:User/Unraid_Scripts.git` |
|
||||
| `GITEA_SSH_KEY` | Path to Gitea private key | `/root/.ssh/id_gitea_rsync` |
|
||||
| `BW_LIMIT` | Global bandwidth limit in KB/s | `12500` |
|
||||
| `ROOTFS_WARN` | Remote rootfs % threshold before aborting | `75` |
|
||||
|
||||
Daily Sync Shares
|
||||
### Daily Sync Shares
|
||||
|
||||
Add the full paths of all media shares you want synced nightly:
|
||||
|
||||
```bash
|
||||
DAILY_SYNC_SHARES=(
|
||||
/mnt/user/Movies
|
||||
/mnt/user/Tv_Shows
|
||||
/mnt/user/Music
|
||||
# add more here
|
||||
)
|
||||
```
|
||||
|
||||
Profiles
|
||||
### Profiles
|
||||
|
||||
Profiles control per-share rsync behaviour for your frequently synced appdata shares.
|
||||
Profiles control per-share rsync behaviour for your frequently synced appdata shares. Each profile is matched by the directory basename (lowercased) — or overridden with `--profile=name`.
|
||||
|
||||
```bash
|
||||
# One array drives both local and remote container stops
|
||||
# Local stops first (flush databases) then remote stops (clean receive)
|
||||
# Same container names on both HOST1 and HOST2 — no duplication needed
|
||||
# Containers not found on a server are skipped gracefully
|
||||
declare -A PROFILE_CRITICAL_CONTAINER_NAMES=(
|
||||
[arrs_stack]="Sonarr Radarr Lidarr Prowlarr"
|
||||
[critical-data]="Mariadb-Authelia Mariadb-Authelia-Secondary Redis-Authelia Redis-Authelia-Secondary Lldap-Gmer4Lfe NginxProxyManager Authelia Authelia-Secondary"
|
||||
[important-data]="Postgres-NextCloud NextCloud"
|
||||
[emby]="Emby" # nightly clean sync — Emby stopped both sides
|
||||
[emby-failover]="" # dirty sync — Emby stays running
|
||||
)
|
||||
```
|
||||
|
||||
Any share with no matching profile falls through to the global DEFAULT_RSYNC_OPTS.
|
||||
Any share with no matching profile falls through to the global `DEFAULT_RSYNC_OPTS`. Containers not found on a server are skipped gracefully — only containers that were actually running get restarted.
|
||||
|
||||
Step 5 — Set Up User Scripts
|
||||
**Two Emby profiles:**
|
||||
|
||||
The User Scripts plugin schedules and runs the scripts. Each sync job is its own script entry.
|
||||
```
|
||||
emby-failover — frequent dirty sync (every 30-60min):
|
||||
Emby stays running on both sides
|
||||
WAL and SHM excluded — safe while Emby is active
|
||||
Only critical failover data: users.db, library.db, authentication.db, config/
|
||||
Fast, small dataset, high bandwidth
|
||||
This is also what gets written back during failover handback
|
||||
|
||||
Frequent Sync Jobs
|
||||
emby — nightly clean sync (via nightly_sync.sh at 2:30am):
|
||||
Emby stopped on both sides — WAL checkpointed on shutdown
|
||||
Full mirror: metadata, plugins, config all included
|
||||
Minimal excludes: logs, transcodes, cache, crash files only
|
||||
Complete faithful state pushed once per night
|
||||
```
|
||||
|
||||
Create one script entry per appdata profile (Plugins → User Scripts → Add New Script)
|
||||
Example for ARR stack:
|
||||
Usage with profile override:
|
||||
```bash
|
||||
# Dirty sync — Emby stays running
|
||||
bash rsync.sh /mnt/user/appdata-Failover/Emby --profile=emby-failover
|
||||
|
||||
# Clean sync — called by nightly_sync.sh, Emby stopped via profile
|
||||
bash rsync.sh /mnt/user/appdata-Failover/Emby
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Set Up User Scripts
|
||||
|
||||
The User Scripts plugin is how unRAID schedules and runs the scripts. Each sync job is its own script entry in the plugin.
|
||||
|
||||
### Frequent Sync Jobs (Scheduled Individually)
|
||||
|
||||
Create one script entry per appdata profile. Go to **Plugins → User Scripts → Add New Script**.
|
||||
|
||||
Name it descriptively — e.g. `rsync appdata arrs_stack`.
|
||||
|
||||
In the script body paste:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
bash /mnt/user/appdata/unraid_scripts/Rsync/rsync.sh /mnt/user/appdata-Failover/Arrs_Stack
|
||||
```
|
||||
|
||||
Daily Sync Orchestrator
|
||||
Set the schedule to match your desired frequency:
|
||||
|
||||
| Profile | Schedule | Notes |
|
||||
|---|---|---|
|
||||
| `emby-failover` | Every 30-60 min | Dirty sync — Emby running, critical data only |
|
||||
| `emby` | via `nightly_sync.sh` | Clean sync — Emby stopped, full mirror |
|
||||
| `Critical-Data` | via `nightly_sync.sh` | Auth stack — clean nightly sync |
|
||||
| `Important-Data` | Every 6-12 hours | NextCloud file changes |
|
||||
| `Arrs_Stack` | Every 12-24 hours | Arr databases |
|
||||
| `Gmer4Lfe` | Daily or weekly | Personal appdata, rarely changes |
|
||||
|
||||
> **Important:** Set each script to run as a **Background Task** — this ensures output streams correctly to the log rather than buffering in the browser.
|
||||
|
||||
### Daily Sync Orchestrator
|
||||
|
||||
Create one more script entry for the daily media sync:
|
||||
|
||||
Name it `daily media sync`.
|
||||
|
||||
In the script body paste:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
bash /mnt/user/appdata/unraid_scripts/Orchestrators/daily_sync.sh
|
||||
Schedule daily at 01:00
|
||||
Set scripts to run as Background Tasks
|
||||
Step 6 — Verify the Setup
|
||||
```
|
||||
|
||||
Manually test from the primary:
|
||||
Set the schedule to **Daily at 01:00**.
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Verify the Setup
|
||||
|
||||
Before letting the scheduled jobs run, do a manual test from the terminal on the primary:
|
||||
|
||||
```bash
|
||||
bash /mnt/user/appdata/unraid_scripts/Rsync/rsync.sh /mnt/user/appdata-Failover/Arrs_Stack --log
|
||||
```
|
||||
|
||||
You should see a healthy run with:
|
||||
A healthy run will show:
|
||||
|
||||
```
|
||||
━━━ ⚙️ Setup ━━━
|
||||
ℹ️ [INFO] 🖥️ Host: unRAID-Gmer4Lfe → unRAID-Jayred365
|
||||
ℹ️ [INFO] 🌐 Remote IP: 100.x.x.x
|
||||
...
|
||||
|
||||
━━━ 🛡️ Pre-flight Checks ━━━
|
||||
ℹ️ [INFO] 📡 unRAID-Jayred365 is reachable
|
||||
ℹ️ [INFO] 🩺 Remote rootfs: 12% used (threshold: 75%)
|
||||
ℹ️ [INFO] 🩺 Remote share verified: ...
|
||||
ℹ️ [INFO] 💾 disk1 🟢 — share present
|
||||
✅ [OK] All disks backing share are online
|
||||
Step 7 — Secondary Server Initial Setup
|
||||
```
|
||||
|
||||
If setting up secondary from scratch:
|
||||
If any pre-flight check fails the script will abort with a clear error and hint before touching anything.
|
||||
|
||||
Complete Steps 1–5 on the secondary
|
||||
Start the array and create your shares in the unRAID UI
|
||||
Run the share recreation tool:
|
||||
---
|
||||
|
||||
## Step 8 — Secondary Server Initial Setup
|
||||
|
||||
If setting up the secondary from scratch (no existing data):
|
||||
|
||||
1. Complete Steps 1–5 on the secondary
|
||||
2. Start the array and create your shares in the unRAID UI
|
||||
3. Run the share recreation tool to create disk directories from your cfg files:
|
||||
|
||||
```bash
|
||||
bash /mnt/user/appdata/unraid_scripts/Tools/recreate_shares.sh
|
||||
Temporarily remove --delete from DEFAULT_RSYNC_OPTS for initial push
|
||||
Restore --delete after the first run; next nightly run will clean .recovery files automatically
|
||||
Troubleshooting
|
||||
```
|
||||
|
||||
SSH connection refused
|
||||
4. Temporarily remove `--delete` from `DEFAULT_RSYNC_OPTS` in `Master.conf`
|
||||
5. Run the initial push from the primary — the `.recovery` marker files allow rsync to populate empty shares without aborting
|
||||
6. Once complete, restore `--delete` to `Master.conf`
|
||||
7. The next nightly run will clean up the `.recovery` marker files automatically
|
||||
|
||||
Verify SSH is enabled on target server (Step 3)
|
||||
Check correct key referenced in Master.conf
|
||||
Confirm Tailscale IP resolves
|
||||
---
|
||||
|
||||
Pre-flight aborts
|
||||
## Troubleshooting
|
||||
|
||||
Rootfs above ROOTFS_WARN
|
||||
Share exists but empty
|
||||
Disk offline
|
||||
### SSH connection refused
|
||||
- Verify SSH is enabled on the target server (Step 3)
|
||||
- Check the correct key is referenced in `Master.conf`
|
||||
- Confirm the Tailscale IP resolves: `tailscale ip -4 HOSTNAME`
|
||||
|
||||
Script not found
|
||||
### Pre-flight aborts on rootfs
|
||||
- Remote rootfs is above `ROOTFS_WARN` threshold
|
||||
- Check if the remote array is started and drives are mounted
|
||||
- `df /` on the remote to see current usage
|
||||
|
||||
Verify scripts exist in /mnt/user/appdata/unraid_scripts/
|
||||
Make scripts executable:
|
||||
chmod +x /mnt/user/appdata/unraid_scripts/Rsync/rsync.sh
|
||||
chmod +x /mnt/user/appdata/unraid_scripts/Orchestrators/daily_sync.sh
|
||||
chmod +x /mnt/user/appdata/unraid_scripts/Tools/recreate_shares.sh
|
||||
Available Flags
|
||||
Flag Description
|
||||
--dry-run or -n Run without making changes
|
||||
--log Enable verbose logging
|
||||
--no-log Disable logging
|
||||
--status Print resolved configuration and exit
|
||||
### Pre-flight aborts on empty share
|
||||
- Share exists but has no content — drives may not be mounted
|
||||
- Run `recreate_shares.sh` if setting up fresh
|
||||
- Check array status on the remote server
|
||||
|
||||
Examples
|
||||
### Pre-flight aborts on disk check
|
||||
- One or more disks backing the share are not mounted
|
||||
- Check **Main → Array Devices** on the remote for offline disks
|
||||
- Verify disk assignments are correct after any hardware changes
|
||||
|
||||
# Preview what would be synced without transferring
|
||||
### Script not found
|
||||
- Verify the repo was cloned to `/mnt/user/appdata/unraid_scripts/`
|
||||
- Check scripts are executable: `chmod +x /mnt/user/appdata/unraid_scripts/Rsync/rsync.sh`
|
||||
|
||||
### Containers not stopping/starting
|
||||
- Verify container names in `Master.conf` match exactly what Docker shows
|
||||
- Check SSH key has access to run docker commands on the remote
|
||||
- Test manually: `ssh -i /root/.ssh/KEY root@REMOTE_IP "docker ps"`
|
||||
|
||||
---
|
||||
|
||||
## Available Flags
|
||||
|
||||
All scripts support the following flags:
|
||||
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
| `--dry-run` or `-n` | Run without making any changes |
|
||||
| `--log` | Enable verbose logging output |
|
||||
| `--no-log` | Disable logging (overrides Master.conf) |
|
||||
| `--status` | Print resolved configuration and exit |
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
# Preview what would be synced without transferring anything
|
||||
bash rsync.sh /mnt/user/Movies --dry-run --log
|
||||
|
||||
# Check profile and settings resolved for a share
|
||||
# Check what profile and settings resolved for a share
|
||||
bash rsync.sh /mnt/user/appdata-Failover/Arrs_Stack --status
|
||||
Repository Structure
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
Unraid_Scripts/
|
||||
├── Master.conf # All user configuration — edit this file only
|
||||
├── common.sh # Shared library — functions used by all scripts
|
||||
@@ -218,4 +441,9 @@ Unraid_Scripts/
|
||||
├── Orchestrators/
|
||||
│ └── daily_sync.sh # Daily media sync orchestrator
|
||||
└── Tools/
|
||||
└── recreate_shares.sh # Share directory recreation from cfg fil
|
||||
└── recreate_shares.sh # Share directory recreation from cfg files
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Guide version aligned with common.sh v1.6*
|
||||
+26
-6
@@ -23,12 +23,15 @@ source "$SCRIPT_DIR/../common.sh"
|
||||
|
||||
# -----------------------------------------------------------------------------------------------
|
||||
# Separate the positional directory argument from flag/key=value args.
|
||||
# Optional --profile=name overrides the basename profile inference.
|
||||
# -----------------------------------------------------------------------------------------------
|
||||
DIRECTORY=""
|
||||
PROFILE_OVERRIDE=""
|
||||
RAW_ARGS=()
|
||||
|
||||
for ARG in "$@"; do
|
||||
case "$ARG" in
|
||||
--profile=*) PROFILE_OVERRIDE="${ARG#--profile=}" ;;
|
||||
--*|*=*) RAW_ARGS+=("$ARG") ;;
|
||||
*) [[ -z "$DIRECTORY" ]] && DIRECTORY="$ARG" || RAW_ARGS+=("$ARG") ;;
|
||||
esac
|
||||
@@ -36,7 +39,7 @@ done
|
||||
|
||||
parse_args "${RAW_ARGS[@]}"
|
||||
|
||||
[[ -z "$DIRECTORY" ]] && error "No directory specified. Usage: rsync.sh <dir> [--dry-run] [--log]" && exit 1
|
||||
[[ -z "$DIRECTORY" ]] && error "No directory specified. Usage: rsync.sh <dir> [--dry-run] [--log] [--profile=name]" && exit 1
|
||||
|
||||
# -----------------------------------------------------------------------------------------------
|
||||
# ━━━ $ICON_GEAR Setup ━━━
|
||||
@@ -49,10 +52,16 @@ resolve_remote_ip
|
||||
|
||||
# -----------------------------------------------------------------------------------------------
|
||||
# Profile inference — basename of directory lowercased
|
||||
# Optional --profile=name overrides basename inference
|
||||
# -----------------------------------------------------------------------------------------------
|
||||
echo ""
|
||||
PROFILE_NAME=$(basename "$DIRECTORY" | tr '[:upper:]' '[:lower:]')
|
||||
info "$ICON_GEAR Loading profile: $PROFILE_NAME"
|
||||
if [[ -n "$PROFILE_OVERRIDE" ]]; then
|
||||
PROFILE_NAME="$PROFILE_OVERRIDE"
|
||||
info "$ICON_GEAR Profile override: $PROFILE_NAME"
|
||||
else
|
||||
PROFILE_NAME=$(basename "$DIRECTORY" | tr '[:upper:]' '[:lower:]')
|
||||
info "$ICON_GEAR Loading profile: $PROFILE_NAME"
|
||||
fi
|
||||
|
||||
# Acquire per-profile lock and check global concurrent limit
|
||||
acquire_rsync_lock "$PROFILE_NAME"
|
||||
@@ -64,9 +73,12 @@ SLEEP=${PROFILE_SLEEP[$PROFILE_NAME]:-$SLEEP}
|
||||
CONTAINER_DELAY=${PROFILE_CONTAINER_DELAY[$PROFILE_NAME]:-$CONTAINER_DELAY}
|
||||
|
||||
# Array overrides
|
||||
read -r -a CRITICAL_CONTAINER_NAMES <<< "${PROFILE_CRITICAL_CONTAINER_NAMES[$PROFILE_NAME]:-}"
|
||||
read -r -a DELAYED_CONTAINERS <<< "${PROFILE_DELAYED_CONTAINERS[$PROFILE_NAME]:-}"
|
||||
read -r -a EXCLUDE_DIRS <<< "${PROFILE_EXCLUDE_DIRS[$PROFILE_NAME]:-}"
|
||||
read -r -a CRITICAL_CONTAINER_NAMES <<< "${PROFILE_CRITICAL_CONTAINER_NAMES[$PROFILE_NAME]:-}"
|
||||
read -r -a DELAYED_CONTAINERS <<< "${PROFILE_DELAYED_CONTAINERS[$PROFILE_NAME]:-}"
|
||||
read -r -a EXCLUDE_DIRS <<< "${PROFILE_EXCLUDE_DIRS[$PROFILE_NAME]:-}"
|
||||
|
||||
# Local and remote use the same container list — same naming scheme on both servers
|
||||
LOCAL_CRITICAL_CONTAINER_NAMES=("${CRITICAL_CONTAINER_NAMES[@]}")
|
||||
|
||||
# Disk check toggle
|
||||
SKIP_DISK_CHECK=${PROFILE_SKIP_DISK_CHECK[$PROFILE_NAME]:-false}
|
||||
@@ -95,6 +107,10 @@ fi
|
||||
echo ""
|
||||
echo "━━━ $ICON_STOP $ICON_CONTAINERS Containers ━━━"
|
||||
|
||||
# Stop local containers first — flush local databases before pushing
|
||||
stop_local_containers
|
||||
|
||||
# Stop remote containers — prevent writes while receiving
|
||||
stop_containers
|
||||
|
||||
# -----------------------------------------------------------------------------------------------
|
||||
@@ -140,8 +156,12 @@ done
|
||||
echo ""
|
||||
echo "━━━ $ICON_START $ICON_CONTAINERS Containers ━━━"
|
||||
|
||||
# Start remote containers first — they can be coming up while local restarts
|
||||
start_containers
|
||||
|
||||
# Start local containers
|
||||
start_local_containers
|
||||
|
||||
END=$(date +%s)
|
||||
DURATION=$((END - START))
|
||||
|
||||
|
||||
@@ -519,15 +519,15 @@ RUNNING_CONTAINERS=()
|
||||
stop_containers() {
|
||||
if [[ ${#CRITICAL_CONTAINER_NAMES[@]} -eq 0 ]] || \
|
||||
[[ "${CRITICAL_CONTAINER_NAMES[*]}" == "" ]]; then
|
||||
log "No containers configured for this profile, skipping stop."
|
||||
log "No remote containers configured for this profile, skipping stop."
|
||||
return
|
||||
fi
|
||||
info "Stopping containers..."
|
||||
info "Stopping remote containers..."
|
||||
RUNNING_CONTAINERS=()
|
||||
for c in "${CRITICAL_CONTAINER_NAMES[@]}"; do
|
||||
[[ -z "$c" ]] && continue
|
||||
STATUS=$(ssh -i "$SSH_KEY" root@"$REMOTE_SERVER" \
|
||||
"docker inspect -f '{{.State.Running}}' $c 2>/dev/null" 2>/dev/null || echo "false")
|
||||
"docker inspect -f '{{.State.Running}}' $c 2>/dev/null" 2>/dev/null || echo "unknown")
|
||||
if [[ "$STATUS" == "true" ]]; then
|
||||
echo "$ICON_STOP Stopping $c..."
|
||||
RUNNING_CONTAINERS+=("$c")
|
||||
@@ -536,8 +536,44 @@ stop_containers() {
|
||||
else
|
||||
error "Failed to stop $c"
|
||||
fi
|
||||
else
|
||||
elif [[ "$STATUS" == "false" ]]; then
|
||||
echo "$ICON_NOT_RUNNING $c is not running, skipping"
|
||||
else
|
||||
log "$c not found on remote — skipping"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------------------------
|
||||
# CONTAINER MANAGEMENT — STOP LOCAL
|
||||
# Stops containers on the LOCAL server before rsync pushes data out.
|
||||
# Uses PROFILE_LOCAL_CRITICAL_CONTAINER_NAMES — same naming scheme as remote.
|
||||
# If container not found on this server → skipped gracefully, not errored.
|
||||
# Only containers that were running get tracked for restart.
|
||||
# -----------------------------------------------------------------------------------------------
|
||||
stop_local_containers() {
|
||||
if [[ ${#LOCAL_CRITICAL_CONTAINER_NAMES[@]} -eq 0 ]] || \
|
||||
[[ "${LOCAL_CRITICAL_CONTAINER_NAMES[*]}" == "" ]]; then
|
||||
log "No local containers configured for this profile, skipping local stop."
|
||||
return
|
||||
fi
|
||||
info "Stopping local containers..."
|
||||
LOCAL_RUNNING_CONTAINERS=()
|
||||
for c in "${LOCAL_CRITICAL_CONTAINER_NAMES[@]}"; do
|
||||
[[ -z "$c" ]] && continue
|
||||
STATUS=$(docker inspect -f '{{.State.Running}}' "$c" 2>/dev/null || echo "unknown")
|
||||
if [[ "$STATUS" == "true" ]]; then
|
||||
echo "$ICON_STOP Stopping local $c..."
|
||||
LOCAL_RUNNING_CONTAINERS+=("$c")
|
||||
if docker stop "$c" >/dev/null; then
|
||||
echo "$ICON_STOPPED $c stopped"
|
||||
else
|
||||
error "Failed to stop local $c"
|
||||
fi
|
||||
elif [[ "$STATUS" == "false" ]]; then
|
||||
echo "$ICON_NOT_RUNNING $c is not running, skipping"
|
||||
else
|
||||
log "$c not found locally — skipping"
|
||||
fi
|
||||
done
|
||||
}
|
||||
@@ -549,10 +585,10 @@ stop_containers() {
|
||||
# -----------------------------------------------------------------------------------------------
|
||||
start_containers() {
|
||||
if [[ ${#RUNNING_CONTAINERS[@]} -eq 0 ]]; then
|
||||
log "No containers to restart."
|
||||
log "No remote containers to restart."
|
||||
return
|
||||
fi
|
||||
info "Starting containers..."
|
||||
info "Starting remote containers..."
|
||||
for c in "${RUNNING_CONTAINERS[@]}"; do
|
||||
[[ -z "$c" ]] && continue
|
||||
local needs_delay=false
|
||||
@@ -564,10 +600,41 @@ start_containers() {
|
||||
sleep "$CONTAINER_DELAY"
|
||||
fi
|
||||
echo "$ICON_START Starting $c..."
|
||||
if ssh -i "$SSH_KEY" root@"$REMOTE_SERVER" "docker start $c" >/dev/null; then
|
||||
if ssh -i "$SSH_KEY" root@"$REMOTE_SERVER" "docker start $c" >/dev/null 2>&1; then
|
||||
echo "$ICON_STARTED $c started"
|
||||
else
|
||||
error "Failed to start $c"
|
||||
error "Failed to start $c — start manually if needed"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------------------------
|
||||
# CONTAINER MANAGEMENT — START LOCAL
|
||||
# Restarts only containers tracked in LOCAL_RUNNING_CONTAINERS.
|
||||
# Respects DELAYED_CONTAINERS and CONTAINER_DELAY same as remote start.
|
||||
# If container not found → skipped gracefully.
|
||||
# -----------------------------------------------------------------------------------------------
|
||||
start_local_containers() {
|
||||
if [[ ${#LOCAL_RUNNING_CONTAINERS[@]} -eq 0 ]]; then
|
||||
log "No local containers to restart."
|
||||
return
|
||||
fi
|
||||
info "Starting local containers..."
|
||||
for c in "${LOCAL_RUNNING_CONTAINERS[@]}"; do
|
||||
[[ -z "$c" ]] && continue
|
||||
local needs_delay=false
|
||||
for d in "${DELAYED_CONTAINERS[@]}"; do
|
||||
[[ "$c" == "$d" ]] && needs_delay=true && break
|
||||
done
|
||||
if [[ "$needs_delay" == true ]]; then
|
||||
info "Waiting ${CONTAINER_DELAY}s before starting local $c..."
|
||||
sleep "$CONTAINER_DELAY"
|
||||
fi
|
||||
echo "$ICON_START Starting local $c..."
|
||||
if docker start "$c" >/dev/null 2>&1; then
|
||||
echo "$ICON_STARTED $c started"
|
||||
else
|
||||
error "Failed to start local $c — start manually if needed"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
+20
-10
@@ -66,6 +66,7 @@
|
||||
# │
|
||||
# ├── Orchestrators/
|
||||
# │ ├── daily_sync.sh # Runs all daily media share syncs sequentially
|
||||
# │ ├── nightly_critical_full_sync.sh # Clean nightly sync — Emby + auth stack stopped
|
||||
# │ ├── media_management.sh # Runs permissions + cleaners + arr cleanup
|
||||
# │ ├── transcode_management.sh # Runs cleanup then manager every 3min + daily stats
|
||||
# │ └── README-Orchestrators.md
|
||||
@@ -163,6 +164,7 @@
|
||||
# Individual scripts below are still accessible for manual runs or testing.
|
||||
#
|
||||
#/mnt/user/appdata/unraid_scripts/Orchestrators/daily_sync.sh
|
||||
#/mnt/user/appdata/unraid_scripts/Orchestrators/nightly_critical_full_sync.sh
|
||||
#/mnt/user/appdata/unraid_scripts/Orchestrators/media_management.sh
|
||||
#/mnt/user/appdata/unraid_scripts/Orchestrators/transcode_management.sh
|
||||
#
|
||||
@@ -170,7 +172,7 @@
|
||||
#/mnt/user/appdata/unraid_scripts/Rsync/rsync.sh /mnt/user/appdata-Failover/Arrs_Stack
|
||||
#/mnt/user/appdata/unraid_scripts/Rsync/rsync.sh /mnt/user/appdata-Failover/Critical-Data
|
||||
#/mnt/user/appdata/unraid_scripts/Rsync/rsync.sh /mnt/user/appdata-Failover/Important-Data
|
||||
#/mnt/user/appdata/unraid_scripts/Rsync/rsync.sh /mnt/user/appdata-Failover/Emby
|
||||
#/mnt/user/appdata/unraid_scripts/Rsync/rsync.sh /mnt/user/appdata-Failover/Emby --profile=emby-failover
|
||||
#/mnt/user/appdata/unraid_scripts/Rsync/rsync.sh /mnt/user/appdata-Failover/Gmer4Lfe
|
||||
#
|
||||
# ━━━ Rsync — Individual Media Shares (ad hoc) ━━━
|
||||
@@ -285,31 +287,39 @@
|
||||
# php_fpm_max_children.sh — WebGUI performance tuning
|
||||
#
|
||||
# ━━━ Frequent (cron) ━━━
|
||||
# */3 * * * * transcode_management.sh (replaces separate manager + cleanup entries)
|
||||
# */3 * * * * transcode_management.sh (cleanup + manager every 3min)
|
||||
# */30 * * * * rsync.sh /mnt/user/appdata-Failover/Emby (emby-failover dirty sync)
|
||||
# --profile=emby-failover
|
||||
# */10 * * * * webgui_restart.sh
|
||||
# */15 * * * * docker_watchdog.sh
|
||||
# */15 * * * * system_watchdog.sh
|
||||
#
|
||||
# ━━━ Daily ━━━
|
||||
# 0 1 * * * daily_sync.sh
|
||||
# 0 2 * * * media_management.sh
|
||||
# 0 3 * * * docker_daily_restart.sh
|
||||
# 0 8 * * * weekly_health_digest.sh (profile controls if it sends)
|
||||
# 0 1 * * * daily_sync.sh (media shares both directions)
|
||||
# 0 2 * * * media_management.sh (permissions + cleaners + arrs)
|
||||
# 30 2 * * * nightly_critical_full_sync.sh (clean Emby + auth stack sync)
|
||||
# 0 3 * * * docker_daily_restart.sh
|
||||
# 0 8 * * * weekly_health_digest.sh (profile controls if it sends)
|
||||
#
|
||||
# ━━━ Rsync profiles — schedule individually ━━━
|
||||
# Arrs_Stack — daily or every few days (arr databases change on every download)
|
||||
# Critical-Data — handled by nightly_critical_full_sync.sh — no separate schedule needed
|
||||
# Important-Data — daily (NextCloud file changes)
|
||||
# Gmer4Lfe — daily or weekly (personal appdata, rarely changes)
|
||||
# Emby — handled by nightly_critical_full_sync.sh — no separate schedule needed
|
||||
# emby-failover — every 30-60min via frequent cron above
|
||||
#
|
||||
# ━━━ Weekly — Sunday morning block ━━━
|
||||
# 0 3 * * 0 docker_weekly_restart.sh
|
||||
# 0 5 * * 0 clear_logs.sh
|
||||
# 0 6 * * 0 zfs_memory_snapshot.sh
|
||||
# 0 7 * * 0 smart_health.sh
|
||||
# 0 8 * * 0 weekly_health_digest.sh (weekly profile sends today)
|
||||
# 0 8 * * 0 weekly_health_digest.sh (weekly profile sends today)
|
||||
# 0 9 * * 0 cert_monitor.sh
|
||||
# 0 10 * * 0 backup_verify.sh
|
||||
# 0 11 * * 0 emby_session_report.sh
|
||||
# 0 11 * * 0 bandwidth_monitor.sh --report
|
||||
#
|
||||
# ━━━ Rsync profiles ━━━
|
||||
# Schedule individually based on how frequently appdata needs syncing
|
||||
#
|
||||
# ━━━ Bandwidth monitor ━━━
|
||||
# bandwidth_monitor.sh --log-transfer called automatically by rsync.sh
|
||||
# bandwidth_monitor.sh --report runs standalone for weekly summary
|
||||
|
||||
Reference in New Issue
Block a user