Skip to content

Syncthing over Tailscale ​

Syncthing finds its peers through public discovery servers and, when a direct connection fails, forwards your traffic through a volunteer relay. A tailnet replaces both: every device joins one private WireGuard mesh, dials a stable address only your own devices can reach, and needs no port opened on the router. This page brings three devices — two desktops and a phone — onto a tailnet and then pins Syncthing to it, with global discovery and relaying off.

Facts on this page were checked on 2026-09-08 against Tailscale 1.102.3 and the tailscale.com docs.

Prerequisites ​

  • Syncthing installed on every device, with the devices already paired (or ready to be — pairing works the same either way).
  • Admin access to a Tailscale account. The free Personal plan is enough: 6 users, unlimited devices, MagicDNS, and Tailnet Lock are all included.
  • Sign-up supports passkeys, so no Google, Apple, or GitHub identity is required.
  • Nothing else. No port forward, no dynamic-DNS name, no VPS.

Install the client ​

Tailscale recommends the Standalone build on macOS: it carries more features than the App Store build, is not bound by App Store distribution limits, and leaves the timing of updates to you. The App Store build is the fallback for a machine where Standalone cannot be installed, and Homebrew's tailscaled is for headless, unattended machines.

PlatformPackageSource
macOSStandalone buildtailscale.com/download/mac
WindowsMSI installertailscale.com/download/windows
LinuxDistro packagetailscale.com/download/linux
AndroidAppPlay Store or F-Droid

Bring each desktop up ​

sh
# The Standalone installer symlinks the CLI into /usr/local/bin; the binary
# itself lives inside the app bundle.
ls -l /usr/local/bin/tailscale
/Applications/Tailscale.app/Contents/MacOS/Tailscale version

# Sign in and join the tailnet.
tailscale up
tailscale ip -4
powershell
# After the MSI has installed, sign in and join the tailnet.
tailscale up
tailscale ip -4
sh
sudo tailscale up
tailscale ip -4

tailscale ip -4 prints this device's tailnet address — a 100.x.y.z address from the CGNAT range. Write down all three; the Syncthing step needs them.

Android ​

The Android client has no CLI. Install the app, sign in with the same account, and read the device's tailnet address from the app's main screen or from the admin console's machine list.

Sign in and lock the tailnet ​

Tailnet Lock stops the coordination server from introducing a device you did not sign for. Without it, a compromised control plane can add a peer that your devices will trust; with it, every new node needs a signature from a key that never leaves your own machines.

Enable it while only the two desktops exist, then enrol the phone and sign it from a desktop:

sh
# Run on each desktop first: it prints that device's own tailnet-lock key.
tailscale lock status

# Then, on one desktop, initialise the lock with both desktops as signing
# nodes. It prints the disablement secret — capture it now, see below.
tailscale lock init tlpub:<mac-key> tlpub:<pc-key>

# Confirm; the JSON output reports Enabled.
tailscale lock status --json

# After the phone joins it waits for a signature. `tailscale lock status`
# lists the pending node key; sign it from a desktop.
tailscale lock sign <node-key>

Android cannot sign

A phone can never be a Tailnet Lock signing node. Enable the lock with the two desktops as signing nodes first — if the phone is the only device left that could sign, the tailnet is stuck.

Store the disablement secret offline

tailscale lock init prints a disablement secret. It is the only way to turn the lock off if every signing node is lost. Put it somewhere outside the tailnet and outside the synced tree — an encrypted vault or paper — and never in zolder.toml, a note file, or a chat message.

Turn client telemetry off ​

Tailscale clients send diagnostic logs to log.tailscale.com by default. Opting out is per platform and not a tailscale set flag — each client reads it at startup, so every change below needs a client restart.

PlatformHow to opt out
WindowsAdd TS_NO_LOGS_NO_SUPPORT=true to %ProgramData%\Tailscale\tailscaled-env.txt, then restart the Tailscale service.
LinuxRun tailscaled --no-logs-no-support, or set the same TS_NO_LOGS_NO_SUPPORT=true in /etc/default/tailscaled.
macOSNo switch exists — neither the Standalone build nor the App Store build has one.
AndroidNo switch.

On macOS the three honest options are: accept the telemetry, block log.tailscale.com in /etc/hosts, or drop the GUI app and run the Homebrew tailscaled instead (which takes the Linux flag). Opting out also opts you out of vendor support, which is the trade the flag's name states.

