DEV Community

prodbymarcu
prodbymarcu

Posted on

I replaced NFS with a FUSE filesystem on my homelab, and macOS ate three working days

I run an always-on homelab, a Mac mini, a Dell server on the LAN, a gaming PC, a UPS on every box. Files move between all three constantly, so the hot tier of the Dell used to be a Nextcloud mount over NFS. It worked, it was just slow, a 1 GiB copy from the Mac topped out around 33 MB/s write and 66 read.

I wanted faster, and I wanted it gone from Nextcloud's DB, so I put JuiceFS on the Dell instead. A Docker compose stack with a redis container for metadata, a one-shot format, and a privileged FUSE mount container exporting /srv/services/dellfs/mnt to the host. No S3 layer, my first attempt reached for Minio as the storage backend and that was the wrong turn, minio/minio is gone from every public registry, the hub repo is deleted and quay.io 401s. JuiceFS has a native --storage file mode, and a server with a real disk behind it wants that mode. Don't reach for a bucket when you have a filesystem.

The numbers after the swap, same Mac, same 1 GiB dd:

path write read
Mac -> DellFS SMB, LAN :445 116 MB/s 97 MB/s cold
Mac -> DellFS SMB, Tailscale 36 MB/s 61 MB/s
Dell native FUSE 225 MB/s 618 MB/s

Three times the NFS ceiling over the wire I already used. The Tailscale leg is transport-limited, which is the correct outcome, away from the house the network is the bottleneck and the filesystem stops mattering.

The failures, in order

  1. The JuiceFS Hub tags are not what they look like. juicedata/juicefs:* on Docker Hub are Docker plugin manifests, they fail to unpack as images. The image you actually want is juicedata/mount, and its Cmd is [bash], so a compose exec-style command silently replaces the shell. I had to write it as:
command: [bash, -c, "exec juicefs mount ..."]
Enter fullscreen mode Exit fullscreen mode
  1. Samba served the share read-only by default. read only = yes is the share default, and the symptom is NT_STATUS_ACCESS_DENIED plus "rejected by share access mask" in the log. Two minutes of testparm pinned it, my config had only [global] in it, because the servercontainers/samba SAMBA_VOLUME_CONFIG_* value has to literally contain the [sharename]; ... string or smbd gets a headerless blob.

  2. macOS refused every authentication shape I tried first. mount_smbfs accepts exactly one form, //WORKGROUP;user:pass@host/share. The user%pass form, the no-domain form, and the password-file form all fail auth, quoting permutations stop mattering once you read the man page, the parser is strict.

  3. A root-mounted SMB share shows up as root:wheel 0700 on the Mac, even when I mounted it as root via su from a daemon. A root-owned 0700 directory in /Volumes is invisible to the GUI session, so Finder can't see a thing. The fix is a split, a root launchd daemon that recreates and chowns the mount point every 15 seconds, plus a user-agent that runs mount_smbfs as the real session user every 30 seconds. The daemon loops instead of running once, diskarbitrationd deletes /Volumes/DellFS outright after an unmount, so the mount point itself disappears and a one-shot daemon has nothing left to remount.

  4. Deleting a file over SMB threw "Operation not supported." JuiceFS plus streams_xattr plus SMB unlink is a bad combination. Deletion works from Finder and from the server side of the mount, the mount itself throws. I left that gap documented and moved on.

  5. The retirement nearly cost me the stack. Unmounting the old NFS tier left two references alive, the compose file still bind-mounted /Volumes/DellHot, and the health script still tried to mount it. The bind mount made the OrbStack VM kernel spam krpc ENOENT for five days at about 100% CPU, and the health script's Errno 13 killed every health cycle. About 36 hours of a wedged homelab. I found the leftovers by grepping the retired path across the repo, the error never pointed there, and retiring an external mount means cleaning the compose bind, the health-script mount logic, and the occ files_external entry in the same change.

What I'd do differently

Benchmark before you pick the backend. The native-file-storage form of JuiceFS was 3x faster than NFS over the same wire, and I was reaching for an S3-shaped stack because that's the shape most writeups lead with.

Test the mount as the session user first. Every macOS automounter bug I hit that week was a who owns the mount bug in disguise, and the fastest way to find that class of bug is to run the mount exactly the way the consumer runs it.

The stack runs headless now, redis for metadata, file storage on /data, FUSE mount propagated to the host, one host smbd share on :445 feeding the Mac and the Windows box, two launchd units plus one Windows scheduled task keeping the drive letters alive through reboots. Rollback is intact, the old NFS export and its contents are still on disk.

Hardware: Mac mini M4, Dell server on a LAN, OrbStack VM for the containers, everything on Tailscale. Benchmarks are dd of 1 GiB, cold cache, LAN, October 2026.

Top comments (1)

Collapse
 
dhruonweb profile image
Dhruv Malaviya •

"Don't reach for a bucket when you have a filesystem" is the right instinct, and it's the mistake most object-storage-adjacent tooling nudges you into by default.

The Tailscale number is the honest one to publish. 36 MB/s over a tunnel next to 225 native looks bad and is exactly correct , measuring transport and filesystem separately is what makes the rest of the table believable.

Worth flagging that MinIO's registry disappearance is now turning up in several unrelated write-ups this month. Whatever the reason, the lesson is that a public image isn't a dependency you control, and a compose file pinned to minio/minio has a failure mode with nothing to do with your config.

The Cmd: [bash] trap deserves emphasis , a command that silently replaces rather than fails is the worst kind, because the container comes up healthy doing the wrong thing.