NFS Over Tailscale - or any VPN

The Problem

For my home media consumption I use a media server that runs in a small VM with passthru of a GPU for transcoding needs. The files are hosted on my local NAS box, which handles running services like 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 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 I configured it as follows:

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:

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.