Hobby projects → Batocera on NFS
Guide · tested August 2026 on Batocera v43

Batocera ROMs on a NAS over NFS

Your Batocera box has a disk that will eventually fill up. Your NAS has room. This guide moves whichever systems you choose onto a network share and symlinks them back into place, so Batocera never knows the difference — and covers the two things that reliably go wrong: file permissions that silently break saves, and a boot hook that changed between versions.

Why symlinks rather than moving everything

Batocera can put its entire /userdata on a network share, but that's an all-or-nothing decision that puts your configuration, saves and scraped artwork behind the network too. Symlinking individual system folders is the more flexible approach: the big systems that eat hundreds of gigabytes live on the NAS, while small, latency-sensitive ones stay on local disk, and the box still boots and runs if the NAS is off.

The rule of thumb: disc-based systems go on the NAS — PS2, GameCube, Wii, Dreamcast, PSX, PS3 — because they're huge and stream large sequential reads that a gigabit network handles comfortably. Cartridge-era systems stay local — NES, SNES, Mega Drive, Game Boy, arcade — because they're small enough not to matter and they load thousands of tiny files when scraping artwork, which is exactly the workload networks are worst at.

Before you start

Throughout this guide the NAS is 192.168.1.10, the Batocera box is 192.168.1.20, the exported directory is /srv/batocera, and it gets mounted on Batocera at /media/nas. Substitute your own — but keep the mount point somewhere under /media, and read the note about that in step 6 before you pick anything clever.

Step 1 — Create and export the share

On a plain Linux NAS, create the directory and add an export line.

On the NAS
mkdir -p /srv/batocera
echo "/srv/batocera 192.168.1.20(rw,sync,no_subtree_check,no_root_squash,insecure)" >> /etc/exports
exportfs -ra
exportfs -v

no_root_squash is the option that matters. Batocera runs everything as root. By default NFS maps a remote root user to nobody — so ROMs will read fine and writes will fail. If you later move saves onto the share, they'll silently stop saving. Read-only ROM folders survive squashing; anything writable does not.

On a NAS with a web interface, find the equivalent setting rather than editing files by hand — Unraid calls it the NFS rule under Share Settings, Synology has a "Squash" dropdown that must be set to No mapping, TrueNAS has a "Maproot User" field to set to root.

Don't trust the interface — verify. NAS web UIs add and rewrite export options in ways that aren't visible on the settings page, and two shares configured identically can end up with different options in force. Only exportfs -v on the NAS shows what the kernel is actually enforcing. Check it now, and check it again after any future change to the share's settings.

Step 2 — Mount it by hand and prove it works

Never automate a mount you haven't performed manually first.

On Batocera
mkdir -p /media/nas
mount -t nfs -o rw,soft,timeo=150,retrans=2 192.168.1.10:/srv/batocera /media/nas
mount | grep /media/nas

You should get a line back showing the mount and its negotiated options. If the mount is refused with a protocol or access error, add nfsvers=3 to the options — some NAS builds export v3 only, and others place v4 exports under a pseudo-root that changes the path.

About those options. soft means a stalled request eventually returns an error instead of blocking forever; on a games machine that's what you want, because a hard mount will freeze the interface indefinitely if the NAS goes away. timeo is in tenths of a second, so timeo=150 is 15 seconds — with retrans=2, a failing request gives up after about 30.

Step 3 — Confirm root can actually write

This takes ten seconds and catches the single most common misconfiguration.

On Batocera
touch /media/nas/.writetest && echo WRITE-OK || echo WRITE-FAILED
stat -c '%U:%G %a' /media/nas/.writetest
rm /media/nas/.writetest
A successful touch does not prove you aren't being squashed. If the parent directory is world-writable the write succeeds anyway — and then every file your emulators create is owned by nobody, which breaks in confusing ways later. The stat output must say root:root. If it says nobody, go back to step 1; no_root_squash isn't in force.

