nixos: add multi-instance tailscale NixOS module

Rewrite the NixOS module to support running multiple Tailscale daemons
simultaneously on the same host (e.g., one connected to Tailscale SaaS,
another to a Headscale instance).

The module provides two interfaces:
  - services.tailscale (singular, backward-compatible, TUN mode default)
  - services.tailscales.<name> (plural, userspace mode default)

Both share a common submodule definition (instance.nix). Per-instance
isolation is achieved through separate systemd services, state dirs,
runtime dirs, sockets, and CLI wrapper scripts (tailscale-<name>).

Only one TUN-mode instance is allowed at a time due to hardcoded
routing table 52, fwmarks, and iptables chain names in the Go source.

The architecture follows the services.github-runners pattern from
nixpkgs: a shared submodule reused by both singular and plural option
paths.

Co-authored-by: Mike O'Driscoll <mikeo@tailscale.com>
This commit is contained in:
Kristoffer Dalby 2026-03-05 12:02:27 +00:00 committed by Kristoffer Dalby
parent 54606a0a89
commit 150ea3f08d
No known key found for this signature in database
5 changed files with 802 additions and 1 deletions

View File

@ -1,3 +1,5 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
# flake.nix describes a Nix source repository that provides
# development builds of Tailscale and the fork of the Go compiler
# toolchain that Tailscale maintains. It also provides a development
@ -67,7 +69,12 @@
})
];
}));
# tailscaleRev is the git commit at which this flake was imported,
# or the empty string when building from a local checkout of the
# tailscale repo.
tailscaleRev = self.rev or "";
lib = nixpkgs.lib;
in {
# tailscale takes a nixpkgs package set, and builds Tailscale from
# the same commit as this flake. IOW, it provides "tailscale built
@ -90,6 +97,8 @@
default = pkgs.buildGo126Module {
name = "tailscale";
pname = "tailscale";
meta.mainProgram = "tailscaled";
src = ./.;
vendorHash = pkgs.lib.fileContents ./go.mod.sri;
nativeBuildInputs = [pkgs.makeWrapper pkgs.installShellFiles];
@ -98,7 +107,6 @@
subPackages = [
"cmd/tailscale"
"cmd/tailscaled"
"cmd/tsidp"
];
doCheck = false;
@ -131,6 +139,28 @@
tailscale = default;
});
overlays.default = final: prev: {
tailscale = self.packages.${prev.stdenv.hostPlatform.system}.default;
};
nixosModules = {
tailscale = import ./nixos/default.nix self;
# Module that disables upstream nixpkgs tailscale and uses this one.
# This is the recommended import for most users.
override = {
imports = [(import ./nixos/default.nix self)];
# Disable both upstream modules: tailscale.nix defines individual
# options under services.tailscale.* that conflict with our submodule,
# and tailscale-derper.nix nests its options under services.tailscale.derper
# which forces evaluation of our submodule and causes infinite recursion.
disabledModules = [
"services/networking/tailscale.nix"
"services/networking/tailscale-derper.nix"
];
};
default = self.nixosModules.override;
};
devShells = eachSystem (pkgs: {
default = pkgs.mkShell {
packages = with pkgs; [

12
nixos/default.nix Normal file
View File

@ -0,0 +1,12 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
#
# Tailscale NixOS module entry point.
# Provides both single-instance (services.tailscale) and multi-instance
# (services.tailscales.<name>) Tailscale daemon configuration.
self: {
imports = [
(import ./options.nix self)
(import ./service.nix self)
];
}

206
nixos/instance.nix Normal file
View File

@ -0,0 +1,206 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
#
# Per-instance option definitions for a Tailscale daemon.
# Used as a submodule by both:
# services.tailscale (singular, backward-compatible)
# services.tailscales.<n> (plural, attrsOf submodule)
#
# Receives `self`, `lib`, and `pkgs` from the importing module
# via closure (see options.nix).
{
self,
lib,
pkgs,
}: let
inherit (lib)
literalExpression
mkEnableOption
mkOption
types
;
in {
options = {
enable = mkEnableOption "Tailscale client daemon";
package = mkOption {
type = types.package;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.tailscale;
defaultText = literalExpression "self.packages.\${pkgs.stdenv.hostPlatform.system}.tailscale";
description = "The Tailscale package to use.";
};
port = mkOption {
type = types.port;
default = 41641;
description = ''
UDP port for incoming WireGuard tunnel traffic.
Set to 0 for automatic port selection.
When running multiple instances, each must use a unique port or 0.
'';
};
interfaceName = mkOption {
type = types.str;
default = "tailscale0";
description = ''
Name of the TUN network interface.
Only used when `networkingMode` is `"tun"`.
Each TUN-mode instance must have a unique interface name.
'';
};
networkingMode = mkOption {
type = types.enum ["tun" "userspace"];
default = "tun";
description = ''
Networking mode for this Tailscale instance.
`"tun"` creates a TUN device and manages routes via the system
routing table. This provides full functionality (exit nodes, subnet
routers, MagicDNS, etc.) but only **one** TUN-mode instance can
run at a time due to hardcoded routing table number (52), fwmark
values, and iptables/nftables chain names in the Tailscale daemon.
`"userspace"` does not create a network interface. Multiple
userspace instances can coexist safely. Userspace mode provides a
SOCKS5/HTTP proxy for accessing the tailnet instead of system-level
routing.
See <https://tailscale.com/kb/1112/userspace-networking>
'';
};
permitCertUid = mkOption {
type = types.nullOr types.nonEmptyStr;
default = null;
description = ''
Username or UID allowed to fetch Tailnet TLS certificates
via `tailscale cert`.
'';
};
disableTaildrop = mkOption {
type = types.bool;
default = false;
description = "Disable Tailscale Taildrop file sending/receiving.";
};
disableUpstreamLogging = mkOption {
type = types.bool;
default = false;
description = ''
Disable upstream debug logging to Tailscale's log server.
Equivalent to passing `--no-logs-no-support` to the daemon.
'';
};
openFirewall = mkOption {
type = types.bool;
default = false;
description = ''
Open the firewall for Tailscale's UDP port.
Recommended to allow direct WireGuard connections.
'';
};
useRoutingFeatures = mkOption {
type = types.enum [
"none"
"client"
"server"
"both"
];
default = "none";
example = "server";
description = ''
Enables settings required for Tailscale's routing features like
subnet routers and exit nodes.
To use these features, you will still need to call
`tailscale up --advertise-exit-node` or similar.
When set to `"client"` or `"both"`, reverse path filtering will be
set to loose instead of strict.
When set to `"server"` or `"both"`, IP forwarding will be enabled.
Only effective when `networkingMode` is `"tun"`.
See <https://tailscale.com/kb/1019/subnets#enable-ip-forwarding>
See <https://github.com/tailscale/tailscale/issues/3310>
'';
};
authKeyFile = mkOption {
type = types.nullOr types.path;
default = null;
example = "/run/secrets/tailscale_key";
description = ''
Path to a file containing a Tailscale auth key. When set, the
instance will automatically authenticate on startup via a
`tailscaled-autoconnect` oneshot service.
'';
};
authKeyParameters = mkOption {
description = ''
Extra parameters to append to the auth key.
See <https://tailscale.com/kb/1215/oauth-clients#registering-new-nodes-using-oauth-credentials>
'';
type = types.submodule {
options = {
ephemeral = mkOption {
type = types.nullOr types.bool;
default = null;
description = "Whether to register as an ephemeral node.";
};
preauthorized = mkOption {
type = types.nullOr types.bool;
default = null;
description = "Whether to skip manual device approval.";
};
baseURL = mkOption {
type = types.nullOr types.str;
default = null;
description = "Base URL of the coordination server.";
};
};
};
default = {};
};
extraUpFlags = mkOption {
type = types.listOf types.str;
default = [];
example = ["--ssh" "--accept-routes"];
description = ''
Extra flags to pass to `tailscale up`.
Only used when `authKeyFile` is set.
'';
};
extraSetFlags = mkOption {
type = types.listOf types.str;
default = [];
example = ["--advertise-exit-node" "--shields-up"];
description = "Extra flags to pass to `tailscale set`.";
};
extraDaemonFlags = mkOption {
type = types.listOf types.str;
default = [];
example = ["--verbose=1"];
description = "Extra flags to pass to the `tailscaled` daemon.";
};
cleanup = mkOption {
type = types.bool;
default = true;
description = ''
Whether to run `tailscaled --cleanup` on service stop to remove
iptables rules, routes, and the TUN device.
'';
};
};
}

66
nixos/options.nix Normal file
View File

@ -0,0 +1,66 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
#
# Top-level option declarations for the Tailscale NixOS module.
# Declares both services.tailscale (singular) and services.tailscales (plural).
self: {
lib,
pkgs,
...
}: let
inherit (lib) mkDefault mkOption types;
instanceModule = import ./instance.nix {inherit self lib pkgs;};
in {
options = {
services.tailscale = mkOption {
type = types.submodule {
imports = [instanceModule];
};
default = {};
description = ''
Tailscale VPN client daemon (single instance).
Backward-compatible with the upstream nixpkgs module.
For running multiple Tailscale instances, use
`services.tailscales` instead.
'';
};
services.tailscales = mkOption {
type = types.attrsOf (types.submodule ({name, ...}: {
imports = [instanceModule];
# Override defaults for multi-instance safety.
config = {
port = mkDefault 0;
networkingMode = mkDefault "userspace";
interfaceName = mkDefault "ts-${name}";
};
}));
default = {};
description = ''
Multiple Tailscale VPN instances. Each attribute name becomes the
instance identifier, used to derive systemd service names, state
directories, and CLI wrapper names.
Instances default to userspace networking mode to avoid conflicts
with resources shared by the TUN interface (routing table 52,
fwmarks, iptables chains).
Example:
```nix
services.tailscales = {
personal = {
enable = true;
authKeyFile = "/run/secrets/personal-ts-key";
};
work = {
enable = true;
authKeyFile = "/run/secrets/work-ts-key";
extraUpFlags = [ "--login-server=https://hs.work.com" ];
};
};
```
'';
};
};
}

487
nixos/service.nix Normal file
View File

@ -0,0 +1,487 @@
# Copyright (c) Tailscale Inc & AUTHORS
# SPDX-License-Identifier: BSD-3-Clause
#
# Config generation for the Tailscale NixOS module.
# Generates systemd services, networking config, and CLI wrappers
# for all enabled Tailscale instances (both singular and plural).
#
# IMPORTANT: This module's config must NOT use `mkMerge (mapAttrsToList ...)`
# at the top-level config structure, as that causes infinite recursion with
# the NixOS module system. Instead, each option path (systemd.services,
# environment.systemPackages, etc.) is assigned directly, with `mkMerge`
# used INSIDE the values to combine singular and plural instance contributions.
# This lets the module system determine which option paths are defined without
# evaluating any thunks that reference config.services.tailscale(s).
self: {
config,
lib,
pkgs,
...
}: let
inherit (lib)
any
attrValues
boolToString
concatMap
concatStringsSep
escapeShellArg
escapeShellArgs
filterAttrs
mapAttrsToList
mkIf
mkMerge
mkOverride
optional
optionalAttrs
optionals
optionalString
pipe
;
singularCfg = config.services.tailscale;
pluralCfg = config.services.tailscales;
# Derive all paths and names for an instance.
instanceMeta = name: let
isDefault = name == "default";
in {
svcName =
if isDefault
then "tailscaled"
else "tailscaled-${name}";
stateDir =
if isDefault
then "tailscale"
else "tailscale-${name}";
runtimeDir =
if isDefault
then "tailscale"
else "tailscale-${name}";
cacheDir =
if isDefault
then "tailscale"
else "tailscale-${name}";
socketPath =
if isDefault
then "/run/tailscale/tailscaled.sock"
else "/run/tailscale-${name}/tailscaled.sock";
wrapperName =
if isDefault
then null
else "tailscale-${name}";
inherit isDefault;
};
# Build the --tun argument based on networking mode.
tunArg = cfg:
if cfg.networkingMode == "userspace"
then "userspace-networking"
else cfg.interfaceName;
# Build the full daemon flags list.
daemonFlags = name: cfg: let
meta = instanceMeta name;
in
[
"--tun=${tunArg cfg}"
"--statedir=/var/lib/${meta.stateDir}"
"--socket=${meta.socketPath}"
]
++ cfg.extraDaemonFlags;
# Build auth key query parameters string.
# Only includes parameters the user explicitly set (non-null).
authKeyParams = cfg:
pipe cfg.authKeyParameters [
(filterAttrs (_: v: v != null))
(mapAttrsToList (k: v:
"${k}=${
if (builtins.isBool v)
then (boolToString v)
else (toString v)
}"))
(builtins.concatStringsSep "&")
(params:
if params != ""
then "?${params}"
else "")
];
# Generate systemd services for a single instance.
mkInstanceServices = name: cfg: let
meta = instanceMeta name;
flags = daemonFlags name cfg;
# For plural instances, merge shared services from the singular config.
# Per-instance services override shared definitions with the same name.
effServices =
if meta.isDefault
then cfg.services
else singularCfg.services // cfg.services;
in
{
# ── Main tailscaled daemon ──
${meta.svcName} = {
description =
"Tailscale node agent"
+ optionalString (!meta.isDefault) " (${name})";
wantedBy = ["multi-user.target"];
wants = ["network-pre.target"];
after =
[
"network-pre.target"
"NetworkManager.service"
"systemd-resolved.service"
]
++ optional
config.networking.networkmanager.enable
"NetworkManager-wait-online.service";
path =
[
(builtins.dirOf config.security.wrapperDir)
pkgs.iproute2
pkgs.iptables
pkgs.procps
pkgs.getent
pkgs.shadow
pkgs.kmod
]
++ optional
config.networking.resolvconf.enable
config.networking.resolvconf.package;
serviceConfig =
{
ExecStart = concatStringsSep " " ([
"${cfg.package}/bin/tailscaled"
"--port=${toString cfg.port}"
]
++ flags);
Environment =
optionals (cfg.permitCertUid != null) [
"TS_PERMIT_CERT_UID=${cfg.permitCertUid}"
]
++ optionals cfg.disableTaildrop [
"TS_DISABLE_TAILDROP=true"
]
++ optionals cfg.disableUpstreamLogging [
"TS_NO_LOGS_NO_SUPPORT=true"
];
Restart = "on-failure";
RuntimeDirectory = meta.runtimeDir;
RuntimeDirectoryMode = "0755";
StateDirectory = meta.stateDir;
StateDirectoryMode = "0700";
CacheDirectory = meta.cacheDir;
CacheDirectoryMode = "0750";
Type = "notify";
}
// optionalAttrs cfg.cleanup {
ExecStopPost = "${cfg.package}/bin/tailscaled --cleanup";
};
stopIfChanged = false;
};
}
# ── Auto-connect service (when authKeyFile is set) ──
// optionalAttrs (cfg.authKeyFile != null) {
"${meta.svcName}-autoconnect" = {
description =
"Tailscale auto-connect"
+ optionalString (!meta.isDefault) " (${name})";
after = ["${meta.svcName}.service"];
wants = ["${meta.svcName}.service"];
wantedBy = ["multi-user.target"];
serviceConfig = {
Type = "notify";
};
path = [cfg.package pkgs.jq];
enableStrictShellChecks = true;
script = let
socket = escapeShellArg meta.socketPath;
params = authKeyParams cfg;
flagsStr = escapeShellArgs cfg.extraUpFlags;
in ''
getState() {
tailscale --socket=${socket} 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|NeedsMachineAuth|Stopped)
echo "Server needs authentication, sending auth key"
tailscale --socket=${socket} up --auth-key "$(cat ${escapeShellArg (toString cfg.authKeyFile)})${params}" ${flagsStr}
;;
Running)
echo "Tailscale is running"
systemd-notify --ready
exit 0
;;
*)
echo "Waiting for Tailscale State = Running or systemd timeout"
;;
esac
echo "State = $state"
fi
lastState="$state"
sleep .5
done
'';
};
}
# ── Set service (when extraSetFlags is set) ──
// optionalAttrs (cfg.extraSetFlags != []) {
"${meta.svcName}-set" = {
description =
"Tailscale set"
+ optionalString (!meta.isDefault) " (${name})";
after = [
"${meta.svcName}.service"
"${meta.svcName}-autoconnect.service"
];
wants = ["${meta.svcName}.service"];
wantedBy = ["multi-user.target"];
serviceConfig.Type = "oneshot";
path = [cfg.package];
script = ''
tailscale --socket=${escapeShellArg meta.socketPath} set ${escapeShellArgs cfg.extraSetFlags}
'';
};
};
# Generate a CLI wrapper package for a named instance.
mkCliWrapper = name: cfg: let
meta = instanceMeta name;
in
optionalAttrs (meta.wrapperName != null) {
${meta.wrapperName} = pkgs.writeShellScriptBin meta.wrapperName ''
exec ${cfg.package}/bin/tailscale --socket=${escapeShellArg meta.socketPath} "$@"
'';
};
in {
config = mkMerge [
# ── Option paths are statically visible here ──
# The module system can see keys (assertions, systemd, environment,
# networking) without evaluating any thunks that reference
# config.services.tailscale(s), avoiding infinite recursion.
{
# ── Assertions ──
# All assertion VALUES are lazy thunks; only evaluated when
# config.assertions is accessed (after all options are resolved).
assertions = let
singularTun =
singularCfg.enable
&& singularCfg.networkingMode == "tun";
pluralTunNames = lib.filter (
name: let
inst = pluralCfg.${name};
in
inst.enable && inst.networkingMode == "tun"
) (builtins.attrNames pluralCfg);
allTunNames =
(
if singularTun
then ["default"]
else []
)
++ pluralTunNames;
hasConflictingDefault =
singularCfg.enable
&& (
if pluralCfg ? "default"
then pluralCfg.default.enable
else false
);
in
[
{
assertion = builtins.length allTunNames <= 1;
message = ''
At most one Tailscale instance can use TUN networking mode.
Configured TUN-mode instances: ${concatStringsSep ", " allTunNames}.
The Tailscale daemon uses hardcoded routing table 52, fwmark
values (0x40000/0x80000), and iptables chain names (ts-forward,
ts-input, ts-postrouting) that conflict when multiple
TUN-mode instances run simultaneously.
Set `networkingMode = "userspace"` for additional instances.
'';
}
{
assertion = !hasConflictingDefault;
message = ''
Cannot enable both `services.tailscale` and
`services.tailscales.default` simultaneously. They create
conflicting instances with the same service name and paths.
'';
}
]
++ (
if singularCfg.enable
then [
{
assertion =
singularCfg.networkingMode == "tun"
|| singularCfg.useRoutingFeatures == "none";
message = ''
services.tailscale: `useRoutingFeatures` requires
`networkingMode = "tun"`. Userspace networking does not
support IP forwarding or subnet routing.
'';
}
]
else []
)
++ concatMap (
name: let
cfg = pluralCfg.${name};
in
if cfg.enable
then [
{
assertion =
cfg.networkingMode == "tun"
|| cfg.useRoutingFeatures == "none";
message = ''
services.tailscales.${name}: `useRoutingFeatures` requires
`networkingMode = "tun"`. Userspace networking does not
support IP forwarding or subnet routing.
'';
}
]
else []
) (builtins.attrNames pluralCfg);
# ── Systemd services (singular + plural merged) ──
systemd.services = mkMerge [
(mkIf singularCfg.enable
(mkInstanceServices "default" singularCfg))
(mkMerge (mapAttrsToList (
name: cfg:
mkIf cfg.enable (mkInstanceServices name cfg)
)
pluralCfg))
];
# ── Packages ──
environment.systemPackages = mkMerge [
(mkIf singularCfg.enable [singularCfg.package])
(mkMerge (mapAttrsToList (
name: cfg:
mkIf cfg.enable
([cfg.package] ++ attrValues (mkCliWrapper name cfg))
)
pluralCfg))
];
# ── DHCP exclusion (TUN mode only) ──
networking.dhcpcd.denyInterfaces = mkMerge [
(mkIf
(singularCfg.enable && singularCfg.networkingMode == "tun")
[singularCfg.interfaceName])
(mkMerge (mapAttrsToList (
name: cfg:
mkIf (cfg.enable && cfg.networkingMode == "tun")
[cfg.interfaceName]
)
pluralCfg))
];
# ── Firewall ──
networking.firewall.allowedUDPPorts = mkMerge [
(mkIf
(singularCfg.enable && singularCfg.openFirewall && singularCfg.port != 0)
[singularCfg.port])
(mkMerge (mapAttrsToList (
name: cfg:
mkIf (cfg.enable && cfg.openFirewall && cfg.port != 0)
[cfg.port]
)
pluralCfg))
];
# ── systemd-networkd (TUN mode only) ──
systemd.network.networks = mkMerge [
(mkIf
(singularCfg.enable
&& config.networking.useNetworkd
&& singularCfg.networkingMode == "tun")
{
"50-tailscale-default" = {
matchConfig.Name = singularCfg.interfaceName;
linkConfig = {
Unmanaged = true;
ActivationPolicy = "manual";
};
};
})
(mkMerge (mapAttrsToList (
name: cfg:
mkIf
(cfg.enable
&& config.networking.useNetworkd
&& cfg.networkingMode == "tun")
{
"50-tailscale-${name}" = {
matchConfig.Name = cfg.interfaceName;
linkConfig = {
Unmanaged = true;
ActivationPolicy = "manual";
};
};
}
)
pluralCfg))
];
}
# ── Global IP forwarding (conditional on any instance needing it) ──
(mkIf (let
singularNeeds =
singularCfg.enable
&& (singularCfg.useRoutingFeatures == "server"
|| singularCfg.useRoutingFeatures == "both");
pluralNeeds = any
(inst:
inst.enable
&& (inst.useRoutingFeatures == "server"
|| inst.useRoutingFeatures == "both"))
(attrValues pluralCfg);
in
singularNeeds || pluralNeeds) {
boot.kernel.sysctl = {
"net.ipv4.conf.all.forwarding" = mkOverride 97 true;
"net.ipv6.conf.all.forwarding" = mkOverride 97 true;
};
})
# ── Reverse path filtering (conditional) ──
(mkIf (let
singularNeeds =
singularCfg.enable
&& (singularCfg.useRoutingFeatures == "client"
|| singularCfg.useRoutingFeatures == "both");
pluralNeeds = any
(inst:
inst.enable
&& (inst.useRoutingFeatures == "client"
|| inst.useRoutingFeatures == "both"))
(attrValues pluralCfg);
in
singularNeeds || pluralNeeds) {
networking.firewall.checkReversePath = "loose";
})
];
}