Skip to content

Commit 55f6289

Browse files
committed
feat: add NAS storage support via rclone SFTP
Extends existing cloud storage infrastructure to support NAS devices (TrueNAS, Synology, Unraid) over SFTP. Same rclone + mergerfs compose, different backend and LAN-optimized defaults. - Add --storage-type nas to setup wizard with SSH key gen, Synology chroot detection, and connectivity testing - Add --nas-storage flag to bootstrap.sh - Make dir-cache-time configurable via RCLONE_DIR_CACHE_TIME env var - Add NAS health checks to doctor.sh (key perms, SFTP connectivity) - Dynamic upload intervals in launchd (2h NAS, 6h cloud) - Add SFTP examples to rclone.conf.example - Add NAS compose render test to CI - Update README and SETUP with NAS docs
1 parent 0141ef1 commit 55f6289

11 files changed

Lines changed: 383 additions & 22 deletions

.env.example

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,19 @@ JELLYSTAT_DB_PASSWORD=
5959
# RCLONE_VFS_CACHE_MAX_AGE=72h
6060
# RCLONE_VFS_READ_CHUNK_SIZE=128M
6161
# CLOUD_UPLOAD_MIN_AGE_HOURS=24
62+
# RCLONE_DIR_CACHE_TIME=5m
63+
64+
# --- NAS STORAGE (rclone SFTP + mergerfs) ---
65+
# Same infrastructure as cloud storage, optimized for LAN.
66+
# Uses rclone SFTP to mount NAS inside Docker (where FUSE works on macOS).
67+
# Run: bash scripts/setup-cloud-storage.sh --storage-type nas
68+
#
69+
# STORAGE_TYPE=nas
70+
# CLOUD_STORAGE_ENABLED=true
71+
# RCLONE_REMOTE=mynas
72+
# RCLONE_REMOTE_PATH=/mnt/pool/dataset/media
73+
# RCLONE_VFS_CACHE_MAX_SIZE=10G
74+
# RCLONE_VFS_CACHE_MAX_AGE=1h
75+
# RCLONE_VFS_READ_CHUNK_SIZE=32M
76+
# RCLONE_DIR_CACHE_TIME=30s
77+
# CLOUD_UPLOAD_MIN_AGE_HOURS=2

.github/workflows/validate.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,11 @@ jobs:
5757
docker compose -f docker-compose.yml -f docker-compose.cloud-storage.yml --profile cloud-storage config >/dev/null
5858
docker compose -f docker-compose.yml -f docker-compose.cloud-storage.yml --profile cloud-storage --profile jellyfin config >/dev/null
5959
docker compose -f docker-compose.yml -f docker-compose.cloud-storage.yml --profile cloud-storage --profile tdarr-docker config >/dev/null
60+
61+
# NAS storage compose (same overlay, different env vars)
62+
sed -i 's/^RCLONE_VFS_CACHE_MAX_SIZE=50G/RCLONE_VFS_CACHE_MAX_SIZE=10G/' .env
63+
echo 'STORAGE_TYPE=nas' >> .env
64+
echo 'RCLONE_DIR_CACHE_TIME=30s' >> .env
65+
docker compose -f docker-compose.yml -f docker-compose.cloud-storage.yml --profile cloud-storage config >/dev/null
66+
docker compose -f docker-compose.yml -f docker-compose.cloud-storage.yml --profile cloud-storage --profile jellyfin config >/dev/null
6067
rm .env

