persistent-sshfs: Retries Your Mounts Until They Stick, Then Fucks Off

Rally the troops and sharpen your digital pitchforks, because SSHFS mounts are fragile little shits. At boot the network isn’t up yet, the VPN takes its sweet fucking time, the box on the other end is halfway through a reboot, and plain sshfs tries exactly once, fails, and leaves you staring at an empty directory while whatever was supposed to write into it happily writes onto your local disk instead. persistent-sshfs is the bash script I wrote so I’d stop babysitting that bullshit by hand. Let me be straight about what it is, though: it’s not some guardian angel that watches your mounts until the heat death of the universe. It hammers at every mount until it’s up, and then it fucks off. Keeping them up after that is a job you hand to something else, and I’ll show you what.

The Essence of persistent-sshfs

You feed it a text file, one mount per line. For every line it creates the local directory if it’s missing and spawns a background worker. Each worker does two things, in this order:

  1. Waits for key-based SSH auth. It runs ssh -o BatchMode=yes -o PasswordAuthentication=no -p <port> user@host exit, and if that fails it logs an error, sleeps 10 seconds and tries again. Forever. There is no password fallback, so a broken key doesn’t prompt you for shit, it just sits there retrying until you fix your keys.
  2. Mounts. If mount already lists the directory as fuse.sshfs, the worker is done. Otherwise it runs sshfs -o reconnect -o port=<port> user@host:/remote/dir /local/dir, and on failure it sleeps 10 seconds and goes again until it works.

The moment its mount is up, a worker quits. The main script waits on all of them and exits 0 when the last one is done. That’s the whole thing. No polling loop, no health checks, no resident daemon. The only way it runs forever is a worker that never finishes: a host that never accepts your key keeps it stuck in step one, and an sshfs that keeps failing (wrong remote path, say) keeps it stuck in step two.

What keeps a mount alive after the script is gone is sshfs itself: -o reconnect tells sshfs to reconnect on its own when the SSH connection drops, so a short network blip doesn’t leave you with a dead mount. And if the script is still running (some host still hasn’t come up) when it gets SIGINT or SIGTERM, it kills its workers and fusermount -us every mount from the file that’s currently up. Kill it with -9 and that cleanup never happens.

How to Unleash the Beast

Three commands. Grab it, make it executable, dump it on your PATH. You need sshfs installed and SSH key auth that works without anybody typing anything, because the script runs ssh with BatchMode=yes. That means a key with no passphrase, or an agent the process can actually reach. Your desktop session’s ssh-agent usually isn’t reachable from cron or supervisor, so don’t be surprised when it hangs there.

wget https://raw.githubusercontent.com/psyb0t/persistent-sshfs/master/persistent-sshfs
chmod +x persistent-sshfs
sudo mv persistent-sshfs /usr/local/bin/

The Ritual of Invocation

The mounts file is one line per mount, colon-separated, exactly four fields: local_dir:user@host:port:remote_dir. No YAML, no JSON, no comments, no blank lines. Then you point the script at it:

% cat mounts.txt
/home/splashipula/caraspi-storage:storage@jmekserver:2222:/home/storage/mounts
/home/splashipula/work-mothership:caras@mothership:22:/home/splashipula/work
% persistent-sshfs mounts.txt

It takes exactly one argument, the path to that file, and bitches with a usage line if you give it anything else. The only other knob is LOG_LEVEL: DEBUG, INFO (the default) or ERROR.

Making It Actually Persistent

Here’s the part the name lies about. The script doesn’t keep anything up by itself, so you re-run it. A fresh run skips whatever is already mounted and brings up whatever isn’t, and that re-run is your remount.

I run my stuff under supervisor with configs from supervisor-config-gen, and the obvious setup is a trap: a run.sh that calls the script once, plus autorestart=true. Looks fine. It’s fucked. When every mount is already up, a run is one quick SSH probe per mount and it exits, usually in well under a second. Supervisor’s default startsecs=1 treats any process that dies before one second as a failed start, retries it startretries times (3 by default), then marks it FATAL and never touches it again. On the runs that do last longer than a second, autorestart=true restarts the thing the instant it exits 0, so you’re SSHing into every box in a tight loop. Setting startsecs=0 doesn’t save you either, it just swaps the FATAL for that same tight loop.

