All writing

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.0192.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.conf block, make the user, print the connection instructions). The educational parts are the config above; the rest is argument-parsing plumbing. Download manage_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:

  1. Map UIDs at the server (NFSv4 idmapd, or all_squash with anonuid/anongid to force everything to one identity). Good when you can’t touch the clients.
  2. 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 fruit settings — 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.