Skip to content
Go back

Bind Mounts vs NFS for Container Storage

By SumGuy 11 min read
Bind Mounts vs NFS for Container Storage

Your Container Just Ate the Database

It’s 2 AM. Your Postgres container restarted after a failed healthcheck. Now it won’t come back up because it can’t fsync — and you’re staring at logs that say could not write to file "pg_wal/00000001000000000000001". The NFS mount timed out mid-write. The WAL is corrupt.

Congratulations, you chose network storage for a stateful workload that really, really did not want network storage.

This isn’t a horror story to scare you off NFS forever. NFS is legitimately useful in a homelab — but the decision of “bind mount or NFS volume?” is one of the most common wrong turns people make when composing a multi-host stack. Let’s fix that.


What We’re Actually Talking About

Before the configs, let’s be clear on what each option actually means when you’re running Docker or Podman.

Bind mounts are a direct path mapping from the host filesystem into the container. The container sees /data, the host has /srv/containers/myapp/data, they’re the same inode tree. Zero abstraction. Fast as your disk. If the container dies, the data stays. If the host dies, the data stays (on that host).

NFS volumes are mounts of a remote filesystem, usually exposed by another machine or NAS, using the Network File System protocol. Docker can consume these through the local volume driver with NFS options, or through third-party drivers like netshare. The container thinks it’s reading a local directory; underneath, every read and write crosses the network.

The hybrid case — bind mounts backed by a replicated or shared local filesystem (ZFS send, Syncthing, DRBD) — is a third path worth discussing because it avoids several NFS failure modes while still giving you shared state.


Bind Mounts: The Default, and Honestly Fine

Bind mounts are the boring choice. They’re also often the right choice.

docker-compose.yml
services:
postgres:
image: postgres:16
volumes:
- /srv/containers/postgres/data:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: supersecret

That’s it. No volume driver config, no NFS exports, no showmount -e troubleshooting sessions. The data lives on the local disk. Postgres can fsync at full disk speed. If you’re on an SSD or NVMe, writes are measured in microseconds, not milliseconds.

Where bind mounts win:

Where bind mounts get awkward:

The solution to the “data is stuck on one host” problem is often not NFS — it’s rethinking the architecture. Does container B actually need container A’s files in real-time? Or does it need a database row, an API response, or a message from a queue? Most of the time, a database or object store is the right shared-state primitive, not a shared filesystem.


NFS Volumes: The Power Move That Bites Back

NFS makes sense when you have a NAS (TrueNAS, Synology, plain Linux with an ext4 export) and you want multiple hosts or containers to share the same files. Media servers, config files shared across replicas, user upload storage — these are legit use cases.

Setting Up an NFS Export

On the NFS server (/etc/exports):

/exports/media 192.168.1.0/24(rw,sync,no_subtree_check,no_root_squash)
/exports/config 192.168.1.0/24(ro,sync,no_subtree_check,root_squash)

Apply it:

Terminal window
exportfs -ra
showmount -e localhost

The options matter more than people realize:

NFSv4 vs NFSv3

NFSv4 is stateful (the server tracks client state), supports ACLs properly, uses a single TCP port (2049), and handles network interruptions better. NFSv3 is stateless, uses multiple ports (making firewall rules annoying), but is simpler and has broader compatibility with older NAS firmware.

For a homelab in 2025+, use NFSv4 unless your NAS won’t speak it.

Terminal window
# Force NFSv4 mount test
mount -t nfs4 -o rw,hard,intr 192.168.1.50:/exports/media /mnt/test

NFS Volumes in Docker Compose

docker-compose.yml
services:
jellyfin:
image: jellyfin/jellyfin:latest
volumes:
- media:/media
- config:/config
volumes:
media:
driver: local
driver_opts:
type: nfs
o: addr=192.168.1.50,rw,nfsvers=4,hard,intr,timeo=600,retrans=3
device: ":/exports/media"
config:
driver: local
driver_opts:
type: nfs
o: addr=192.168.1.50,rw,nfsvers=4,hard,intr
device: ":/exports/config"

The hard option means the NFS client will retry indefinitely if the server is unreachable, blocking the process. soft mounts will time out and return an error, which sounds nicer but causes data corruption on writes because the caller thinks the write succeeded when it didn’t. Use hard for anything with write workloads. Use soft only for read-only exports where timeouts are acceptable.


The SQLite-over-NFS Problem (Don’t Do This)

Here’s the thing that trips people up constantly. SQLite uses file locking for concurrency control. NFS’s file locking implementation is… historically troubled. Even with NFSv4 and proper lockd setup, SQLite’s documentation explicitly says:

Do not use SQLite over NFS if more than one process will write to the database.

Practically, this means: if your app uses SQLite (Gitea, Vaultwarden’s default, Photoprism’s index, many others) and you put the database file on NFS, you will eventually get corruption. Maybe not today. Maybe not this month. But it will happen, and the error will be baffling.

The fix: use a named Docker volume (local bind mount) for the SQLite file, and NFS only for the blob storage (images, attachments, uploaded files). Separate the database from the media:

docker-compose.yml
services:
vaultwarden:
image: vaultwarden/server:latest
volumes:
- vw_data:/data # SQLite DB — local, fast
- attachments:/data/attachments # blobs — NFS fine here
volumes:
vw_data:
driver: local # stays on the local host disk
attachments:
driver: local
driver_opts:
type: nfs
o: addr=192.168.1.50,rw,nfsvers=4,hard
device: ":/exports/vaultwarden/attachments"

Postgres on NFS: A War Story

You will find forum posts where people successfully run Postgres on NFS. Those people either got lucky, are using enterprise NFS with pNFS and all the right tuning, or haven’t had their server crash mid-checkpoint yet.