README.md

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ New to self-hosted media? Start with the [basic version](https://github.com/liam
5656
| **Intro Skipper** | Jellyfin plugin: auto-detects intros and adds a "Skip Intro" button on TV shows |
5757
| **TMDb Box Sets** | Jellyfin plugin: auto-creates franchise collections from TMDb data (Jellyfin's Kometa) |
5858
| **Jellystat** | Jellyfin analytics dashboard (like Tautulli for Plex: watch history, user stats, library insights) |
59-
| **Cloud Storage** | rclone + mergerfs: transparent cloud/local merged library (Google Drive, S3, B2, Dropbox) |
59+
| **Cloud / NAS Storage** | rclone + mergerfs: transparent remote/local merged library (Google Drive, S3, B2, Dropbox, NAS via SFTP) |
6060

6161
## Choosing Your Media Server
6262

@@ -96,11 +96,11 @@ After the stack is running:
9696

9797
Jellystat will start tracking watch history, user activity, and library statistics automatically.
9898

99-
## Optional: Cloud Storage (rclone + mergerfs)
99+
## Optional: Cloud / NAS Storage (rclone + mergerfs)
100100

101-
Transparent cloud/local merged library. rclone FUSE-mounts your cloud storage inside Docker (where FUSE works natively), mergerfs overlays it with local storage, and all existing services see a single unified path. Local-first writes keep downloads fast; a periodic upload script moves stable media to the cloud.
101+
Transparent cloud/local or NAS/local merged library. rclone FUSE-mounts your remote storage inside Docker (where FUSE works natively on macOS), mergerfs overlays it with local storage, and all existing services see a single unified path. Local-first writes keep downloads fast; a periodic upload script moves stable media to the remote.
102102

103-
Supports Google Drive, S3, Backblaze B2, Dropbox, and [40+ other providers](https://rclone.org/overview/).
103+
Supports Google Drive, S3, Backblaze B2, Dropbox, NAS via SFTP (TrueNAS, Synology, Unraid), and [40+ other providers](https://rclone.org/overview/).
104104

105105
### Compatibility Requirements (macOS)
106106

@@ -123,13 +123,26 @@ Or include it in bootstrap:
123123
bash bootstrap.sh --cloud-storage
124124
```
125125

126+
### NAS Quick Start
127+
128+
```bash
129+
bash scripts/setup-cloud-storage.sh --storage-type nas
130+
docker compose -f docker-compose.yml -f docker-compose.cloud-storage.yml --profile cloud-storage --profile jellyfin --profile tdarr-docker up -d
131+
```
132+
133+
Or include it in bootstrap:
134+
135+
```bash
136+
bash bootstrap.sh --nas-storage
137+
```
138+
126139
### How It Works
127140

128141
```
129142
rclone-mount --> FUSE mounts cloud provider to /cloud
130143
mergerfs --> overlays /local + /cloud into /merged
131144
Radarr/Sonarr --> read/write /merged (local-first writes)
132-
cloud-upload --> every 6h, moves files >24h old to cloud
145+
cloud-upload --> periodically moves stable files to remote (6h/24h for cloud, 2h for NAS)
133146
```
134147

135148
### Library paths when cloud storage is enabled
@@ -150,6 +163,24 @@ cloud-upload --> every 6h, moves files >24h old to cloud
150163
| `RCLONE_VFS_READ_CHUNK_SIZE` | `128M` | Read chunk size |
151164
| `CLOUD_UPLOAD_MIN_AGE_HOURS` | `24` | Only upload files older than this |
152165

166+
### NAS Configuration (LAN-Optimized Defaults)
167+
168+
| Variable | Default | Description |
169+
|----------|---------|-------------|
170+
| `STORAGE_TYPE` | (empty) | Set to `nas` for NAS mode |
171+
| `RCLONE_DIR_CACHE_TIME` | `30s` | Directory listing cache (shorter for LAN) |
172+
| `RCLONE_VFS_CACHE_MAX_SIZE` | `10G` | Smaller cache needed on fast LAN |
173+
| `RCLONE_VFS_CACHE_MAX_AGE` | `1h` | Shorter cache age on LAN |
174+
| `RCLONE_VFS_READ_CHUNK_SIZE` | `32M` | Read chunk size |
175+
| `CLOUD_UPLOAD_MIN_AGE_HOURS` | `2` | Upload files older than 2h (vs 24h for cloud) |
176+
177+
**Platform notes:**
178+
- **TrueNAS:** Media path is typically `/mnt/pool/dataset/media`
179+
- **Synology:** Path is `/volume1/media`. The setup wizard auto-adds `--sftp-path-override` for SFTP chroot compatibility.
180+
- **Unraid:** Path is `/mnt/user/media`
181+
182+
**Performance:** rclone SFTP delivers ~100MB/s on Gigabit LAN, adequate for multiple concurrent 4K streams.
183+
153184
**Cloud mode note:** Use `TDARR_MODE=docker` when cloud storage is enabled. Native Tdarr cannot access merged Docker FUSE mounts on macOS.
154185

155186
## Optional: Music (Lidarr + Tidarr)
@@ -185,7 +216,7 @@ Then open Tidarr at `http://localhost:8484` to authenticate with your Tidal acco
185216
| Log prune | Daily | Removes log files older than 30 days |
186217
| Franchise sort | Optional/manual | Sorts franchise collection movies by release date in Plex (Plex only) |
187218
| VPN failover | Every 2 min (optional) | Auto-switches between ProtonVPN and NordVPN on sustained failure |
188-
| Cloud upload | Every 6 hours (optional) | Moves local media older than 24h to cloud storage |
219+
| Cloud upload | Every 2-6 hours (optional) | Moves stable local media to remote storage (NAS: 2h, cloud: 6h) |
189220
| Watchtower | Daily at 04:00 (optional) | Auto-pulls latest container images and recreates updated services |
190221

191222
Franchise sorting is kept manual by default because it requires your Plex token:
@@ -241,6 +272,12 @@ To use Jellyfin instead of Plex:
241272
bash bootstrap.sh --jellyfin
242273
```
243274

275+
NAS storage via SFTP (TrueNAS, Synology, Unraid):
276+
277+
```bash
278+
bash bootstrap.sh --nas-storage
279+
```
280+
244281
## Update Existing Clone
245282

246283
Already cloned an older version and want the latest release tag without reinstalling?

SETUP.md

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -335,9 +335,9 @@ Everything else is fully automated.
335335

336336
---
337337

338-
## Optional: Cloud Storage (rclone + mergerfs)
338+
## Optional: Cloud / NAS Storage (rclone + mergerfs)
339339

340-
If your local disk is too small for your full library, cloud storage lets you extend it transparently. rclone mounts your cloud provider (Google Drive, S3, B2, Dropbox, etc.) inside Docker, and mergerfs overlays it with your local storage so all services see one unified library.
340+
If your local disk is too small for your full library, or you have a NAS with plenty of space, remote storage lets you extend your library transparently. rclone mounts your cloud provider or NAS (via SFTP) inside Docker, and mergerfs overlays it with your local storage so all services see one unified library.
341341

342342
### Compatibility (macOS)
343343

@@ -365,6 +365,33 @@ docker compose -f docker-compose.yml -f docker-compose.cloud-storage.yml --profi
365365

366366
Or pass `--cloud-storage` to `bootstrap.sh` for a full install.
367367

368+
### NAS Setup (TrueNAS, Synology, Unraid)
369+
370+
```bash
371+
bash scripts/setup-cloud-storage.sh --storage-type nas
372+
```
373+
374+
The wizard will:
375+
1. Ask for your NAS hostname/IP and SSH username
376+
2. Offer to generate an SSH key pair (recommended), use an existing key, or use password auth
377+
3. Ask for your media path with platform-specific examples
378+
4. Auto-detect Synology paths and add `--sftp-path-override` for SFTP chroot compatibility
379+
5. Test connectivity before writing the config
380+
6. Write NAS-optimized VFS cache settings to `.env`
381+
382+
**SSH key setup:** If you generate a new key, the wizard prints the public key. Add it to your NAS:
383+
- **TrueNAS:** Accounts > Users > [user] > SSH Public Key
384+
- **Synology:** DSM > Control Panel > User > [user] > Edit > SSH Key
385+
- **Unraid:** Tools > User SSH Keys, or append to `~/.ssh/authorized_keys`
386+
387+
**Start with NAS storage:**
388+
389+
```bash
390+
docker compose -f docker-compose.yml -f docker-compose.cloud-storage.yml --profile cloud-storage --profile jellyfin --profile tdarr-docker up -d
391+
```
392+
393+
Or use bootstrap: `bash bootstrap.sh --nas-storage`
394+
368395
### How it works
369396

370397
- **Write path:** Downloads land on local disk (fast). Every 6 hours, `cloud-upload.sh` moves files older than 24h to the cloud via `rclone move`.
@@ -392,6 +419,22 @@ docker exec rclone-mount rclone ls myremote: --max-depth 1
392419
docker restart rclone-mount && sleep 15 && docker restart mergerfs
393420
```
394421

422+
### NAS-specific troubleshooting
423+
424+
```bash
425+
# Verify SSH key permissions
426+
ls -la ~/Media/config/rclone/nas_key.pem
427+
# Should show -rw------- (600)
428+
429+
# Test NAS connectivity directly
430+
docker run --rm -v ~/Media/config/rclone:/config/rclone rclone/rclone lsd mynas: --contimeout 10s
431+
432+
# Check SFTP connection speed
433+
docker run --rm -v ~/Media/config/rclone:/config/rclone rclone/rclone test bandwidth mynas: --duration 10s
434+
435+
# Synology: if paths don't resolve, check sftp_path_override in rclone.conf
436+
```
437+
395438
---
396439

397440
## Troubleshooting

bootstrap.sh

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ NON_INTERACTIVE=false
1717
MEDIA_SERVER=plex
1818
TDARR_MODE=native
1919
CLOUD_STORAGE=false
20+
NAS_STORAGE=false
2021

2122
usage() {
2223
cat <<EOF
@@ -29,6 +30,7 @@ Options:
2930
--tdarr-mode MODE Tdarr mode: native (default) or docker
3031
--tdarr-docker Shortcut for --tdarr-mode docker
3132
--cloud-storage Enable cloud storage (rclone + mergerfs)
33+
--nas-storage Enable NAS storage via SFTP (rclone + mergerfs)
3234
--non-interactive Skip interactive prompts (manual Seerr wiring required)
3335
--help Show this help message
3436
@@ -37,6 +39,7 @@ Examples:
3739
bash bootstrap.sh --media-dir /Volumes/T9/Media
3840
bash bootstrap.sh --tdarr-docker
3941
bash bootstrap.sh --jellyfin --cloud-storage --tdarr-docker
42+
bash bootstrap.sh --jellyfin --nas-storage --tdarr-docker
4043
bash bootstrap.sh --media-dir /Volumes/T9/Media --non-interactive
4144
EOF
4245
}
@@ -79,6 +82,11 @@ while [[ $# -gt 0 ]]; do
7982
CLOUD_STORAGE=true
8083
shift
8184
;;
85+
--nas-storage)
86+
NAS_STORAGE=true
87+
CLOUD_STORAGE=true
88+
shift
89+
;;
8290
--non-interactive)
8391
NON_INTERACTIVE=true
8492
shift
@@ -334,11 +342,14 @@ fi
334342
if [[ "$CLOUD_STORAGE" == true ]]; then
335343
echo ""
336344
echo -e "${CYAN}Setting up cloud storage...${NC}"
345+
SETUP_ARGS=("--media-dir" "$MEDIA_DIR")
346+
if [[ "$NAS_STORAGE" == true ]]; then
347+
SETUP_ARGS+=("--storage-type" "nas")
348+
fi
337349
if [[ "$NON_INTERACTIVE" == true ]]; then
338-
bash scripts/setup-cloud-storage.sh --media-dir "$MEDIA_DIR" --non-interactive
339-
else
340-
bash scripts/setup-cloud-storage.sh --media-dir "$MEDIA_DIR"
350+
SETUP_ARGS+=("--non-interactive")
341351
fi
352+
bash scripts/setup-cloud-storage.sh "${SETUP_ARGS[@]}"
342353
fi
343354

344355
echo ""
@@ -506,8 +517,13 @@ else
506517
fi
507518
echo ""
508519
if [[ "$CLOUD_ACTIVE" == true ]]; then
509-
echo " Cloud storage: enabled (rclone + mergerfs)"
510-
echo " Uploads run every 6 hours via launchd"
520+
if [[ "$NAS_STORAGE" == true ]]; then
521+
echo " NAS storage: enabled (rclone SFTP + mergerfs)"
522+
echo " Uploads run every 2 hours via launchd"
523+
else
524+
echo " Cloud storage: enabled (rclone + mergerfs)"
525+
echo " Uploads run every 6 hours via launchd"
526+
fi
511527
echo ""
512528
fi
513529

configs/rclone.conf.example

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,40 @@
4040
# [local-test]
4141
# type = local
4242
# nounc = true
43+
44+
# ── NAS via SFTP (recommended for TrueNAS, Synology, Unraid) ────────
45+
# https://rclone.org/sftp/
46+
# SFTP gives ~100MB/s on Gigabit LAN (vs ~20MB/s for rclone SMB).
47+
# SSH key auth recommended for unattended operation.
48+
#
49+
# The setup wizard can generate keys for you:
50+
# bash scripts/setup-cloud-storage.sh --storage-type nas
51+
52+
# TrueNAS (key auth)
53+
# [mynas]
54+
# type = sftp
55+
# host = 192.168.1.100
56+
# user = media
57+
# key_file = /config/rclone/nas_key.pem
58+
59+
# Synology (key auth + path override for SFTP chroot)
60+
# [synology]
61+
# type = sftp
62+
# host = 192.168.1.101
63+
# user = media
64+
# key_file = /config/rclone/nas_key.pem
65+
# sftp_path_override = /volume1/media
66+
67+
# Unraid (key auth)
68+
# [unraid]
69+
# type = sftp
70+
# host = 192.168.1.102
71+
# user = root
72+
# key_file = /config/rclone/nas_key.pem
73+
74+
# Generic NAS (password auth — run: rclone obscure YOUR_PASSWORD)
75+
# [nas-password]
76+
# type = sftp
77+
# host = 192.168.1.100
78+
# user = media
79+
# pass = OBSCURED_PASSWORD_HERE

docker-compose.cloud-storage.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ services:
2424
--vfs-cache-max-size ${RCLONE_VFS_CACHE_MAX_SIZE:-50G}
2525
--vfs-cache-max-age ${RCLONE_VFS_CACHE_MAX_AGE:-72h}
2626
--vfs-read-chunk-size ${RCLONE_VFS_READ_CHUNK_SIZE:-128M}
27-
--dir-cache-time 5m
27+
--dir-cache-time ${RCLONE_DIR_CACHE_TIME:-5m}
2828
--poll-interval 15s
2929
--log-level INFO
3030
volumes:

scripts/cloud-upload.sh

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ BASE_DIR="$(resolve_media_dir "$PROJECT_DIR")"
1313
# shellcheck disable=SC1091
1414
source "$PROJECT_DIR/.env" 2>/dev/null || true
1515

16+
STORAGE_TYPE="${STORAGE_TYPE:-cloud}"
17+
1618
# Exit silently if cloud storage not enabled
1719
if [[ "${CLOUD_STORAGE_ENABLED:-}" != "true" ]]; then
1820
exit 0
@@ -56,7 +58,7 @@ if [[ ! -f "$RCLONE_CONF" ]]; then
5658
exit 1
5759
fi
5860

59-
log "--- Cloud upload started ---"
61+
log "--- Cloud upload started (${STORAGE_TYPE}) ---"
6062

6163
# Move Movies
6264
if [[ -d "$BASE_DIR/Movies" ]]; then
@@ -90,4 +92,4 @@ if [[ -d "$BASE_DIR/TV Shows" ]]; then
9092
log "TV Shows upload complete (exit: $?)"
9193
fi
9294

93-
log "--- Cloud upload finished ---"
95+
log "--- Cloud upload finished (${STORAGE_TYPE}) ---"

scripts/doctor.sh

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,41 @@ if [[ "$CLOUD_STORAGE_ENABLED" == "true" ]]; then
236236
fi
237237
fi
238238

239+
# NAS-specific checks
240+
STORAGE_TYPE=""
241+
if [[ -f "$ENV_FILE" ]]; then
242+
STORAGE_TYPE=$(sed -n 's/^STORAGE_TYPE=//p' "$ENV_FILE" | head -1)
243+
fi
244+
245+
if [[ "$STORAGE_TYPE" == "nas" ]]; then
246+
nas_key="$MEDIA_DIR/config/rclone/nas_key.pem"
247+
if [[ -f "$nas_key" ]]; then
248+
ok "NAS SSH key exists"
249+
key_perms=$(stat -f '%Lp' "$nas_key" 2>/dev/null || stat -c '%a' "$nas_key" 2>/dev/null || echo "unknown")
250+
if [[ "$key_perms" == "600" ]]; then
251+
ok "NAS SSH key permissions are 600"
252+
else
253+
fail "NAS SSH key permissions are $key_perms (expected 600)"
254+
fi
255+
else
256+
warn "No NAS SSH key found at $nas_key (password auth may be in use)"
257+
fi
258+
259+
# Test NAS connectivity
260+
rclone_conf="$MEDIA_DIR/config/rclone/rclone.conf"
261+
env_remote=$(sed -n 's/^RCLONE_REMOTE=//p' "$ENV_FILE" | head -1)
262+
if [[ -n "$env_remote" && -f "$rclone_conf" ]]; then
263+
if docker run --rm \
264+
-v "$MEDIA_DIR/config/rclone:/config/rclone" \
265+
rclone/rclone lsd "${env_remote}:" \
266+
--contimeout 10s 2>/dev/null; then
267+
ok "NAS reachable via rclone SFTP"
268+
else
269+
fail "Cannot reach NAS via rclone SFTP (check network/credentials)"
270+
fi
271+
fi
272+
fi
273+
239274
PORTS="5055 9696 8989 7878 8080 6767 8191 8265 8266"
240275
if [[ "$MEDIA_SERVER" == "jellyfin" ]]; then
241276
PORTS="$PORTS 8096"

0 commit comments

Comments
 (0)