Skip to content

syncthing-tailnet-declarative

Modules

A NixOS module that runs Syncthing as a discovery-free mesh pinned to a tailnet (Tailscale / any WireGuard VPN), and sets the Web GUI password declaratively — without the password ever entering the Nix store.

The problem

Syncthing's defaults assume the open internet: it announces itself to global discovery servers, punches NAT, and falls back to public relays so any two devices can find each other from anywhere. If all your devices already share a private tailnet, you don't want any of that — it's attack surface and it leaks metadata to third parties.

There's also a smaller, sharper annoyance: there is no NixOS option for the GUI password. Syncthing stores it bcrypt-hashed in config.xml, generated at runtime. You can't declare the hash sensibly, and you certainly don't want the plaintext sitting world-readable in /nix/store.

What this module does

1. Closes the mesh. Global announce, relays and NAT traversal are all turned off; local (LAN) announce stays on. Every peer is pinned to a fixed tailnet address (tcp://<tailnet-ip>:22000). Discovery never leaves the tailnet.

2. Orders the daemon after the tailnet. Those pinned peer addresses don't exist until the VPN is up, so syncthing and syncthing-init are ordered after (and wants) the tailnet unit — tailscaled.service by default, configurable for wg-quick, etc.

3. Sets the GUI password declaratively. A oneshot service reads the plaintext password from a secret file (agenix, sops-nix, …) and PUTs it through Syncthing's own REST API (/rest/config/gui). Syncthing hashes it on receipt. The password lives only in your secrets manager and in RAM at activation time — never in the store.

The trap this encodes

The REST API needs an API key, and Syncthing only generates that key on its own first run — it isn't there when the module is activated. So the oneshot polls config.xml for up to 60 seconds (via xmllint --xpath) until the key appears, and only then calls the API. Skip the poll and the password step races Syncthing's first boot and fails intermittently. The curl calls additionally --retry in case the HTTP listener isn't accepting yet.

Usage

{
  imports = [ ./syncthing-tailnet-declarative ];

  services.syncthingTailnet = {
    enable = true;

    # Bind the GUI to loopback (or a tailnet IP). Never expose it publicly —
    # there's a brief window at first boot before the password is set.
    guiAddress = "127.0.0.1";
    guiPort = 8384;

    # Plaintext password file from your secrets manager. Must be readable by
    # the syncthing user. Omit to leave the GUI open (loopback only!).
    guiPasswordFile = config.age.secrets.syncthing-gui-password.path;

    # Whatever brings the tailnet up. Default is tailscaled.service.
    # orderAfterUnits = [ "wg-quick-wg0.service" ];

    # Peers, pinned to their tailnet addresses.
    devices = {
      laptop = {
        id = "AAAAAAA-BBBBBBB-CCCCCCC-DDDDDDD-EEEEEEE-FFFFFFF-GGGGGGG-HHHHHHH";
        addresses = [ "tcp://100.100.100.10:22000" ];
      };
      phone = {
        id = "IIIIIII-JJJJJJJ-KKKKKKK-LLLLLLL-MMMMMMM-NNNNNNN-OOOOOOO-PPPPPPP";
        addresses = [ "tcp://100.100.100.20:22000" ];
      };
    };

    folders = {
      "shared" = {
        path = "/var/lib/syncthing/Shared";
        devices = [ "laptop" "phone" ];
      };
    };
  };
}

Get a device's ID with syncthing --device-id on that device (or from its GUI).

Options

Option Default Purpose
enable false Turn the module on.
user / group "syncthing" Identity the daemon runs as.
uid null Pin a fixed UID (handy for shared/persisted data dirs across a fleet); overrides the upstream default of ids.uids.syncthing. Null keeps that default.
dataDir /var/lib/syncthing Synced data / default folder location.
configDir ${dataDir}/.config/syncthing Holds config.xml, keys, DB.
guiAddress 127.0.0.1 GUI/REST bind address. Keep it private.
guiPort 8384 GUI/REST port (not opened in the firewall).
guiUser "syncthing" Username set alongside the password.
guiPasswordFile null Path to a plaintext-password secret file. Null = no password.
listenPort 22000 Peer sync TCP/UDP port.
orderAfterUnits [ "tailscaled.service" ] Units that must be up first.
openFirewall true Open the sync port + local-discovery UDP 21027 (never the GUI port).
devices {} Peers with pinned tailnet addresses.
folders {} Folders to sync.

Caveats

  • Keep the GUI on loopback or a tailnet address. Between Syncthing's first boot and the password oneshot completing, the GUI is briefly unauthenticated. The module deliberately does not open the GUI port in the firewall.
  • Local announce stays on. If you want a strictly addressed mesh with zero broadcast, set localAnnounceEnabled = false too (it's left on here so devices on the same LAN still connect fast).
  • overrideDevices / overrideFolders are true. Nix is the source of truth; devices and folders you add through the GUI are reverted on the next rebuild.
  • Requires a secrets manager that can drop a plaintext password file readable by the syncthing user. Any of agenix, sops-nix, or a manually-provisioned file works — the module only cares about the path.

Source

modules/syncthing-tailnet-declarative/default.nix
# syncthing-tailnet-declarative
#
# Run Syncthing as a discovery-free mesh pinned to a Tailscale (or any WireGuard)
# tailnet, and set the Web GUI password declaratively — without the password ever
# landing in the Nix store.
#
# Two ideas are bundled here:
#
#   1. A "closed mesh": global announce, relays and NAT traversal are all OFF, and
#      every peer is pinned to its fixed tailnet address. Discovery never leaves the
#      tailnet. Because those addresses only exist once the VPN is up, the daemon is
#      ordered `after` the VPN's service.
#
#   2. Declarative GUI password via the REST API. Syncthing has no Nix option for the
#      hashed GUI password, and you do not want the plaintext in the store. A oneshot
#      reads the password from a file (e.g. an agenix/sops secret) and PUTs it through
#      Syncthing's own REST API. The API key it needs is only generated on Syncthing's
#      first run, so the oneshot polls `config.xml` until the key appears.
#
# This module is provider-agnostic: point `orderAfterUnits` at whatever brings your
# tailnet up (default: tailscaled), and put your peers' pinned addresses in `devices`.

{
  config,
  lib,
  pkgs,
  ...
}:
with lib;
let
  cfg = config.services.syncthingTailnet;
in
{
  options.services.syncthingTailnet = {
    enable = mkEnableOption "Syncthing pinned to a tailnet with a declarative GUI password";

    user = mkOption {
      type = types.str;
      default = "syncthing";
      description = "User the Syncthing daemon runs as.";
    };

    group = mkOption {
      type = types.str;
      default = "syncthing";
      description = "Primary group for the Syncthing user.";
    };

    uid = mkOption {
      type = types.nullOr types.int;
      default = null;
      description = ''
        Optional fixed UID for the Syncthing user. Pin this when you want the same
        numeric owner across a fleet (e.g. for shared/persisted data dirs). Leave
        null to let NixOS allocate one.
      '';
    };

    dataDir = mkOption {
      type = types.str;
      default = "/var/lib/syncthing";
      description = "Directory Syncthing stores its synced data and default folder in.";
    };

    configDir = mkOption {
      type = types.str;
      default = "${cfg.dataDir}/.config/syncthing";
      defaultText = literalExpression ''"''${cfg.dataDir}/.config/syncthing"'';
      description = "Directory holding Syncthing's config.xml, keys and database.";
    };

    guiAddress = mkOption {
      type = types.str;
      default = "127.0.0.1";
      description = ''
        Address the Web GUI / REST API binds to. Keep this loopback (or a tailnet
        address) — the password-setting oneshot talks to it, and the GUI has no
        password during the brief window before that oneshot runs.
      '';
    };

    guiPort = mkOption {
      type = types.port;
      default = 8384;
      description = "TCP port for the Web GUI / REST API.";
    };

    guiUser = mkOption {
      type = types.str;
      default = "syncthing";
      description = "Username set on the Web GUI alongside the password.";
    };

    guiPasswordFile = mkOption {
      type = types.nullOr types.path;
      default = null;
      example = "/run/agenix/syncthing-gui-password";
      description = ''
        Path to a file containing the *plaintext* GUI password, readable by
        `user`. Wire this to your secrets manager (agenix, sops-nix, …) so the
        password never enters the Nix store. When null, no password is set and the
        GUI is left open on `guiAddress` — only acceptable on a loopback bind.
      '';
    };

    listenPort = mkOption {
      type = types.port;
      default = 22000;
      description = "TCP/UDP port Syncthing uses for peer sync traffic.";
    };

    orderAfterUnits = mkOption {
      type = types.listOf types.str;
      default = [ "tailscaled.service" ];
      example = [ "wg-quick-wg0.service" ];
      description = ''
        Units that must be up before Syncthing starts, because the pinned peer
        addresses only exist once the tailnet/VPN is established. Applied as both
        `after` and `wants` on the syncthing and syncthing-init services.
      '';
    };

    openFirewall = mkOption {
      type = types.bool;
      default = true;
      description = ''
        Open the sync port (TCP+UDP) and the local-discovery UDP port (21027) in the
        firewall. The GUI port is *not* opened — reach it over the tailnet or an SSH
        tunnel.
      '';
    };

    devices = mkOption {
      type = types.attrsOf (types.submodule { freeformType = types.attrs; });
      default = { };
      example = literalExpression ''
        {
          laptop = {
            id = "AAAAAAA-BBBBBBB-CCCCCCC-DDDDDDD-EEEEEEE-FFFFFFF-GGGGGGG-HHHHHHH";
            addresses = [ "tcp://100.100.100.10:22000" ];
          };
          phone = {
            id = "IIIIIII-JJJJJJJ-KKKKKKK-LLLLLLL-MMMMMMM-NNNNNNN-OOOOOOO-PPPPPPP";
            addresses = [ "tcp://100.100.100.20:22000" ];
          };
        }
      '';
      description = ''
        Peer devices, each with its Syncthing device `id` and a list of *pinned*
        tailnet `addresses` (e.g. `tcp://<tailnet-ip>:22000`). Pinning the address
        is what keeps discovery off the public internet.
      '';
    };

    folders = mkOption {
      type = types.attrs;
      default = { };
      description = "Folders to sync, in `services.syncthing.settings.folders` form.";
    };
  };

  config = mkIf cfg.enable {
    users.users.${cfg.user} = {
      group = cfg.group;
      isSystemUser = true;
      # When `user` is the default "syncthing", the upstream services.syncthing
      # module already pins uid = config.ids.uids.syncthing (237) at normal
      # priority, so a plain assignment here would conflict. mkForce lets an
      # explicit `uid` win over that default; null leaves upstream's uid in place.
      uid = mkIf (cfg.uid != null) (mkForce cfg.uid);
    };
    users.groups.${cfg.group} = { };

    systemd.tmpfiles.rules = [
      "d ${cfg.dataDir}   0750 ${cfg.user} ${cfg.group} - -"
      "d ${cfg.configDir} 0700 ${cfg.user} ${cfg.group} - -"
    ];

    networking.firewall = mkIf cfg.openFirewall {
      allowedTCPPorts = [ cfg.listenPort ];
      allowedUDPPorts = [
        cfg.listenPort
        21027 # local discovery broadcast
      ];
    };

    # The pinned peer addresses only resolve once the tailnet is up, so gate the
    # daemon (and its init helper) on whatever brings the tailnet online.
    systemd.services.syncthing = {
      after = cfg.orderAfterUnits;
      wants = cfg.orderAfterUnits;
    };
    systemd.services.syncthing-init = {
      after = cfg.orderAfterUnits;
      wants = cfg.orderAfterUnits;
    };

    # Declarative GUI password. Syncthing exposes no Nix option for the hashed
    # password and we refuse to bake plaintext into the store, so we set it through
    # the REST API. The catch: the API key is generated by Syncthing on its own
    # first run, so we must poll config.xml until it exists before we can call the API.
    systemd.services.syncthing-set-password = mkIf (cfg.guiPasswordFile != null) {
      description = "Set Syncthing GUI password from a secret file";
      requisite = [ "syncthing.service" ];
      after = [ "syncthing-init.service" ];
      wantedBy = [ "multi-user.target" ];

      path = [
        pkgs.curl
        pkgs.libxml2
        pkgs.jq
      ];

      script = ''
        set -efu
        umask 0077

        # Wait for Syncthing's first run to write config.xml (with the API key).
        attempts=0
        while ! xmllint \
            --xpath 'string(configuration/gui/apikey)' \
            ${escapeShellArg cfg.configDir}/config.xml \
            > "$RUNTIME_DIRECTORY/api_key" 2>/dev/null; do
          attempts=$((attempts + 1))
          if [ "$attempts" -ge 60 ]; then
            echo "Timeout waiting for Syncthing config.xml after 60s"
            exit 1
          fi
          sleep 1
        done
        API_KEY=$(cat "$RUNTIME_DIRECTORY/api_key")

        PASSWORD=$(cat ${escapeShellArg (toString cfg.guiPasswordFile)})

        # Read the current GUI config, splice in user+password, PUT it back.
        # Syncthing hashes the plaintext password on receipt.
        # Note: the GUI endpoint is loopback http by default, so no TLS occurs.
        # We do NOT pass curl -k/--insecure: it would be a latent footgun if the
        # endpoint were ever pointed at a TLS address (it would accept forged certs
        # and leak the API key + password). If you ever front this with real TLS,
        # keep verification on and trust a proper CA.
        CURRENT=$(curl -sSL \
          -H "X-API-Key: $API_KEY" \
          -H "Content-Type: application/json" \
          --retry 30 --retry-delay 1 --retry-all-errors \
          http://${cfg.guiAddress}:${toString cfg.guiPort}/rest/config/gui)

        # Keep the plaintext password out of process argv (world-readable via
        # /proc/<pid>/cmdline): pass it to jq through the environment (env.pw, from
        # /proc/<pid>/environ, mode 0400) and hand the JSON body to curl on stdin.
        UPDATED=$(echo "$CURRENT" | pw="$PASSWORD" jq \
          --arg guiuser ${escapeShellArg cfg.guiUser} \
          '.password = env.pw | .user = $guiuser')

        printf '%s' "$UPDATED" | curl -sSL \
          -H "X-API-Key: $API_KEY" \
          -H "Content-Type: application/json" \
          -X PUT --data-binary @- \
          --retry 30 --retry-delay 1 --retry-all-errors \
          http://${cfg.guiAddress}:${toString cfg.guiPort}/rest/config/gui
      '';

      serviceConfig = {
        Type = "oneshot";
        RemainAfterExit = true;
        RuntimeDirectory = "syncthing-set-password";
        User = cfg.user;
      };
    };

    services.syncthing = {
      enable = true;
      inherit (cfg) user configDir dataDir;
      group = cfg.group;
      openDefaultPorts = false;
      relay.enable = false;
      overrideDevices = true;
      overrideFolders = true;
      guiAddress = "${cfg.guiAddress}:${toString cfg.guiPort}";
      settings = {
        options = {
          urAccepted = -1; # opt out of usage reporting
          listenAddress = [ "tcp://0.0.0.0:${toString cfg.listenPort}" ];
          globalAnnounceEnabled = false; # no public discovery server
          localAnnounceEnabled = true; # LAN broadcast only
          natEnabled = false; # no UPnP/NAT-PMP
          relaysEnabled = false; # no relay servers
        };
        gui = {
          user = cfg.guiUser;
        };
        inherit (cfg) devices folders;
      };
    };
  };
}