A power cut, a reboot, and the storage box comes up a minute after the services that depend on it. The NFS mounts fail once and stay failed; Docker helpfully creates empty directories where the shares should be; the photo server refuses to start, the file server exits, and the media server scans an empty library and starts forgetting things. This is the four-part fix we run on our AI appliance, generalised so it drops onto any Docker host that mounts from a NAS.
The root problem is timing, and timing is never fixed by one change. Four ideas together make the host indifferent to when the NAS shows up:
Give the NAS a head start. If the storage server is a VM on the same hypervisor, tell the hypervisor to start it first and to wait before starting anything else. A NAS has no guest agent to say “ready”, so the wait is a fixed number of seconds — long enough for the array to come up.
Retry instead of failing once. A network mount in fstab
is attempted at boot and never again. Mark it nofail so a slow
NAS cannot degrade the boot, then let a minutely timer keep trying until it succeeds.
Never start on a bare mountpoint. The dangerous moment is not the failed mount, it is the container that starts anyway. A marker file that exists only on the NAS, bound into the container as a file Compose is forbidden to create, turns “the share is missing” into “the container will not start” — which is exactly right.
Say so when it happens. When the guard does remount, it restarts the consumers and posts one line to your notifier. A silent recovery is a bug you never learn about.
.immich marker into each of its folders and refuses to
start if one is missing — that refusal is the best thing that happened in our incident,
because it stopped the server from writing uploads onto the wrong disk. Never set
IMMICH_IGNORE_MOUNT_CHECK_ERRORS to make the message go away.
Fix the mount.photos, media and
files, mounted at /mnt/<share>
from a NAS at 192.168.1.10.startup order and,
with up=, waits that many seconds before moving to the next
one. Give the NAS VM a low order and a real delay; the Docker VM comes later.qm set 109 --onboot 1 --startup order=2,up=180
qm set 104 --onboot 1 --startup order=5
# see the whole boot sequence at a glance
for c in /etc/pve/qemu-server/*.conf; do
printf "%-10s %s\n" "$(basename $c)" "$(grep -E '^(name|onboot|startup):' $c | tr '\n' ' ')"
donenofail stops a missing share from degrading the
boot; x-systemd.mount-timeout stops it from delaying the boot
either. The retry lives in step 3, not here — fstab has no retry.192.168.1.10:/mnt/user/photos /mnt/photos nfs rw,soft,timeo=150,_netdev,nofail,x-systemd.mount-timeout=30 0 0
192.168.1.10:/mnt/user/media /mnt/media nfs rw,soft,timeo=150,_netdev,nofail,x-systemd.mount-timeout=30 0 0
192.168.1.10:/mnt/user/files /mnt/files nfs rw,soft,timeo=150,_netdev,nofail,x-systemd.mount-timeout=30 0 0soft with a bounded
timeo is the honest choice — a stalled request errors instead of
hanging the container forever. For a share that receives irreplaceable uploads with no
application-level check, prefer hard and accept that a dead NAS
will freeze the writer.#!/bin/bash
# Remount lost NAS shares; restart their consumers only when a remount actually
# happened; say so once. Runs as root, every minute.
changed=""
for m in photos media files; do
if ! mountpoint -q /mnt/$m; then
# An unmounted mountpoint may hold directories Docker auto-created while the
# NAS was away. They would be hidden under the mount later — clear them so the
# state stays honest and nothing ever lands on the local disk by mistake.
find /mnt/$m -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null
mount /mnt/$m 2>/dev/null && changed="$changed $m"
fi
done
if [ -n "$changed" ]; then
docker restart immich_server jellyfin >/dev/null 2>&1
docker start opencloud >/dev/null 2>&1
# swap for your notifier of choice
sudo -u duncan -H /home/duncan/scripts/tg-send.sh \
"🔁 nfs-guard remounted:$changed — consumers restarted (the NAS had been away)." \
>/dev/null 2>&1 || true
logger -t nfs-guard "remounted:$changed; consumers restarted"
fi
exit 0# nfs-guard.service
[Unit]
Description=Remount NAS shares and restart their consumers if they were lost
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/nfs-guard.sh
# nfs-guard.timer
[Unit]
Description=Minutely NFS mount guard
[Timer]
OnBootSec=30s
OnUnitActiveSec=1min
AccuracySec=10s
[Install]
WantedBy=timers.targetsudo chmod 755 /usr/local/sbin/nfs-guard.sh
sudo systemctl daemon-reload
sudo systemctl enable --now nfs-guard.timer
systemctl list-timers nfs-guard.timer --no-legendcreate_host_path: false. Compose refuses to invent the missing
file, so the container cannot start until the share is back — and the guard’s restart
brings it up the moment it is.for m in photos media files; do sudo touch /mnt/$m/.on-nas; doneservices:
jellyfin:
image: jellyfin/jellyfin:latest
container_name: jellyfin
volumes:
- ./config:/config
- ./cache:/cache
- /mnt/media:/media:ro
# the gate: a file that exists only on the NAS, and that Compose may not create
- type: bind
source: /mnt/media/.on-nas
target: /on-nas
read_only: true
bind:
create_host_path: false
restart: unless-stoppeddocker-compose.yml that
you should not edit; put the same block in a
docker-compose.override.yml under
immich-server: — Compose merges the volume lists.# exports appear only once the NAS array is up
showmount -e 192.168.1.10
# mounts, not directory listings — an empty local directory lists just fine
for m in photos media files; do mountpoint -q /mnt/$m && echo "/mnt/$m mounted" || echo "/mnt/$m MISSING"; done
# the gate is in place when every consumer shows the marker bind
for c in jellyfin opencloud immich_server; do
docker inspect -f '{{.Name}}: {{range .Mounts}}{{if eq .Destination "/on-nas"}}{{.Source}}{{end}}{{end}}' $c
done
# the real test: stop the NAS array, watch the consumers refuse, start it, watch the guard
journalctl -t nfs-guard -fmountpoint -q, or a marker file that only the
NAS can supply, is the test.