Here’s what happens: Postgres relies on fsync() to guarantee that WAL records hit stable storage before acknowledging a transaction commit. NFS’s fsync() behavior depends on the server’s export options and the client’s mount options. With async exports, fsync() doesn’t actually flush to disk — it returns success as soon as the server’s buffer cache has it. Server crashes = committed transactions vanish = database corruption.

Even with sync exports, NFS adds latency to every fsync, which tanks checkpoint performance. Postgres with local NVMe: checkpoint completes in seconds. Postgres with GbE NFS: same checkpoint takes minutes, during which the database is partially paused.

Honestly? If you want shared Postgres storage across hosts, use streaming replication (primary + standby) with local storage on each. That’s the right tool. NFS is not a substitute for database replication.


The Hybrid Path: Bind Mounts + Replication

There’s a middle ground that gets underused: keep fast local bind mounts but replicate the data between hosts using a tool that understands filesystems.

ZFS send/receive is the gold standard for this. You snapshot the dataset, pipe it to the receiving host, and now both hosts have identical data. It’s not real-time sync — it’s scheduled replication — but for most homelab use cases (daily backup, failover standby), that’s fine.

Terminal window
# Send incremental snapshot to standby host
zfs snapshot tank/containers/postgres@$(date +%Y%m%d)
zfs send -i tank/containers/postgres@yesterday tank/containers/postgres@$(date +%Y%m%d) \
| ssh standby-host zfs recv tank/containers/postgres

Syncthing works well for cases where you want near-real-time file sync between hosts for non-database files — config directories, uploaded media, static files. It’s not suitable for actively-written database files (same locking caveats as NFS), but it’s excellent for things like shared Nextcloud data or Immich upload directories.

docker-compose.yml
services:
syncthing:
image: syncthing/syncthing:latest
volumes:
- /srv/containers/syncthing/config:/var/syncthing
- /srv/containers/shared/media:/sync/media
ports:
- "22000:22000/tcp"
- "22000:22000/udp"
- "8384:8384"

The pattern is: run Syncthing on both hosts, both pointing at /srv/containers/shared/media, and let it handle the sync. Each host uses a bind mount to its local copy. No NFS. No file locking hell.


Performance Reality Check

Let’s be concrete about numbers. On a typical homelab with GbE networking:

OperationLocal NVMeNFS (GbE, sync)NFS (GbE, async)
Sequential read 1 GB~2 GB/s~100 MB/s~110 MB/s
Sequential write 1 GB~1.5 GB/s~90 MB/s~100 MB/s
Random 4K write IOPS~200K~1K~8K
fsync latency~100 µs~2–10 ms~0.1 ms*

*async fsync latency is fake — the data isn’t actually on disk.

For media streaming (Jellyfin, Plex), sequential read performance is what matters. GbE NFS at ~100 MB/s is more than enough for 4K remux playback (~80 Mbps peak = ~10 MB/s). NFS is fine here.

For databases, it’s the random 4K write IOPS and fsync latency that kill you. A 200x difference in IOPS and 20-100x worse fsync latency under sync mounting is not recoverable by tuning.


The Decision Matrix

Here’s the honest breakdown:

WorkloadBind MountNFS
Relational database (Postgres, MariaDB, MySQL)YesNo
SQLite database fileYesNo
Message queue (RabbitMQ, Redis)YesNo
Media files (video, images, audio)Single host onlyYes
Config files shared across replicasSyncthing/ZFSYes (read-only)
User uploads / object-like storageSingle hostYes
Logs and metricsEitherEither
Git repos served by Gitea/ForgejoYesNo (SQLite)
Nextcloud data directorySyncthing or ZFSYes (with caveats)

The Nextcloud caveat: Nextcloud’s data directory can live on NFS, but the database cannot. Split them.


Quick Reference: Mount Options Worth Memorizing

For your /etc/exports:

# Safe default for read-write exports
/exports/data 192.168.1.0/24(rw,sync,no_subtree_check,no_root_squash)
# Read-only export (configs, templates)
/exports/cfg 192.168.1.0/24(ro,sync,no_subtree_check,root_squash)

For your Docker Compose NFS volume:

driver_opts:
type: nfs
o: addr=<NAS_IP>,rw,nfsvers=4,hard,intr,timeo=600,retrans=3
device: ":/<export_path>"

For your /etc/fstab if you’re mounting directly on the host:

192.168.1.50:/exports/media /mnt/media nfs4 rw,hard,intr,timeo=600,retrans=3,_netdev 0 0

The _netdev option tells the init system to wait for network before mounting. Without it, a reboot without network access will hang at the mount step for a very long time.


The Bottom Line

Bind mounts are the default answer. They’re fast, simple, and they keep your data on metal you can physically touch. If your container is stateful and writes frequently — especially if it’s a database — bind mount it to local storage, full stop.

NFS earns its place when you genuinely need shared access to files across multiple hosts and the workload is read-heavy or sequential write-heavy (media, backups, uploads). Use NFSv4, use sync exports, use hard mounts, and for the love of all things holy, don’t put SQLite or Postgres WAL files on it.

When you want the best of both worlds — local speed with shared state — reach for ZFS send/receive for block-level replication or Syncthing for file-level sync. NFS is not the only way to share data between hosts; it’s just the most traditional one.

Your 2 AM self will thank you for making this decision sober, in daylight, with a clear head — not while staring at a corrupted WAL log wondering why the NAS rebooted for firmware updates during a checkpoint.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
eBPF for the Curious: Kernel Tracing Without the PhD
Next Post
NixOS First Impressions for Pragmatists

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts