---
title: "NFS Over Tailscale - or any VPN"
date: 2025-10-26T22:20:32-05:00
draft: false
---

## The Problem
For my home media consumption I use a [media server](https://jellyfin.org/)
that runs in a small VM with passthru of a GPU for transcoding needs. The files
are hosted on [my local NAS](https://www.truenas.com/) box, which handles running
services like [Pinchflat](https://github.com/kieraneglin/pinchflat) and also
holds the digitized copies from my wife's CD collection and my home photo
and family video collection. The NAS exports each of these 3 collections as
an NFS share that everyone on the LAN can mount, including the media server VM.

The media server and VM both are connected to my [Tailnet](https://tailscale.com/)
in order to facilitate easy interoperation with my phone, my Kubernetes cluster,
and other pieces of my personal infrastructure. Although the VM and NAS are on
the same actual physical LAN and could directly connect over IP address, for
the sake of continuity I keep all of my infrastructure communicating over the
VPN.

This led to a problem: the VPN sits in the startup chain later than the mounts
are being attached. While the system is smart enough to reserve NFS automounts
for after reaching the `networking-online.target`, it was unaware that my
particular needs include waiting for Tailscale to also be started.

## The Solution

So this is where I had to step outside of `/etc/fstab` and instead create a
systemd mount file instead. `/etc/fstab` allows you to specify as an option
a dependency on another mount using the option `x-systemd.requires=foo`, but
I was unable to convince that option to allow me to specify a `foo.service`.
It only seems to want to accept `foo` and assumes that its type is a `.mount`
file. But, by crafting an actual systemd unit file for the mount directly, I
could control this behavior.

In [NixOS](https://nixos.org) I configured it as follows:
```nix
systemd.mounts = let
  nfs = name: {
    what = "nas.my-domain.ts.net:/mnt/path/to/share/${name}";
    type = "nfs";
    name = "${name}.mount";
    where = "/${name}";
    requires = [ "tailscaled-autoconnect.service" ];
    after = [ "tailscaled-autoconnect.service" ];
    wantedBy = [ "multi-user.target" ];
    mountConfig.Options = "_netdev,noexec,ro,timeo=50,retrans=5,soft";
  };
in [
  (nfs "photos")
  (nfs "video")
  (nfs "music")
];
```
The options here are pretty well mapped directly to the systemd unit file
options that you might expect.

## The Details

However, just for completeness's sake I'll show
you one of the full systemd unit files it generated in case you are not on
a NixOS system.

```
[Unit]
After=tailscaled-autoconnect.service
Requires=tailscaled-autoconnect.service

[Mount]
Options=_netdev,noexec,ro,timeo=50,retrans=5,soft
Type=nfs
What=nas1.private-redacted.ts.net:/mnt/all/video
Where=/video


[Install]
WantedBy=multi-user.target
```

Of course, in order for this to work for you, you will need a `tailscaled-autoconnect.service`
to wait on. It is not sufficient just to wait for the normal `tailscaled.service` unit
file that is normally present. That only starts the daemon, but the daemon can sometimes
take a bit to connect. That's where NixOS module maintainers come to the rescue by
giving me this very helpful tailscaled-autoconnect service that I can latch on to. It has a
`Type=notify` configuration and just runs a shell script, which I will replicate here:

```bash
set -o errexit
set -o nounset
set -o pipefail

getState() {
  tailscale status --json --peers=false | jq -r '.BackendState'
}

lastState=""
while state="$(getState)"; do
  if [[ "$state" != "$lastState" ]]; then
    # https://github.com/tailscale/tailscale/blob/v1.72.1/ipn/backend.go#L24-L32
    case "$state" in
      NeedsLogin)
        echo "Server needs authentication, sending auth key"
        tailscale up --auth-key "$(cat /run/agenix/tailscale-key)?preauthorized=true" --hostname jellyfin
        ;;
      Running)
        echo "Tailscale is running"
        systemd-notify --ready
        exit 0
        ;;
      *)
        echo "Waiting for Tailscale State = Running or systemd timeout"
        ;;
    esac
  fi
  echo "State = $state"
  lastState="$state"
  sleep .5
done
```

Quickly looking through this script it is very straightforward. It loops every 0.5 seconds, calling
the `tailscale up` command when the system needs to be logged in and passing it my Tailscale OAuth key.
Once the system is up and Running, meaning that the connection to Tailscale is completed, it will
notify systemd that the service has come up and then exit gracefully.

Obviously, tweak these scripts and needs to your own use. In NixOS, most of them are already present
and all you need is to start your mount after `tailscaled-autoconnect.service`. A similar technique
could easily be adapted to other flavors of Linux and to other VPN providers.
