The fallback docs used an older COVERS naming in the wrong conf file, and rsync's merge-run mode — the only path that carries --delete — was undocumented entirely.
24 KiB
━━━━━ RSYNC — Manual ━━━━━
Config reference, procedures, operational workflows. For overview see README-Rsync.md. For per-script detail see the rsync.sh header.
This document also serves as the initial setup guide for standing up the two-server ecosystem from scratch.
━━━ WHAT YOU'RE BUILDING ━━━
HOST1 (unRAID-Gmer4Lfe) HOST2 (unRAID-Jayred365)
────────────────────── ──────────────────────
Downloads to: Downloads to:
/mnt/user/Movies /mnt/user/Anime_Movies
/mnt/user/Tv_Shows /mnt/user/Anime_Shows
/mnt/user/Music
Both servers' arrs track ALL content across both servers.
It does not matter who downloaded what or when.
Daily 3-phase window (1am):
Phase 1 — arr_sync.sh (runs first):
All arrs on all nodes reconcile libraries using external IDs
(TMDB, TVDB, MusicBrainz). Union model — any node that tracks
an item, all nodes get it. After this phase both servers' arrs
know about all content regardless of who downloaded it.
Phase 2 — rsync (bidirectional, no --delete on media shares):
HOST1 pushes its shares ──────► HOST2 receives files
HOST1 receives files ◄────── HOST2 pushes its shares
Files arrive already tracked by the remote arr (arr_sync ran first).
No --delete — media files only spread. Arr cleanup handles deletions.
Phase 3 — arr cleanup:
sonarr_cleanup / radarr_cleanup / lidarr_cleanup query the live arr
API and remove any files no longer tracked. Emby notified after.
Note: profiled syncs (arrs_stack, emby, critical-data…) use their own
PROFILE_RSYNC_OPTS which include --delete. Only the no-profile media
shares use DEFAULT_RSYNC_OPTS (spread only).
Weekly clean sync (2:30am Sunday, containers 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 ━━━
Required on both servers before starting:
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 fallback
━━━ 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
1. Open Apps in the unRAID UI
2. Search "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
# Test actual connectivity:
tailscale ping unRAID-Jayred365
Critical: The hostnames in
master.conf(HOST1andHOST2) must match the Tailscale machine names exactly — case sensitive. All remote IP resolution goes throughtailscale ip -4 HOSTNAME. A name mismatch causes every remote operation to fail at the IP resolution step.
━━━ STEP 2 — ENABLE SSH ━━━
unRAID 7.x has SSH disabled by default. Enable it on both servers.
Settings → Management Access → Secure Shell
SSH: Enabled
SSH port: 22
Apply
SSH is only exposed on your local network and Tailscale interface. No ports are opened to the public internet.
━━━ STEP 3 — SSH KEYS ━━━
Two sets of keys needed: rsync automation keys (server-to-server) and a Gitea access key (for script repository pulls).
3a — Rsync Automation Keys (ssh_setup.sh)
Run on each server. ssh_setup.sh generates the key, copies it to the remote,
and updates host*.conf with the key path automatically.
# On HOST1 — after the repo is cloned:
bash /boot/config/plugins/varaverk/Partnership/ssh_setup.sh
# On HOST2:
bash /boot/config/plugins/varaverk/Partnership/ssh_setup.sh
Key naming convention: hostname lowercased, unraid- prefix stripped.
unRAID-Gmer4Lfe → /root/.ssh/gmer4lfe_rsync_automation
unRAID-Jayred365 → /root/.ssh/jayred365_rsync_automation
host*.conf is updated automatically with HOST*_SSH_KEY pointing to the
generated key. Run --status to verify:
bash /boot/config/plugins/varaverk/Partnership/ssh_setup.sh --status
ssh_setup.shis idempotent — safe to re-run. Use--forceto regenerate a key (e.g., after a security incident) and re-copy it to the remote.
3b — Gitea SSH Key (manual)
# On BOTH servers:
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 above
━━━ STEP 4 — CLONE THE REPOSITORY ━━━
# Create target directory:
mkdir -p /boot/config/plugins/varaverk
# Clone:
GIT_SSH_COMMAND="ssh -i /root/.ssh/unraid_gitea" \
git clone git@YOUR_GITEA_HOST:Varaverk/varaverk.git \
/boot/config/plugins/varaverk
# Make scripts executable:
find /boot/config/plugins/varaverk -name "*.sh" -exec chmod +x {} \;
Expected structure after clone:
master.conf ← all shared configuration
host1.conf ← HOST1-specific configuration
host2.conf ← HOST2-specific configuration
common.sh ← shared library
load_config.sh ← config loader
Orchestrators/
Rsync/
Fallback/
Docker_Essentials/
...
━━━ STEP 5 — CONFIGURE MASTER.CONF ━━━
nano /boot/config/plugins/varaverk/master.conf
Host Identity
# These must match Tailscale machine names exactly — case sensitive.
HOST1="unRAID-Gmer4Lfe" # REQUIRED
HOST2="unRAID-Jayred365" # REQUIRED
# SSH keys — each server uses its own key to authenticate to the other:
HOST1_SSH_KEY="/root/.ssh/Gmer4Lfe-rsync-key"
HOST2_SSH_KEY="/root/.ssh/Jayred365-rsync-key"
Git Repository
GITEA_CONTAINER="Gitea"
GITEA_REPO_PATH="Varaverk/varaverk.git"
TARGET_DIR="/boot/config/plugins/varaverk"
GITEA_SSH_KEY="/root/.ssh/unraid_gitea"
SSH_PORT=221
Daily Sync Shares
# host1.conf
# Shares this server downloads to — pushed to the other server nightly.
# arr_sync.sh runs before rsync in the daily window, so the receiving
# server's arrs already track incoming files when they arrive.
# Never put the same path in both lists.
HOST1_DAILY_SYNC_SHARES=(
"/mnt/user/Movies"
"/mnt/user/Tv_Shows"
"/mnt/user/Music"
"/mnt/user/Kids_Movies"
"/mnt/user/Kids_Tv_Shows"
"/mnt/user/Sports"
"/mnt/user/stand-up_comedy"
)
# host2.conf
HOST2_DAILY_SYNC_SHARES=(
"/mnt/user/Anime_Shows"
"/mnt/user/Anime_Movies"
)
Weekly Sync Shares
# master.conf
# Synced during the Sunday 2:30am window — containers stopped both sides.
WEEKLY_SYNC_SHARES=(
"/mnt/user/Media_Server/Emby"
"/mnt/user/appdata-Fallback/Critical-Data"
)
━━━ STEP 6 — MASTER_HOST*.CONF ━━━
detect_hosts() reads which server is running and aliases HOST*_ prefixed vars
to their unprefixed names. Scripts only ever reference the unprefixed name — they
work identically on both servers.
nano /boot/config/plugins/varaverk/host1.conf # on HOST1
nano /boot/config/plugins/varaverk/host2.conf # on HOST2
Every variable is documented in the conf files. Key values to set:
- SSH key paths
- DAILY_SYNC_SHARES
- EMBY_URL / EMBY_API_KEY
- CERT_MONITOR_DOMAINS
- SMART_IGNORE_DRIVES
- ZFS_REPORT_IGNORE_POOLS
━━━ RSYNC PROFILES ━━━
Profiles control per-share behavior. Profile key = directory basename lowercased.
Override with --profile=name.
Profile Matching
# rsync.sh /mnt/user/appdata-Fallback/Arrs_Stack
# basename: Arrs_Stack → lowercased: arrs_stack → matches [arrs_stack] profile
#
# rsync.sh /mnt/user/Movies
# basename: Movies → no profile match → global defaults (no containers stopped)
#
# rsync.sh /mnt/user/appdata-Fallback/Critical-Data --profile=critical-data
# explicit override
Current Profile Definitions
# master.conf
# ── 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, Authelia delayed restart
PROFILES["critical-data_CRITICAL_CONTAINER_NAMES"]=(
"Mariadb-Authelia" "Redis-Authelia"
"NginxProxyManager" "Lldap"
)
PROFILES["critical-data_DELAYED_CONTAINERS"]=(
"Authelia" "Authelia-Secondary"
)
PROFILES["critical-data_CONTAINER_DELAY"]=30
# ── 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/"
)
The emby Profile and Watch-State Sync
emby (Sunday 2:30am, both Emby instances stopped):
- Syncs: everything except transcodes, logs, cache, crash files
- Includes: metadata, plugins, full database state, all config
- Why: WAL is checkpointed on clean shutdown — safe to copy everything
- Result: HOST2 gets a gold-standard Emby state once per week
Between weekly mirrors, Emby watch/resume state is kept current by
Media/play_state_sync.sh, which reads and writes play state over the Emby API every
30 minutes (CRITICAL_MAINTENANCE_SCRIPTS) — so HOST2 never falls more than one cycle
behind on what's been watched. This replaced the old emby-fallback dirty-rsync profile:
copying Emby's live SQLite databases mid-write risked landing a half-written WAL file on
HOST2, and an API-level sync sidesteps the database-consistency problem entirely.
━━━ PERSONAL ENCRYPTED SHARES ━━━
Personal shares sync to the remote for offsite backup. ZFS encrypts at the dataset level — the remote 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
# 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 — more convenient, but the keyfile itself is a secret
dd if=/dev/urandom bs=32 count=1 | base64 > /root/.zfs-keys/personal.key
chmod 600 /root/.zfs-keys/personal.key
zfs change-key \
-o keylocation=file:///root/.zfs-keys/personal.key \
-o keyformat=raw \
poolname/Gmer4Lfe-Personal
# Add to array_started.sh or ramdisk_setup.sh:
zfs load-key poolname/Gmer4Lfe-Personal
zfs mount poolname/Gmer4Lfe-Personal
# Manual unlock alternative (most secure):
zfs load-key poolname/Gmer4Lfe-Personal # prompts for passphrase
zfs mount poolname/Gmer4Lfe-Personal
Add to host1.conf
HOST1_PERSONAL_SHARES=(
"/mnt/user/Gmer4Lfe-Personal"
)
━━━ STEP 7 — SCHEDULER SETUP ━━━
All scheduling is managed by the Varaverk plugin — no User Scripts entries needed.
Array start (Varaverk disks_mounted event hook)
The Varaverk plugin fires array_started.sh automatically via its disks_mounted event
hook. It's registered in schedule.json:
"Orchestrators/array_started.sh": { "cron": "array_start", "enabled": true }
Launches everything in ARRAY_START_SCRIPTS: api_key_renew, inotify_tuning, docker_syslog_filter, php_fpm_max_children, ramdisk_setup, docker_network_connect, fallback. Watchdogs run separately via watchdog_orchestrator.sh (cron — every 15 minutes).
Cron Schedule (Varaverk Scheduler)
*/7 * * * * transcode_management.sh
*/15 * * * * watchdog_orchestrator.sh
*/30 * * * * critical_sync_maintenance.sh
0 */4 * * * intermediate_sync_maintenance.sh
0 1 * * * daily_sync_maintenance.sh
0 7 * * 0 sunday_morning_coffee_report.sh
30 2 * * 0 weekly_sync_maintenance.sh
0 0 15 * * monthly_maintenance.sh
All entries are configured via the Varaverk scheduler UI or schedule.json.
Scripts live at /boot/config/plugins/varaverk/.
━━━ VERIFICATION ━━━
Test with --dry-run first — all pre-flight checks run, no changes made.
Test a Single Profile Sync
bash /boot/config/plugins/varaverk/Rsync/rsync.sh \
/mnt/user/appdata-Fallback/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 ━━━
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
bash /boot/config/plugins/varaverk/Orchestrators/daily_sync_maintenance.sh --dry-run
Check Configuration Resolution
bash /boot/config/plugins/varaverk/Rsync/rsync.sh \
/mnt/user/appdata-Fallback/Arrs_Stack --status
━━━ INITIAL HOST2 SYNC ━━━
If HOST2 is being set up from scratch with empty shares:
# Create share directories on HOST2:
bash /boot/config/plugins/varaverk/Tools/recreate_shares.sh
# Initial push from HOST1 — first run may take several hours for large libraries:
bash /boot/config/plugins/varaverk/Orchestrators/daily_sync_maintenance.sh --log
The scheduled nightly sync will be incremental after the initial push.
━━━ NAMING CONSISTENCY REQUIREMENT ━━━
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
/mnt/user/Tv_Shows ← both HOST1 and HOST2
If a container has a different name on one server: the script skips it silently. You only notice when the container is not stopped during a sync that requires it.
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.
━━━ 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
# Test SSH manually (key path from --status output):
ssh -i /root/.ssh/gmer4lfe_rsync_automation \
root@$(tailscale ip -4 unRAID-Jayred365) "hostname"
# If password prompted: key not authorised — re-run ssh_setup.sh
# Re-run setup (idempotent, re-copies key to remote):
bash /boot/config/plugins/varaverk/Partnership/ssh_setup.sh
Pre-flight Aborts on Remote Rootfs
# Check current usage on remote:
ssh -i /root/.ssh/Gmer4Lfe-rsync-key root@[HOST2-ip] "df /"
# Common cause: array not started, drives not mounted
Remote Share Missing
# 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, then run initial sync
Containers Not Stopping / Starting
# Verify container names match Docker exactly — case sensitive:
docker ps --format "{{.Names}}"
# Test remote:
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-Fallback/Arrs_Stack → key: arrs_stack
# Override explicitly:
bash rsync.sh /mnt/user/appdata-Fallback/My_Stuff --profile=arrs_stack
# Verify what profile resolved:
bash rsync.sh /mnt/user/appdata-Fallback/Arrs_Stack --status
━━━ FULL CONFIGURATION REFERENCE ━━━
master.conf
# Rsync engine
RSYNC_ENABLED=true
DEFAULT_RSYNC_OPTS="-az --no-perms --no-owner --no-group --inplace"
# No --delete in DEFAULT_RSYNC_OPTS — media share rsync is additive only.
# arr_sync.sh keeps all arr databases in union — either server can download anything.
# arr cleanup handles deletions (orphans only). Profiles use --delete explicitly.
BW_LIMIT=0 # KB/s, 0 = unlimited
RETRY_COUNT=3
SLEEP=60 # seconds between retries
ROOTFS_WARN=75 # abort if remote rootfs above this %
# Shared with Monitors/
BANDWIDTH_LOG="$DATA_DIR/bandwidth_history.db"
BANDWIDTH_LOG_RETENTION=90
BANDWIDTH_WARN_GB=50
# Profile definitions (see RSYNC PROFILES section above)
declare -A PROFILES
declare -A PROFILE_BW_LIMIT
declare -A PROFILE_RETRY_COUNT
declare -A PROFILE_SLEEP
declare -A PROFILE_CONTAINER_DELAY
declare -A PROFILE_CRITICAL_CONTAINER_NAMES
declare -A PROFILE_DELAYED_CONTAINERS
declare -A PROFILE_EXCLUDE_DIRS
declare -A PROFILE_REMOTE_RESTART_CONTAINERS
host*.conf
# Daily sync shares — one list per server (mutually exclusive)
HOST1_DAILY_SYNC_SHARES=(...)
HOST2_DAILY_SYNC_SHARES=(...)
# Personal encrypted shares
HOST1_PERSONAL_SHARES=(...)
# SSH key for this server to authenticate to the remote
# Set automatically by ssh_setup.sh — do not edit manually
HOST1_SSH_KEY="/root/.ssh/gmer4lfe_rsync_automation"
HOST2_SSH_KEY="/root/.ssh/jayred365_rsync_automation"
━━━ OUTPUT TIERS ━━━
Without --log, each run shows: section headers (Pre-flight, Transfer, Stop/Start
Containers), the transfer identity block (source, remote, profile, identity), a
progress indicator while rsync is running, and a summary block with duration,
bytes transferred, and status. Warnings and errors are always visible.
With --log, every decision is shown — pre-flight check results, profile resolution,
per-container stop/start state, retry details, and bandwidth log confirmation.
Use for first-run validation or when investigating unexpected behavior.
━━━ FLAG REFERENCE ━━━
All flags work on rsync.sh and all orchestrators.
--dry-run
Runs all pre-flight checks. Shows what rsync would transfer. No transfer, no container stops, no bandwidth log entry. Safe to run at any time.
rsync.sh /mnt/user/Movies --dry-run
rsync.sh /mnt/user/appdata-Fallback/Arrs_Stack --dry-run --log
daily_sync_maintenance.sh --dry-run
--status
Shows resolved configuration — profile, remote identity, all vars that would be used — then exits. No pre-flight checks, no rsync. Use to verify configuration loaded correctly.
rsync.sh /mnt/user/appdata-Fallback/Arrs_Stack --status
--log
Verbose output throughout. Every decision, every container operation, every rsync progress line. Use for first-time runs or when investigating issues.
--profile=name
Override profile selection. Bypasses basename inference. Use when the directory name doesn't match any profile key, or when testing a specific profile.
rsync.sh /mnt/user/appdata-Fallback/Critical-Data --profile=critical-data
rsync.sh /mnt/user/Media_Server/Emby --profile=emby
--seed
Skip the empty-remote-share guard. Normally check_remote_share() aborts when the target
directory on the remote is missing or empty, because that usually means the array is not
mounted and syncing into it would write onto the underlying root filesystem. --seed is the
deliberate exception, for the first push of a genuinely new share.
rsync.sh /mnt/user/New_Share --seed
--merge-run
Bidirectional merge. Two passes:
Pass 1 pull remote → local, with --ignore-existing
Pass 2 push local → remote, with --delete
Local ends up authoritative: the remote may contribute files the local lacks, but never a competing version of a file the local already has. Also triggered automatically when the pre-scan detects ≥75% directory overlap with the remote.
--delete is applied only if pass 1 completed. Its entire justification is "local is now
the authoritative superset", and a pull that was capped by RSYNC_MAX_RUNTIME_HOURS or failed
outright means it is not — the remote still holds content the local never received. On an
incomplete pull the push runs without --delete, warns, and notifies. Local content still
propagates; nothing remote-unique is destroyed; the delete happens on a later run whose pull
actually succeeded.
This was a real defect, fixed 2026-08-01.
--deleteused to be unconditional, so a capped or failed pull would permanently delete remote content the pull had not yet retrieved — and the timeout branch's own comment promised it would "resume the pull next run", which was impossible once the data was gone. If you are changing this function, that interlock is the part that matters.
━━━ SOURCE PATH GUARDS ━━━
rsync.sh pushes to root@remote at the same absolute path, and --merge-run adds
--delete. The source argument is therefore a remote-side hazard, not just a local one, so
two guards run before anything else:
| Guard | Rejects |
|---|---|
| Absolute path, ≥3 components | /, /mnt, /mnt/user, empty |
| Directory must exist locally | typo'd paths |
Every real job path clears both — see HOST*_DAILY_SYNC_SHARES and friends, all of which are
/mnt/user/<Share> or deeper.
Without the existence check, a mistyped path under --merge-run would be created locally by
the pull pass, filled from the remote, then pushed back with --delete.
━━━ RUNTIME CEILING ━━━
No single transfer attempt may exceed RSYNC_MAX_RUNTIME_HOURS (default 23). On timeout the
transfer is terminated and resumes on the next scheduled run.
This is only safe because --partial is in DEFAULT_RSYNC_OPTS — a terminated transfer picks
up where it left off rather than restarting. The cap exists so one huge or stuck transfer
cannot hold its per-profile lock indefinitely and starve every other profile of a turn.
Both --merge-run passes are capped independently.