Moving services off the NAS
My NAS ran out of RAM, so I moved six services to my second server without losing any data, and documented every snag along the way.
| From | ShiNAS (NAS appliance, 7.5 GB RAM) |
| To | ShineoriServer (repurposed laptop, 31 GB RAM, RTX 2060) |
| Moved | Immich machine learning, Uptime Kuma, Homarr, Dawarich, Karakeep, Ghiro |
| When | October 2026 |
Why
The night before, the NAS overloaded: load average 36, swap almost full, and every app timing out (the full diagnosis is in the Immich troubleshooting log). Capping Immich's machine-learning container fixed the crash, but the NAS was still tight:
ShiNAS: 7.5 GB total
├─ containers ≈ 2.9 GB (measured with docker stats)
├─ NAS system ≈ 2.4 GB
├─ disk cache ≈ 2.8 GB (given back when apps need it)
└─ available ≈ 2.2 GB ← one ML job (up to 2 GB) or a video transcode used all of it
Meanwhile the laptop server had 31 GB of RAM and was mostly idle between AI chats.
How I chose what moves
📦 Big, irreplaceable data stays on the NAS. Apps that need lots of RAM but hold little data move.
| Stays on ShiNAS 🔒 | Why |
|---|---|
| Immich (server + database), Paperless | Irreplaceable photos and documents. Immich is in the nightly encrypted backup; adding Paperless is on the backup roadmap. |
| Vaultwarden, Authentik | Identity and passwords: the most important services stay on the most reliable box |
| Jellyfin, Audiobookshelf, *arr stack, qBittorrent | They read and write the media on the NAS disks directly |
| Diun, resume site, cloudflared | Diun watches the host it runs on; the other two are tiny |
| Moved to ShineoriServer 🚚 | RAM on the NAS (idle) | Data |
|---|---|---|
| Immich machine learning | up to 2 GB during jobs | none (model files only) |
| Dawarich (4 containers) | ~450 MiB | small Postgres database |
| Homarr | ~260 MiB | one small folder |
| Uptime Kuma | ~160 MiB | one SQLite file |
| Karakeep | headless Chrome + search engine | small |
| Ghiro | only while running | none |
Uptime Kuma had a second reason: a monitor running on the box it watches can't warn you when that box dies. During the overload it went down with the NAS instead of reporting it. Now it watches the NAS from outside, including a ping monitor for the NAS itself.
How each one moved
Every move followed the same safety rule: copy, start on the laptop, check, and only then switch traffic. The NAS copy stayed untouched as a one-command rollback.
NAS: stop app ─▶ pack data ─▶ copy over SSH ─▶ laptop: unpack ─▶ start ─▶ check ─▶ repoint tunnel + monitors
(NAS copy kept for rollback)
| Service | Method | The interesting part |
|---|---|---|
| Immich ML | Nothing to copy | Immich can send ML work to another machine by URL. The NAS keeps the service definition behind a compose profiles: switch, so rolling back is one line. |
| Uptime Kuma | Folder copy | Stopped first, so SQLite's write-ahead log (kuma.db-wal) was merged into the database before copying |
| Homarr | Folder copy, including .env |
The .env holds the encryption key for every saved integration secret. Without it, they come back unreadable. |
| Dawarich | Docker volume export and import | Its data lives in 5 Docker named volumes, not in its folder, so copying the folder alone would have produced an empty Dawarich. I exported each volume to a tarball with a throwaway container, then imported them into fresh volumes on the laptop. |
| Karakeep | Volume export and import | A port clash (Open WebUI already had 3000) and a dead image registry. See below. |
| Ghiro | Fresh install | It had no data, and the old image turned out to be damaged |
The volume trick, for reference:
# export (NAS): read the volume with an image that's already downloaded, so nothing new is pulled
docker run --rm --entrypoint tar -v dawarich_dawarich_db_data:/v:ro -v /tmp/move:/out \
postgis/postgis:17-3.5-alpine czf /out/db_data.tgz -C /v .
# import (laptop): `docker compose create` makes the empty volumes, then fill them
docker run --rm --entrypoint tar -v dawarich_dawarich_db_data:/v -v ~/docker/move:/in \
postgis/postgis:17-3.5-alpine xzf /in/db_data.tgz -C /v
Troubleshooting log
| Problem | Root cause | Fix |
|---|---|---|
The laptop couldn't download anything: lookup ghcr.io ... i/o timeout |
Not DNS. The laptop could reach the router but not the internet. A host-wide ProtonVPN WireGuard tunnel (wg-quick@proton) was sending all traffic into a tunnel that had stopped passing traffic. It hid from ip route because WireGuard uses separate policy-routing rules (ip rule). |
SearXNG already had its own VPN through gluetun, so the host-wide tunnel was redundant (a VPN inside a VPN). Brought it down and disabled it at boot. |
scp from the NAS said No such file or directory for a file that existed |
Newer scp uses SFTP, and the NAS locks SFTP into the shared folders, so its /tmp isn't the real /tmp |
Streamed the file over plain SSH instead: ssh nas 'cat /tmp/file.tgz' > file.tgz |
| Karakeep's headless-Chrome image wouldn't download: billing must be enabled | The Google registry project hosting it stopped serving downloads. The NAS only had it because it was downloaded long ago. | Switched to the same image on Docker Hub (docker.io/zenika/alpine-chrome:124) |
Karakeep's NEXTAUTH_URL was http://localhost:3000 |
A wrong value from the original setup, found while moving it | Set it to the public hostname |
| Dawarich login ended on a Cloudflare 502 | The tunnel route still pointed at the NAS, and I had started the login on the laptop's IP while Authentik returns you to the public hostname | Repointed the route; logins start from the public hostname |
| Homarr's Uptime Kuma widget returned 404 | With "no secrets", Homarr reads a status page with the slug default, which didn't exist |
Created a status page and entered its slug |
| Homarr's tile still opened the old address | In Homarr, the integration (data for widgets) and the app (where the tile links) are stored separately | Updated the app's URL as well |
docker save of Ghiro failed: a file missing from an image layer |
The locally built image on the NAS was damaged, and its Dockerfile is based on long-retired Ubuntu 16.04 and Python 2 | It held no data, so: built fresh on the laptop first, then removed everything from the NAS (docker compose down -v --rmi all) |
Result
ShineoriServer 💻 31 GB ShiNAS 🗄️ 7.5 GB
├─ Immich ML ├─ Immich, Paperless (irreplaceable data)
├─ Uptime Kuma (watches the NAS) ├─ Vaultwarden, Authentik (identity)
├─ Homarr ├─ Jellyfin, Audiobookshelf, *arr stack
├─ Dawarich, Karakeep ├─ Diun, resume site, cloudflared
├─ Ghiro (on demand) └─ Flowcase (on demand)
└─ Ollama, Open WebUI, SearXNG, Minecraft
About 870 MiB of steady RAM use left the NAS, and more importantly the up-to-2 GB spikes from machine learning now land on a machine with room for them. One Cloudflare tunnel on the NAS still publishes apps on both machines.
What I'd do next
- [x] Back up the laptop ✅ Done: nightly, encrypted, append-only backups to the NAS (Backups)
- [x] Diun on the laptop ✅ Done: same setup as the NAS, with alerts labeled
shineoriserver - [x] A DHCP reservation for the laptop ✅ Done: its address is reserved in the router, so it can't change under the tunnel routes, Immich ML and the backup job that point at it
- [x] The laptop never sleeps ✅ Done: all sleep modes masked in systemd, and the lid and idle timers set to
ignoreinlogind, on battery, on power and docked - [ ] Delete the rollback copies left on the NAS after a few calm days
- [x] GPU acceleration for Immich ML ✅ Done: runs on the RTX 2060 through the
-cudaimage (details)
What I learned
- Know where your data actually lives. A folder copy is enough for bind mounts; named volumes need an export. Checking
docker volume lsfirst saved me from moving an empty app. - Build the new copy before you delete the old one. Every move kept a working rollback until the new copy was checked.
- Read the routing table and the rules.
ip routelooked normal while WireGuard's policy rules sent everything into a dead tunnel. - Images can disappear. Something that runs fine today may be impossible to download again tomorrow, and you only find out when you move or rebuild.
- Monitoring belongs outside what it monitors.