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.
services: postgres: image: postgres:16 volumes: - /srv/containers/postgres/data:/var/lib/postgresql/data environment: POSTGRES_PASSWORD: supersecretThat’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:
- Stateful workloads with fsync requirements (databases, message queues)
- Single-host stacks where everything runs on one machine
- Situations where you want predictable, auditable data paths (
/srv/containers/<name>/datais obvious) - Performance — local NVMe will always beat GbE NFS for random small writes
Where bind mounts get awkward:
- Multi-host setups where two containers on different machines need the same file
- When you want Docker’s volume lifecycle management (volumes survive container/compose down cleanly)
- Migrating a container to another host means
rsync-ing the data directory first
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:
exportfs -rashowmount -e localhostThe options matter more than people realize:
sync— writes are committed to disk before ACKing to the client. Slower but safe.asyncis faster and risky; if the server crashes between the ACK and the disk write, data is gone.no_subtree_check— disables subtree checking, which causes stale filehandle errors when files are renamed. Just always use this.root_squash— maps root on the client tonobodyon the server. Good for shared exports.no_root_squashgives containers root-level access to the export, needed when container processes run as UID 0. Know what you’re enabling.
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.
# Force NFSv4 mount testmount -t nfs4 -o rw,hard,intr 192.168.1.50:/exports/media /mnt/testNFS Volumes in Docker Compose
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:
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.
# Send incremental snapshot to standby hostzfs 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/postgresSyncthing 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.
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:
| Operation | Local NVMe | NFS (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:
| Workload | Bind Mount | NFS |
|---|---|---|
| Relational database (Postgres, MariaDB, MySQL) | Yes | No |
| SQLite database file | Yes | No |
| Message queue (RabbitMQ, Redis) | Yes | No |
| Media files (video, images, audio) | Single host only | Yes |
| Config files shared across replicas | Syncthing/ZFS | Yes (read-only) |
| User uploads / object-like storage | Single host | Yes |
| Logs and metrics | Either | Either |
| Git repos served by Gitea/Forgejo | Yes | No (SQLite) |
| Nextcloud data directory | Syncthing or ZFS | Yes (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 0The _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.