Cross-Platform Home Storage: NFS, SMB, and the UID Trap
One pool of storage that Linux, macOS, and Windows can all share — NFS for the compute nodes, SMB for the laptops, and the UID-alignment trick that stops NFS permissions from ruining your week. Built for home ML clusters and home labs.
If you are building a machine-learning cluster at home — even a small one, a couple of GPU boxes and a laptop — the first wall you hit is not compute. It is storage. You want one place to keep datasets, checkpoints, and notebooks, and you want every machine to see it as if it were local: the Linux training nodes, the macOS laptop you actually live on, maybe a Windows box for good measure.
The dream is a single pool of disk that everyone can read and write. The reality, the first time you try it, is permission errors, files owned by nobody, and a Mac that silently forgets the mount every time it reboots. I have set this up enough times that the failures stopped looking like bad luck and started looking like a short list of the same three problems. This post is that short list, and the fixes.
The shape of the solution: a single Linux file server on ZFS, exporting the same datasets two ways. NFS for the Linux compute nodes — low overhead, native, the right tool when a training job is hammering a dataset. SMB (Samba) for the macOS and Windows clients — friendlier auth, plays nicely with Finder and Explorer, and doubles as a Time Machine target. Same bytes underneath, two doors in.
A word before we start: everything here uses the classic trust-based AUTH_SYS model for NFS,
which is fine on a trusted home LAN (ideally its own VLAN) and not fine on a hostile network.
If you need real authentication, that is Kerberos/LDAP territory and out of scope here.
Part 1 — The server: one ZFS dataset, two protocols
I run the storage on ZFS because snapshots and checksums are exactly what you want for data you care about. Each share is its own dataset, which keeps quotas and snapshots clean.
# Create a dataset for a share, tuned for mixed use.
zfs create -p \
-o mountpoint=/mnt/datasets \
-o compression=lz4 \
-o acltype=posixacl \
-o aclmode=passthrough \
-o aclinherit=passthrough \
-o xattr=sa \
-o primarycache=metadata \
tank/data/datasets
acltype=posixacl + aclmode=passthrough matter later — they let POSIX ACLs survive, which is
how you keep a shared directory writable by several people without chmod 777 everything.
NFS export (for the Linux nodes)
On the server, export the dataset to your LAN in /etc/exports:
# /etc/exports — export the dataset to the home subnet
/mnt/datasets 192.168.1.0/24(rw,sync,no_subtree_check,root_squash)
That 192.168.1.0/24 is a subnet in CIDR notation — not a path, and not a URL, even though
the slash makes it look like one. It means every address whose first 24 bits match
192.168.1. An IPv4 address is 32 bits; the number after the slash is how many of those bits
are the fixed network part, leaving the rest for hosts. So /24 pins the first three
octets (192.168.1.) and leaves the last 8 bits — 256 addresses — for machines: 192.168.1.0
through 192.168.1.255 (254 usable, since .0 is the network itself and .255 is broadcast).
It is the exact same thing as the dotted netmask 255.255.255.0. Fewer prefix bits means a
bigger network: a /16 (255.255.0.0) would cover all of 192.168.0.0–192.168.255.255. Put
your LAN’s subnet here — ip addr on Linux or ipconfig getifaddr en0 on macOS will tell you
what it is (commonly 192.168.0.0/24, 192.168.1.0/24, or 10.0.0.0/24).
Then reload:
sudo exportfs -ra
showmount -e localhost # sanity check: is the path exported?
root_squash maps a remote root down to an unprivileged user so a client root can’t run wild
on your pool. Keep it on. Remember the export is keyed to a subnet — this is trust by network
location, which is why the VLAN advice above matters.
SMB share (for macOS and Windows)
Samba is the cross-platform door. The trick that makes it pleasant on a Mac is the fruit VFS
module, which speaks Apple’s SMB extensions (resource forks, Finder metadata, Time Machine).
Set the Apple bits once in the [global] section of /etc/samba/smb.conf:
[global]
vfs objects = catia fruit streams_xattr
fruit:aapl = yes
fruit:nfs_aces = no
fruit:copyfile = yes
fruit:model = MacPro
Then one block per share:
[datasets]
comment = Shared datasets
path = /mnt/datasets
valid users = alice, bob
writable = yes
browsable = yes
vfs objects = catia fruit streams_xattr acl_xattr
fruit:time machine = yes
fruit:metadata = stream
fruit:posix_rename = yes
map acl inherit = yes
store dos attributes = yes
Samba users are separate from the share — each person needs a system account and a Samba password:
sudo useradd -m -s /bin/bash alice
sudo passwd alice # Linux login password
sudo smbpasswd -a alice # Samba (share) password — can differ
sudo smbpasswd -e alice
sudo systemctl restart smbd nmbd
Here is the thing worth internalizing: SMB authenticates by name and password, then maps you to a system user. It does not care what UID your client thinks you are. That single fact is why SMB “just works” across platforms while NFS, as we are about to see, does not.
I keep all of this in one management script (create dataset, write the
smb.confblock, make the user, print the connection instructions). The educational parts are the config above; the rest is argument-parsing plumbing. Downloadmanage_server.sh→
Part 2 — macOS as an NFS client (the part Apple makes annoying)
You can mount NFS by hand on a Mac, but two things make it fragile: macOS likes to use
non-reserved source ports, and it will delete an empty mount point under /Volumes
periodically, so your carefully-made directory vanishes and the mount dies. The durable fix is
autofs plus a tiny boot script that recreates the mount point.
Pick a mount point. The most reliable home is under /System/Volumes/Data/Volumes/. Add an
autofs map at /etc/auto_nfs:
/System/Volumes/Data/Volumes/Datasets -fstype=nfs,nolockd,noresvport,hard,bg,intr,rw,tcp nfs://nas.local:/mnt/datasets
The flags earn their place: noresvport is the one that fixes the mystery “permission denied”
from a Mac (it stops insisting on reserved source ports), nolockd avoids the NLM lock-manager
dance, hard,intr gives you a mount that waits out a server reboot but can still be interrupted.
Register the map in /etc/auto_master:
/- auto_nfs -nobrowse,nosuid
Because macOS will eat the mount point, add a LaunchDaemon that recreates it on boot. Drop this
at /Library/LaunchDaemons/com.automt.startup.plist (owned by root):
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.automt.startup.plist</string>
<key>LaunchOnlyOnce</key>
<true/>
<key>ProgramArguments</key>
<array>
<string>sh</string>
<string>-c</string>
<string>/usr/local/bin/run-nfs.sh</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
And the script it runs, /usr/local/bin/run-nfs.sh (root-owned, executable):
#!/usr/bin/env bash
SHAREDIR=/System/Volumes/Data/Volumes/Datasets
if [[ -e $SHAREDIR ]]; then
echo "Exists, likely mounted"
exit
else
mkdir "$SHAREDIR"
chmod 775 "$SHAREDIR"
automount -cv
echo "Done"
exit
fi
Reboot, and the Mac brings the NFS mount up on its own and keeps it up.
Part 3 — The UID trap (why NFS “permission denied” makes no sense)
This is the one that costs people an evening. Your Mac can see the files, the export is correct,
and you still get Permission denied — or worse, everything looks owned by some stranger.
The cause: NFS with AUTH_SYS trusts numeric UIDs and GIDs, not names. When your Mac writes
a file, it sends “user 501 did this.” The server writes it owned by its UID 501 — which may be
a completely different person, or nobody at all. Names never enter the conversation.
And the defaults are rigged against you. The first human user on macOS is UID 501. The first human user on most Linux distros is UID 1000. So your Mac account (501) and your Linux account (1000) are, as far as NFS is concerned, two different people who happen to share a name.
There are two honest fixes:
- Map UIDs at the server (NFSv4
idmapd, orall_squashwithanonuid/anongidto force everything to one identity). Good when you can’t touch the clients. - Align the UIDs so 501 means the same person everywhere. Since you can’t easily move macOS off 501, the move is to migrate the Linux account to 501. This is my preferred fix for a small home setup — once the numbers line up, NFS, local access, and backups all agree.
Aligning a live Linux user’s UID is fiddly (you can’t renumber an account that has running processes or is logged in), so it’s worth scripting. The shape: create a throwaway admin, log in as it, renumber the real user, then re-own that user’s files.
#!/bin/bash
# UID alignment: move a Linux user to UID/GID 501 to match macOS.
# Run as root. Two phases: `setup` (make a temp admin), then `migrate`.
# WARNING: destructive — it kills the user's processes, renumbers the
# account, and chowns their files. Back up / snapshot first.
TARGET_USER="youruser" # the account to renumber
NEW_UID=501
NEW_GID=501
TEMP_USER="tempadmin"
TEMP_PASS="$(openssl rand -base64 12)" # random throwaway; printed once below
if [ "$EUID" -ne 0 ]; then
echo "Run as root: sudo ./fix_uid.sh [setup|migrate]"; exit 1
fi
MODE=$1
if [ "$MODE" == "setup" ]; then
if ! id "$TEMP_USER" &>/dev/null; then
useradd -m -G sudo -s /bin/bash "$TEMP_USER"
echo "$TEMP_USER:$TEMP_PASS" | chpasswd
echo "Created '$TEMP_USER' with password: $TEMP_PASS"
fi
echo "Next: log out, switch to a TTY (Ctrl+Alt+F3), log in as '$TEMP_USER',"
echo "then run: sudo ./fix_uid.sh migrate"
exit 0
fi
if [ "$MODE" == "migrate" ]; then
CURRENT_USER=$(logname 2>/dev/null || echo "$SUDO_USER")
if [ "$CURRENT_USER" == "$TARGET_USER" ]; then
echo "You're still logged in as $TARGET_USER. Log in as $TEMP_USER first."; exit 1
fi
OLD_UID=$(id -u "$TARGET_USER")
[ "$OLD_UID" == "$NEW_UID" ] && { echo "Already $NEW_UID, nothing to do."; exit 0; }
pkill -u "$TARGET_USER"; sleep 2; pkill -9 -u "$TARGET_USER" || true # free the account
usermod -u "$NEW_UID" "$TARGET_USER"
if getent group "$NEW_GID" >/dev/null; then
usermod -g "$NEW_GID" "$TARGET_USER"
else
groupmod -g "$NEW_GID" "$TARGET_USER"
fi
chown -R "$NEW_UID":"$NEW_GID" /home/"$TARGET_USER"
# Re-own stray files elsewhere. -xdev stays on the root fs and, crucially,
# does NOT cross into mounted filesystems like your NFS pool.
find / -xdev -uid "$OLD_UID" -exec chown -h "$NEW_UID":"$NEW_GID" {} + 2>/dev/null || true
echo "Done. Reboot, log in as $TARGET_USER, verify with: id"
echo "Then remove the temp user: sudo userdel -r $TEMP_USER"
exit 0
fi
echo "Usage: sudo ./fix_uid.sh [setup|migrate]"
Download fix_uid.sh → — set TARGET_USER at the top first.
Two details that keep this safe. The -xdev flag on find is load-bearing: it stops the chown
from wandering across the NFS mount and rewriting ownership on the server’s files — you only
want to fix the local root filesystem. And the temp-admin two-step exists because you cannot
renumber an account you are currently logged into; doing it from a separate session is the only
clean way.
After the reboot, id on the Mac and id on the Linux box should print the same number, and
the “permission denied” evaporates because, for the first time, both machines agree on who you
are.
The cruft each OS leaves behind
Permissions are the loud failure. The quiet one is litter — every operating system wants to scribble its own bookkeeping onto your shared pool, and two kinds will confuse you the first time you see them.
NFS stubs (.nfs00000…)
Delete a file on an NFS mount while some process still has it open — or let an editor do an
atomic “save” (write a temp file, rename it over the original) — and you’ll find a file named
something like .nfs0000000001234567. You try to rm it and get Device or resource busy, or
it reappears the moment you delete it.
This is a feature, not corruption. POSIX promises that an open file keeps working even after it’s
unlinked. On a local disk the kernel just hides the inode until the last handle closes. NFS has
no such private place, so the client does a “silly rename”: it renames the file to .nfs…
to keep the open handle valid, and removes it for real once that handle closes.
So don’t fight them. Find who’s holding the file and the stub disappears on its own:
lsof | grep '.nfs' # which process is still holding it?
# ...then close that process (or just wait); the stub clears itself.
If you keep seeing them in a project directory, something is repeatedly deleting open files there — a file watcher, a build tool — which is a hint to point that tool somewhere local.
Apple’s .DS_Store and ._* files
macOS Finder drops a .DS_Store into every folder it so much as looks at (it stores icon
positions and view options). Harmless on your own Mac; on a shared server it quietly litters
every directory and sneaks into your git commits and tarballs.
The sneakier cousin is the AppleDouble file: ._<filename>. When macOS writes a file to a
filesystem that can’t store its extended attributes and resource forks natively — NFS, SMB,
FAT/exFAT — it splits that metadata into a sidecar ._ file. On APFS or HFS+ it tucks the same
data inline, which is why you only see ._* files on the network share, never on the local
disk. (A nice side effect of the fruit VFS module from the SMB section: it stores that metadata
in real xattr streams, so the litter mostly stays off the SMB view — it’s the bare NFS path where
you’ll see ._* pile up.)
These aren’t only cosmetic. They break tooling: a stray ._* sidecar appearing and vanishing
mid-scan is enough to crash a file watcher. Three ways to keep them in check:
# 1. Stop Finder writing .DS_Store to network shares (log out / back in after):
defaults write com.apple.desktopservices DSDontWriteNetworkStores -bool true
# 2. Strip existing Apple cruft from a tree:
dot_clean /path/to/share
find /path/to/share \( -name '._*' -o -name '.DS_Store' \) -delete
# 3. Keep all of it out of git:
printf '.DS_Store\n._*\n.nfs*\n' >> .gitignore
You can also make the server refuse them on the SMB side, so no client can leave them behind:
veto files = /._*/.DS_Store/.Trashes/
delete veto files = yes
The theme, one more time: cross-platform storage isn’t only about who can read what. Every OS treats your pool as a place to stash its own metadata. A calm shared server is one where you’ve decided, on purpose, where that metadata is allowed to live — and where it isn’t.
The payoff
Once the UIDs line up and both protocols point at the same ZFS datasets, the home cluster finally behaves like one machine:
- Linux GPU nodes mount the datasets over NFS and read training data at wire speed.
- Your laptop browses the same files over SMB from Finder, no UID drama.
- Time Machine backs up to the same server via the
fruitsettings —Cmd+K,smb://nas.local/datasets, and on macOS:
sudo tmutil setdestination "smb://alice@nas.local/datasets"
None of this is exotic, but the pieces are scattered across a dozen man pages and the failure modes are silent. The mental model that makes it click: NFS trusts numbers, SMB trusts names. Get the numbers to agree and the whole thing stops fighting you.
Get the scripts
Both helpers, sanitized — open each and set the placeholders at the top before you run anything:
fix_uid.sh— the UID-alignment migration from Part 3.manage_server.sh— create/destroy ZFS datasets and Samba shares, manage users, and wire up Time Machine.
All paths, usernames, and addresses above are placeholders — swap in your own. And keep this on
a trusted network segment; AUTH_SYS is convenience, not security.