Step 4 — Copy your ROMs to the share

Copy, verify, and only then delete the originals.

On Batocera
rsync -aH --info=progress2 /userdata/roms/ps2/ /media/nas/ps2/
du -sh /userdata/roms/ps2 /media/nas/ps2

The trailing slashes matter — /ps2/ on both sides copies the contents into the target rather than nesting a folder inside it. The du comparison should show matching totals.

For a large library, run the copy under nohup so it survives the SSH session dropping. Batocera ships no tmux or screen.

On Batocera — for a long-running copy
nohup rsync -aH --partial --info=progress2 /userdata/roms/ps2/ /media/nas/ps2/ > /userdata/romcopy.log 2>&1 &

tail -c 300 /userdata/romcopy.log
pgrep -a rsync || echo FINISHED

Step 5 — Replace the folders with symlinks

Rename rather than delete, so you can reverse this in one command.

On Batocera
for SYS in ps2 gamecube wii dreamcast psx; do
  [ -d "/media/nas/$SYS" ] || continue
  mv "/userdata/roms/$SYS" "/userdata/roms/$SYS.local-backup"
  ln -s "/media/nas/$SYS" "/userdata/roms/$SYS"
done

ls -la /userdata/roms | grep media/nas

Each system's folder is now a symlink into the share. EmulationStation follows them without knowing or caring. Once you've booted and confirmed the games are all present, delete the .local-backup folders to reclaim the space.

Check for symlinks whose target doesn't resolve — a folder that failed to copy shows up here rather than as a mysteriously empty system in the interface:

On Batocera
for d in /userdata/roms/*; do [ -L "$d" ] && [ ! -e "$d" ] && echo "BROKEN: $d"; done

Optional — saves and emulator data

The same trick works for /userdata/saves, which is handy if you play the same games on more than one machine. It also raises the stakes considerably: saves are written constantly, so this only makes sense once step 3 passed cleanly and the boot mount in step 6 is reliable.

On Batocera
mkdir -p /media/nas/saves
rsync -aH /userdata/saves/ /media/nas/saves/
mv /userdata/saves /userdata/saves.local-backup
ln -s /media/nas/saves /userdata/saves
The failure mode to understand before you do this. If the share isn't mounted at boot, /media/nas still exists as an empty directory, so the symlink resolves to a valid-looking empty path. Games then write saves into local storage, and you end up with two diverging sets. Some standalone emulators — RPCS3 and Dolphin among them — go further and regenerate a blank configuration on top. This is why the boot script in the next step retries rather than attempting the mount once.

Step 6 — Make it mount at every boot

The part most guides get wrong, because Batocera changed it.

Batocera's root filesystem is a read-only squashfs image with a temporary overlay on top. Anything written outside /userdata and /boot is discarded at reboot, so /etc/fstab is not an option — it won't survive. Batocera provides its own hooks instead, and which one you use depends on your version.

HookRunsUse for a network mount?
/boot/boot-custom.sh
v32+
First thing after init.d startsNo — the network stack isn't up yet
/boot/postshare.sh
v35+
After /userdata mounts, before the splash video and EmulationStationYes — this is the right one
/userdata/system/custom.sh
deprecated since v38
After EmulationStation has already launchedNo — deprecated, and too late anyway
/userdata/system/services/<name>
v43+ recommended
From S99userservices, after EmulationStation launchesFallback only — see below
If you found an older guide telling you to use custom.sh, that is very likely why your share isn't mounting. It has been deprecated since v38, and on v43 we found it simply never ran — the file sits there, correct and executable, and is never invoked. There is no error message. The services system replaced it, but services start after EmulationStation has scanned your ROM folders, so a share mounted there arrives too late and every networked system shows up empty until you restart the interface. postshare.sh runs before the scan, which is what you actually want.

/boot is mounted read-only, so it has to be remounted writable first — and set back afterwards.

On Batocera
mount -o remount,rw /boot

cat > /boot/postshare.sh <<'EOF'
#!/bin/sh
MNT=/media/nas
SRV=192.168.1.10:/srv/batocera

mkdir -p "$MNT"
n=0
while [ $n -lt 15 ]; do
  grep -q " $MNT nfs" /proc/mounts && exit 0
  mount -t nfs -o rw,soft,timeo=150,retrans=2 "$SRV" "$MNT" 2>/dev/null && exit 0
  n=$((n+1))
  sleep 2
done

mkdir -p /userdata/system/logs
echo "$(date) NAS mount FAILED" >> /userdata/system/logs/nas-mount.log
EOF

mount -o remount,ro /boot

Why the script is written that way

Line endings. If you write these scripts on Windows, save them with Unix (LF) line endings. A file with CRLF endings will not run, and again, will not tell you why. Editing over SSH as shown above avoids the problem entirely.

Fallback — the services method

If postshare.sh doesn't fire on your version, use a service instead. The filename must have no extension and contain only letters, digits and underscores. Because services start after EmulationStation, add an interface restart at the end or accept restarting it by hand once after boot.

On Batocera
mkdir -p /userdata/system/services

cat > /userdata/system/services/nasmount <<'EOF'
#!/bin/bash
MNT=/media/nas
SRV=192.168.1.10:/srv/batocera

case "$1" in
  start)
    mkdir -p "$MNT"
    grep -q " $MNT nfs" /proc/mounts || \
      mount -t nfs -o rw,soft,timeo=150,retrans=2 "$SRV" "$MNT"
    ;;
  stop)
    umount -l "$MNT" 2>/dev/null
    ;;
esac
EOF

chmod +x /userdata/system/services/nasmount
batocera-services enable nasmount
batocera-services list user

Step 7 — Reboot and verify

Check from the command line before you touch the interface.

On Batocera, after rebooting
mount | grep /media/nas
cat /userdata/system/logs/nas-mount.log 2>/dev/null
ls /userdata/roms/ps2 | head

A mount line, no log file, and a listing of games is the clean result. If the log file exists, the script ran and the mount failed — check the NAS is awake and that the Batocera box still has the IP address in your export rule.

Then let EmulationStation load and confirm the networked systems show their games. A system that appears empty despite the folder listing correctly is a stale gamelist cache rather than a mount problem — update the game list for that system from the interface.

Troubleshooting

SymptomLikely cause
Share missing after every reboot, mounts fine by handWrong boot hook for your version — you're almost certainly using custom.sh. Move to postshare.sh.
Mount point directory doesn't exist after rebootNormal. /media is a temporary filesystem; the boot script must recreate it.
Games load, saves silently don't persistRoot squashing. Verify with exportfs -v on the NAS and the stat test in step 3.
Boot hangs at a black screen after adding the scriptAn uncapped retry loop in postshare.sh. It isn't backgrounded — the boot waits for it.
Script never runs and produces no errorWindows CRLF line endings, or the wrong filename. Both fail silently.
mount.nfs: access denied by serverThe export rule doesn't match the box's current IP, or your NAS needs nfsvers=3.
Interface freezes while scrolling a networked systemUsually artwork loading over the network. If it persists, check dmesg on the Batocera box — storage faults on local USB drives present identically and are easy to misattribute to the share.
Everything works until the NAS reboots, then hangsYou're on a hard mount. Use soft with a bounded timeo.

Backing out

Nothing here is one-way.

On Batocera
SYS=ps2
rm /userdata/roms/$SYS
mv /userdata/roms/$SYS.local-backup /userdata/roms/$SYS

Remove /boot/postshare.sh the same way you created it — remount /boot writable, delete, remount read-only. The ROMs on the NAS are untouched by any of this.

Reference

Tested end to end in August 2026 against Batocera v43 with an NFSv4.2 share. Version-specific behaviour is called out where we hit it; if a step behaves differently on your build, the hook timing table above is the first place to look.