So run.sh gets its own loop with a sleep in it. Now the process supervisor watches never exits, startsecs stops mattering, and the runs happen one after another, so a host that stays down can’t stack up a pile of stuck copies:

% cat run.sh
#!/usr/bin/env bash
while true; do
    persistent-sshfs mounts.txt
    sleep 60
done
% cat persistent-sshfs-supervisor.conf
[program:persistent-sshfs]
command=/home/splashipula/supervisor/apps/persistent-sshfs/run.sh
directory=/home/splashipula/supervisor/apps/persistent-sshfs
process_name=%(program_name)s
numprocs=1
user=splashipula
stopsignal=TERM
stopwaitsecs=10
stopasgroup=true
killasgroup=true
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/home/splashipula/logs/supervisord/%(program_name)s-out.log
stdout_logfile_maxbytes=50MB
stdout_logfile_backups=10

The relative mounts.txt works because of directory=. One thing to know about stopping it: stopasgroup=true sends the TERM to the whole group, so if a pass happens to be running at that moment, the script’s cleanup unmounts everything in the file. If it’s in the sleep, the mounts stay where they are.

No supervisor? Cron does the same job, but wrap it in flock. Without it, a host that stays down leaves one run stuck in the auth loop and cron starts another one every minute on top of it:

* * * * * flock -n /tmp/persistent-sshfs.lock /usr/local/bin/persistent-sshfs /home/you/mounts.txt >> /home/you/persistent-sshfs.log 2>&1

On systemd, the repo’s setup notes have a Type=oneshot service plus a timer that re-runs it every minute. Don’t get clever with Type=simple and Restart=on-failure: the script exits 0 when everything is mounted, so on-failure never fires and you get exactly one pass.

And one hole none of this plugs. “Already mounted” means mount lists the directory as fuse.sshfs. If the sshfs process itself dies, the kernel keeps that entry around and every access gives you “Transport endpoint is not connected”. The script sees the entry, calls it mounted and skips it. Nothing comes back until you fusermount -u the corpse by hand, and then the next pass mounts it fresh.

Why Join the persistent-sshfs Uprising?

Because plain sshfs at boot is a coin toss, and “SSH in and remount it” is not a fucking workflow. Every mount gets its own worker, so one dead box doesn’t hold the others hostage. All your mounts live in one dumb text file instead of a pile of fstab entries full of _netdev and IdentityFile incantations. And it refuses passwords outright, so nothing ever sits there waiting for you to type one into a terminal nobody is looking at.

What It Won’t Do

  • Watch your mounts. It gets them up and exits. Staying up is on sshfs -o reconnect and whatever re-runs the script.
  • Talk passwords. Keys or nothing. A bad key means it retries every 10 seconds forever, not that it asks you nicely.
  • Clean up a dead sshfs process. A mount whose sshfs died still shows up as mounted, so it gets skipped until you unmount it yourself.
  • Mount anything that isn’t SSHFS. fuse.sshfs is hardcoded into the check. NFS and SMB can go fuck themselves somewhere else.

Join the Revolution

It’s one bash script. It retries your SSHFS mounts until they’re up and gets out of the way, and you bolt a loop, a cron line or a timer on top to make “persistent” true. Grab it at https://github.com/psyb0t/persistent-sshfs, or don’t, and keep typing sshfs by hand every morning like a caveman.

Installing It Into Your Agent

A bash script that retries mounts does not obviously need an agent skill. It has one anyway. Everything under .agents/ is catalogued in one marketplace, so it is two commands:

claude plugin marketplace add psyb0t/agents
claude plugin install persistent-sshfs@psyb0t

Codex uses the same marketplace with a different verb, codex plugin add persistent-sshfs@psyb0t, because there is no codex plugin install. It also finds the skill on its own in a checkout of the repo, since it scans .agents/skills/ natively with nothing installed at all.