Key expiry ​

Device keys expire on a schedule and the device drops off the tailnet until someone signs it back in. That is the behaviour you want on a laptop and a phone: a lost device leaves the tailnet on its own.

  • Disable key expiry on the one always-on machine that everything else dials — here, the Windows PC. Admin console → Machines → the device → Disable key expiry. An anchor that silently expires looks exactly like a broken sync.
  • Leave key expiry on for the laptop and the phone.

Write a grants policy ​

The policy file is deny-by-default once you remove the starter rule that allows everything. Legacy acls are frozen — write grants.

Three devices need exactly two rules: the desktops talk to each other on the Syncthing sync port, and the phone talks to the desktops. Nothing dials the phone.

json
{
  // Replace each placeholder with the tailnet address `tailscale ip -4`
  // printed for that device. Tailnet addresses are stable per device.
  "hosts": {
    "mac":   "100.x.y.z",
    "pc":    "100.x.y.z",
    "phone": "100.x.y.z"
  },

  "grants": [
    // Desktops sync with each other.
    {
      "src": ["mac", "pc"],
      "dst": ["mac", "pc"],
      "ip":  ["tcp:22000"]
    },
    // The phone pushes to the desktops; nothing dials the phone.
    {
      "src": ["phone"],
      "dst": ["mac", "pc"],
      "ip":  ["tcp:22000"]
    }
  ]
}

There is no rule granting anything else, so everything else is denied. Add one grant per service later, never a blanket one.

Point Syncthing at the tailnet ​

Per device, in the Syncthing web GUI. Work through this list on every device before checking any of them:

  • Actions → Settings → Connections → Sync Protocol Listen Addresses: set it to tcp://0.0.0.0:22000, quic://0.0.0.0:22000 and remove default. That keeps the device listening on every interface — the LAN one and the tailnet one — while dropping the relay endpoint that default carries.
  • Global Discovery: off.
  • Enable Relaying: off.
  • Local Discovery: on.
  • For each remote device — Edit Device → Advanced → Addresses: set it to dynamic, tcp://100.x.y.z:22000 using that device's tailnet address. Keep dynamic in the list: it is what lets local discovery hand Syncthing the peer's LAN address.
  • Use the tailnet address, not the MagicDNS name. The address is stable per device, and Syncthing stores what you type.

Both halves of that address list earn their place. At home, local discovery answers with the peer's LAN address; Syncthing classes the tailnet address as a WAN address and prefers a LAN one when both exist — it will even upgrade an already-connected WAN link to the LAN one — so two devices on the same network keep talking directly at LAN speed. Away from home nothing answers on the LAN and the static tailnet address is the one left, so the tunnel carries only traffic that is genuinely off-LAN. Neither path can fall back to a public relay or a discovery server: both are off.

zolder's [syncthing] check is unaffected by any of this: its url is the local REST API on http://127.0.0.1:8384, which is a different thing from the sync listen address. See the Configuration reference.

Verify ​

Two checks, one per layer.

sh
# Tailscale: each peer should read `direct`, not `relay "ams"` or similar.
# A peer can sit on a relay for a few seconds before NAT traversal completes,
# so send some traffic and look again.
tailscale status
tailscale ping <peer-name>

In Syncthing, open the remote device's panel on the dashboard. The connection type reads TCP LAN for a peer on the same network as this device, and TCP WAN for one that is away, reached over the tailnet. If it reads Relay, the setup is not done: relaying is still enabled somewhere, or a device is still listening on default.

Fallbacks ​

If the tailnet has to go, two paths keep the mesh working:

  • Plain WireGuard. Configure wg by hand between the devices. It is the same data plane with no coordination service and no vendor, at the cost of distributing keys and endpoints yourself every time an address changes.
  • Headscale. An open-source implementation of the coordination server that you run yourself, spoken to by the official Tailscale clients. You own the peer list; you lose Tailnet Lock, and whoever hosts the server sees the same device metadata the vendor did.

Node-side features — a peer relay, an exit node, a subnet router, tailnet DNS — are for later, when a home node exists.

What zolder watches here ​

Nothing yet, on this layer. zolder's [syncthing] check reads folder and peer health over Syncthing's local REST API, so it reports that a peer is disconnected without knowing whether the tailnet is the reason. A [tailscale] check that reports tailnet reachability alongside it is planned, not shipped — until it lands, tailscale status is the tool for that question.