Pair a storage VPS with your main VPS
Run the app on a fast small VPS and keep the terabytes on a cheap storage VPS. Join them with NFS, SSHFS or rclone over WireGuard, and survive a lost mount.
Why pair a storage VPS with your main VPS
Pair a storage VPS with your main VPS and each box does the job it is priced for. The small fast box runs the application, its database and its cache. The large cheap box holds the terabytes and serves bytes. A private WireGuard tunnel joins the two, and the application sees the far disk as an ordinary directory under /mnt.
The reason to split is cost per gigabyte. A storage VPS trades CPU and IOPS for raw capacity, so the same monthly spend buys far more space once you stop paying for fast compute next to every terabyte. If you have not settled on the storage product yet, work through how a storage VPS compares against block volumes and object storage first, because the join options below depend on which one you bought.
The cost of the split is one network round trip in front of every file operation. A local stat() is a memory lookup. The same stat() on a remote mount is a request, a wait, and a reply. No mount option removes that round trip. You can only reduce how many of them the application makes.
Two decisions decide whether the pair works at all. Both boxes stay in the same region. Databases, caches and transcode scratch stay on local disk. Get either one wrong and nothing later in this guide rescues it.
Why both boxes must be in the same region
Round trip time is the whole budget. Inside one data centre the round trip between two VPS instances is a fraction of a millisecond. Between two continents it is well over a hundred times that, and that multiplier lands on every single operation, not on the slow ones.
Consider what a media scanner does. It reads a directory, then calls stat() on each entry, then opens some of them to read embedded metadata. A library of ten thousand files therefore costs at least ten thousand round trips before any video data moves. Bulk throughput is not the problem here. Streaming one large file over a long link is fine, because the reads are large and sequential and the kernel reads ahead. Walking a tree is the problem, because each small operation waits for the answer to the previous one.
So: same provider, same region, ideally the same data centre. Verify it before you migrate anything. Rent both boxes for one month, bring up the tunnel, and measure with ping -c 100 10.9.0.2. If the average is not a small fraction of a millisecond, do not build this pair. Put the application next to its data, or move to object storage where the access pattern is one large request at a time.
What must never live on the far side
Four kinds of file belong on the local disk of the application box.
- Database files. SQLite, PostgreSQL and MariaDB need
fsync()to mean what it says, and they need file locking to work across processes. Over a remote mount both depend on the far side and on the network behaving. When the link stalls, a database does not pause politely. It hangs, or it corrupts. - Caches, indexes and thumbnails. Small files, read constantly, cheap to regenerate. Putting them behind a round trip is the worst trade available. Every page of a gallery becomes a burst of tiny remote reads.
- Transcode scratch. A transcoder writes and deletes at a high rate, in small chunks. Point its temporary directory at local disk and check that you actually changed the setting.
- Lock files and unix sockets. A
.pidfile or a socket in a shared directory means nothing across two machines, and some remote filesystems handle them badly.
The far side holds bulk files that are written once and read whole: video, photo originals, archives, backup repositories.
For a media server the split is clean. Jellyfin running on a VPS keeps its configuration and metadata under /var/lib/jellyfin on local disk and reads video from the mount. Move /var/lib/jellyfin to the storage box and every click in the web interface waits for the far side. Photos split the same way: with Immich, the upload library and the Postgres database are separate things. The library can live on the mount. The database cannot.
Join the two boxes with WireGuard
Do not export a filesystem to a public IP address. NFS has no transport encryption by default, and its authentication is mostly "trust the numeric user id the client sends". Put it inside a tunnel.
The full setup lives in the guide to self-hosting WireGuard on a VPS. Here is the two-peer version. Generate a key pair on each box:
sudo apt update && sudo apt install -y wireguard
sudo install -d -m 700 /etc/wireguard
sudo sh -c 'umask 077; wg genkey > /etc/wireguard/priv.key'
sudo sh -c 'wg pubkey < /etc/wireguard/priv.key > /etc/wireguard/pub.key'On the application box, /etc/wireguard/wg0.conf:
[Interface]
Address = 10.9.0.1/24
ListenPort = 51820
PrivateKey = <application box private key>
[Peer]
PublicKey = <storage box public key>
AllowedIPs = 10.9.0.2/32
Endpoint = <storage box public IP>:51820
PersistentKeepalive = 25The storage box mirrors it: Address = 10.9.0.2/24, the peer's AllowedIPs = 10.9.0.1/32, and the endpoint set to the application box's public address. Both files need mode 600. Then bring the tunnel up on both:
sudo chmod 600 /etc/wireguard/wg0.conf
sudo systemctl enable --now wg-quick@wg0
sudo wg show
ping -c 3 10.9.0.2wg show should list the peer with a recent latest handshake line, and the ping should answer. A peer with no handshake line means packets are not arriving. Check UDP 51820 on both host firewalls and on your provider's separate network firewall, which is a different control on most panels.
Two tunnel problems will bite this design later, so recognise them now. Large sequential reads that stall while ping still works are a path MTU problem, and the fix is finding the real path MTU by bisection instead of guessing round numbers. And if you refer to the storage box by hostname rather than by its tunnel address, DNS resolution over the tunnel becomes one more thing that can fail at boot. Use the literal 10.9.0.2 address in every mount definition and that problem never exists.
Option 1: NFS over the tunnel
NFS is the strongest option for a filesystem in the same region, because the kernel client caches attributes and because its behaviour under failure is tunable rather than fixed.
On the storage box:
sudo apt update && sudo apt install -y nfs-kernel-server
sudo install -d -m 755 /srv/media
sudo chown 1000:1000 /srv/mediaAdd one line to /etc/exports, naming the application box's tunnel address:
/srv/media 10.9.0.1(rw,sync,no_subtree_check,root_squash)Apply and inspect it:
sudo exportfs -ra
sudo exportfs -vexportfs -v prints the options the kernel actually applied, which is not always what you typed, because defaults are filled in around your list.
The export line restricts which client address may mount, but the NFS server still listens on TCP 2049 on every interface. A port scan of the public address finds it. The export list is not a firewall, so add one:
sudo ss -lnt | grep 2049
sudo ufw status verbose
sudo ufw allow in on wg0 to any port 2049 proto tcpss shows the listener bound to every address, which is the point. ufw status verbose should print Default: deny (incoming). With that default in place, the single tunnel rule is enough: 2049 answers on wg0 and nowhere else. If the default is allow, fix that before going further.
NFSv4 without Kerberos maps ownership by numeric user id. If the application runs as uid 1000 on one box and the files are owned by uid 1000 on the other, it works. If the numbers differ, files show as owned by nobody or writes fail with Permission denied, and no amount of chmod helps. Make the ids match on both boxes, or add all_squash,anonuid=1000,anongid=1000 to the export so every client access lands on one owner.
On the application box, mount it:
sudo apt install -y nfs-common
sudo install -d -m 755 /mnt/media
sudo mount -t nfs4 -o vers=4.2,nconnect=4 10.9.0.2:/srv/media /mnt/media
findmnt /mnt/mediafindmnt prints the source, the type and the options in effect. Then make it permanent in /etc/fstab, with an automount so that a boot with the tunnel down does not hang the boot:
10.9.0.2:/srv/media /mnt/media nfs4 _netdev,x-systemd.automount,x-systemd.mount-timeout=30,x-systemd.idle-timeout=600,vers=4.2,nconnect=4,hard,timeo=100,retrans=3,acdirmin=60,acdirmax=600 0 0sudo systemctl daemon-reload
sudo systemctl start mnt-media.automount
systemctl status mnt-media.automountThe unit name comes from the path: /mnt/media becomes mnt-media.automount. With x-systemd.automount, systemd only installs a trigger at boot. The real mount happens on first access, so the storage box being slow to boot cannot delay your application box.
Three option choices are worth understanding rather than copying. nconnect=4 opens several TCP connections to the same server, up to a documented limit of 16, which helps when a single connection's window caps throughput on a link with real latency. acdirmin and acdirmax set how long the client trusts its cached directory attributes before asking again, and raising them from the short defaults is the single most effective change for metadata-heavy scans. The trade is that a file added on the storage box by some other process appears late. If files only ever arrive through the application box, that trade costs you nothing, because the client updates its own cache when it writes. And noatime does nothing here, whatever the blog post you copied the line from says: the nfs man page states plainly that the atime and noatime options have no effect on NFS mounts.
Option 2: SSHFS
SSHFS needs nothing on the storage box beyond an SSH account. It runs in userspace through FUSE, and every file operation becomes an SFTP request inside the SSH connection.
Know what you are adopting. As of August 2026 the upstream README states the project "does not have any active, regular contributors, and there are a number of known issues", while the maintainer still applies pull requests and makes releases. It is packaged by every major distribution and it works. Treat it as stable rather than as improving.
sudo apt install -y sshfs
sudo install -d -m 755 /mnt/media
sshfs -o reconnect,ServerAliveInterval=15,ServerAliveCountMax=3,uid=1000,gid=1000,allow_other,default_permissions storage@10.9.0.2:/srv/media /mnt/media
ls /mnt/mediaUnmount with fusermount -u /mnt/media, not umount. The uid and gid options translate remote ownership into a local id, which sidesteps the id-matching problem NFS has. reconnect plus the two keepalive settings is what lets the mount survive a brief tunnel drop. Without them, one dropped connection kills the mount permanently and every access returns Transport endpoint is not connected until you unmount and mount again.
The fstab form needs a key with no passphrase that root can read:
storage@10.9.0.2:/srv/media /mnt/media fuse.sshfs _netdev,x-systemd.automount,IdentityFile=/root/.ssh/id_ed25519,reconnect,ServerAliveInterval=15,ServerAliveCountMax=3,uid=1000,gid=1000,allow_other,default_permissions 0 0Where it hurts: every metadata operation is a full round trip over one connection, and there is no kernel attribute cache doing the work the NFS client does for free. A directory listing that NFS answers from cache costs SSHFS a request per entry. You are also encrypting twice, once for SSH and once for WireGuard, which is CPU spent for nothing on a small box. That points at a defensible alternative: drop the tunnel and run SSHFS against the storage box's public address, because SSH is safe to expose and NFS is not. Pick one design. Do not run both.
Option 3: an rclone mount
rclone also mounts through FUSE, and its remote can be SFTP on the storage box or an object storage bucket somewhere else. It is the right answer when the far side is object storage. It is the wrong answer when the far side is a filesystem you could mount properly.
sudo apt install -y rclone
rclone config
rclone lsd storage:rclone lsd listing your directories proves the remote is configured before you try to mount it. The mountpoint must be an existing empty directory on Linux, which the rclone documentation states directly.
rclone mount storage:media /mnt/media --vfs-cache-mode full --vfs-cache-max-size 20G --dir-cache-time 72h --allow-other --daemon--vfs-cache-mode full is what makes ordinary applications work against the mount, and it writes a local cache. That cache lands on the small fast disk you were trying not to fill, so --vfs-cache-max-size is not optional. --dir-cache-time 72h holds directory listings in memory for three days, which is what makes a large library browsable at all, and it means a file added by some other process on the far side may not appear for three days. For a systemd unit, the documentation notes that Type=notify makes the service reach the started state only after the mountpoint is set up, which is what other units should order themselves against.
Where it hurts: there are no POSIX semantics underneath. On an object backend a rename is a copy followed by a delete, and a small write into the middle of a large existing file rewrites the whole object. Applications that assume a real filesystem, which includes every media server, behave in ways the documentation cannot predict for you. rclone earns its place for reading large files and for running the far box as an offsite backup target. It does not earn a place as a general purpose disk.
What the application does when the link drops
Decide this before you build, because the default is not what most people expect.
With NFS hard, which is the default, a dropped link puts every process touching the mount into uninterruptible sleep. ps shows state D, kill -9 does nothing, and df hangs. If your monitoring runs df, monitoring hangs too. The kernel logs nfs: server 10.9.0.2 not responding, still trying. When the link returns, you get nfs: server 10.9.0.2 OK and every process resumes exactly where it stopped with no lost writes. That is the correct trade for anything that writes.
With NFS soft, requests fail with an I/O error once timeo and retrans are exhausted, and the application sees EIO while processes stay killable. The man page's warning is worth quoting exactly, because it is stronger than most people assume: a soft timeout "can cause silent data corruption in certain cases", so use it "only when client responsiveness is more important than data integrity". A read-only media mount is that case. A write path is not. Do not reach for intr to soften a hard mount either. The man page says it is ignored after kernel 2.6.25, so it is a comment in your fstab and nothing more.
softreval sits between the two. It lets the client keep serving paths and attributes from cache after revalidation attempts have timed out, so the directory tree stays browsable while the data behind it is unreachable.
SSHFS with reconnect retries the SSH connection on its own. An rclone mount with --vfs-cache-mode full keeps serving files already in its cache and queues writes for later upload, while reads that miss the cache fail.
Whatever you pick, answer one question in writing: when the storage box reboots for a kernel update, does the application recover on its own, or does a human have to log in? Test it by rebooting the storage box on purpose, before this pair carries anything you care about.
The failure you will hit first: writing into an empty mountpoint
This one costs people real data, and it does not care which protocol you chose.
/mnt/media is a directory. When the mount is active it shows the storage box's files. When the mount is not active it is an empty directory on your small local disk. The application cannot tell the difference. It writes into /mnt/media, the writes succeed, and the data lands on the application box's root filesystem. You find out when the root disk fills and services start dying. It gets worse on recovery: mounting again hides the misplaced files underneath the mount, so the disk stays full while du /mnt/media shows nothing to delete.
Make the empty directory refuse writes. Unmount it, then set the immutable attribute:
sudo umount /mnt/media
sudo chattr +i /mnt/media
lsattr -d /mnt/medialsattr -d prints the flags, and the i appears while nothing is mounted over the path. Now a write to the unmounted path fails with Operation not permitted, so the application logs an error instead of quietly filling your disk. Mounting still works, because mounting does not modify the underlying directory. Remove it later with chattr -i if you need to. This works on ext4 and XFS, which covers the root filesystem of a normal VPS image.
Next, make services depend on the mount. Add a systemd drop-in:
sudo systemctl edit jellyfin[Unit]
RequiresMountsFor=/mnt/mediaBe honest about what this buys. RequiresMountsFor= orders the service after the mount unit for that path and pulls it in. With x-systemd.automount, systemd only has to install the trigger, so the service can still start while the storage box is unreachable. The dependency makes the failure visible in systemctl status. The immutable directory is what stops the damage.
Docker is its own trap, because the daemon creates a missing bind source for you. In Compose, turn that off:
services:
jellyfin:
volumes:
- type: bind
source: /mnt/media
target: /media
read_only: true
bind:
create_host_path: falseThe Compose documentation describes create_host_path as creating a directory at the source path on the host if nothing is present, defaulting to true. Setting it false stops Compose from inventing the path. It does not help when the path exists and is empty, which is the common case here, so keep the immutable directory as well.
Finally, guard every script that writes to the mount:
mountpoint -q /mnt/media || { echo "/mnt/media is not mounted" >&2; exit 1; }Which box is the source of truth?
Answer this per dataset, in writing, before the first sync job runs.
The usual arrangement gives each box a different role. The application box owns the small mutable state: database, configuration, keys. The storage box owns the bulk: media, originals, archives. Neither box is the source of truth for everything, so backups run in both directions, and running one of them backwards is easy.
The dangerous direction is an rsync --delete push from the application box to the storage box. If the mount is down, the source directory reads as empty, and --delete faithfully removes the destination. That is the terabytes gone in the time it takes rsync to walk a list.
Two habits prevent it. Guard every sync with the mountpoint -q check above. And prefer a pull over a push: run the job on the box that holds the copy, so it reaches out and fetches. A pull job also survives the application box being compromised, because the credentials that can delete backups do not live on the machine an attacker reached first.
Use a tool that stores snapshots rather than a mirror. restic and borg keep history, so a file deleted or encrypted on the source is still recoverable from an earlier snapshot, while an rsync mirror copies the deletion perfectly. If the storage box is the copy rather than the original, set it up as a real offsite backup target with append-only access, so the source can add snapshots but cannot remove them.
There is one direction people forget. If the storage box holds the only copy of your photo library, the storage box needs a backup of its own. A restore has to cover both halves of the pair, taken close enough together that they agree with each other. A database snapshot from Tuesday plus a photo library from Thursday restores into a broken installation, with database rows pointing at files that are not there yet.
What to measure on your own pair
Do not trust a published throughput or latency figure for this design, including any you read elsewhere. The result depends on your provider, your region and the two plans you picked. Measure your own pair. Every command below is one you run yourself, from the application box unless noted.
Round trip over the tunnel, which multiplies every metadata operation:
ping -c 100 10.9.0.2Raw link throughput, before any filesystem is involved. Run the server half on the storage box:
iperf3 -siperf3 -c 10.9.0.2 -t 30If this is far below what your plan advertises, the problem is the network or the tunnel, and no mount option will fix it. Check MTU before blaming NFS.
Large sequential read through the mount, to compare against that number:
fio --name=seqread --rw=read --bs=1M --size=2G --numjobs=1 --direct=1 --filename=/mnt/media/fio-test --end_fsync=1
rm -f /mnt/media/fio-testOn a FUSE mount, drop --direct=1 if fio reports that the flag is not supported, since O_DIRECT handling varies by driver. A large gap between this figure and the iperf3 figure points at the mount rather than the link.
Metadata cost, which is the number that actually decides the design:
time find /mnt/media -type f | wc -lRun it twice. The first pass is cold and the second shows what the attribute cache buys you. Then run the same find on the storage box locally and compare. The ratio between those two timings is the tax this architecture charges your application, measured on your hardware instead of assumed.
If you chose NFS, look at per-operation latency directly:
nfsiostat 5 3
nfsstat -cnfsiostat ships with nfs-common and reports operations per second with an average round trip time per operation for each mount. nfsstat -c breaks the client's calls down by operation type. If getattr calls dominate the counts, your workload is metadata bound: raise acdirmin and acdirmax, remount, and measure the same find again.
Then run the test people skip. Reboot the storage box while the application is serving traffic, and watch what happens. That is the only way to learn whether your failure behaviour is what you configured or what you assumed.
FAQ
Can I put a database on a storage VPS mount?
No. Database engines depend on fsync() reaching real storage and on file locking working across processes, and neither is reliable over NFS, SSHFS or an rclone mount. A stalled link does not pause a database politely: it hangs the process or corrupts the file. Keep the data directory of SQLite, PostgreSQL or MariaDB on the local disk of the application box. The mount holds bulk files that are written once and read whole.
NFS, SSHFS or rclone: which one should I use?
Use NFS over a WireGuard tunnel when the far side is a filesystem in the same region, because the kernel client caches attributes and because hard, soft and softreval let you choose the failure behaviour. Use SSHFS when you want no server-side setup and can accept a round trip for every metadata operation. Use rclone when the far side is object storage rather than a filesystem, where its VFS cache and a long --dir-cache-time are doing real work.
What happens to my application if the storage VPS goes offline?
With a default hard NFS mount, processes touching the mount block in uninterruptible sleep, kill -9 does not reach them, and the kernel logs nfs: server 10.9.0.2 not responding, still trying. They resume with no lost writes when the link returns. With soft, calls fail with an I/O error instead, at the documented risk of silent data corruption on writes. SSHFS with reconnect retries by itself, and an rclone mount keeps serving whatever is already in its VFS cache.
Why did my root disk fill up when the mount was down?
Because an unmounted mountpoint is still a writable empty directory on the local disk, so the application wrote into it and the data landed on the root filesystem. Mounting again hides those files underneath the mount, so the space stays used while the path looks empty. Unmount, delete what is underneath, then run sudo chattr +i /mnt/media on the empty directory so future writes fail with Operation not permitted instead of succeeding in the wrong place.
Do both servers really need to be in the same region?
Yes, for this design. Every metadata operation costs at least one round trip, and a library scan makes one per file, so a cross-region round trip multiplies the slowest part of the workload by a hundred or more. Measure with ping -c 100 over the tunnel before you migrate data. If the average is not a small fraction of a millisecond, keep the application next to its data instead.