tailscale-derp-server¶
Modules
A NixOS module that self-hosts a Tailscale DERP relay behind nginx, with ACME certificates and abuse filtering.
What problem it solves¶
DERP is Tailscale's relay of last resort: when two nodes can't establish a direct WireGuard path (symmetric NAT, restrictive firewalls), their encrypted packets bounce through a DERP relay instead. Running your own relay keeps that traffic on your own infrastructure rather than Tailscale's public DERP mesh, and — fronted on port 443 — it works through corporate firewalls that block non-standard ports.
NixOS ships no DERP module, so this wraps the derper binary (from
pkgs.tailscale.derper) into a hardened systemd service plus an nginx reverse
proxy.
The insight & the traps¶
The value here is the wiring, not the binary. Several things are non-obvious:
-
-certmode=manualfed by symlinks. derper's default cert mode wants to run its own LetsEncrypt autocert. Instead we let NixOS's ACME manage the cert and hand derper the files. ACME and derper disagree on filenames, so anExecStartPresymlinksfullchain.pem/key.pemto the<hostname>.crt/.keynames derper expects. -
Nothing runs as root — not even the setup step. The obvious way to write that
ExecStartPreis with systemd's+prefix so it canchownfreely. That is unnecessary:StateDirectory=derperalready creates the state directory owned by the service user, and the certificate is reachable by joining the group that owns it. Dropping the prefix also makes the readability check real — as root,test -ralways succeeds and has to be faked withsu, which silently tests the wrong thing if the user's groups are wrong. Run as the service user and the check is the same access the daemon will perform a second later, so a group misconfiguration fails the unit immediately with a clear message rather than surfacing minutes later as a TLS handshake error. -
The owning group is derived, not assumed. It is tempting to hardcode
nginx, since a web server usually provisions the cert. But that group is a property of the certificate, not of this module: it comes fromsecurity.acme.certs.<dir>.group, whose NixOS default isacme. The module reads that value and joins whatever it actually is, and asserts at build time that it could determine it — so a mismatch is a build error with a fix in the message, never a running relay that cannot read its own key. -
Poll for the cert. The unit orders
aftertheacme-finished-<host>.target, but the files can still lag that target. TheExecStartPrepolls up to ~60s for them before symlinking — without the loop, a race leaves derper starting with no cert. -
HTTP/2 must be OFF on the nginx vhost. DERP negotiates over HTTP/1.1
Upgradesemantics; leaving HTTP/2 on breaks the relay handshake. -
Only proxy the real DERP endpoints; 444 everything else. The vhost enumerates exactly
/derp(websocket, with 10-minute timeouts),/derp/probe,/derp/latency-check,/generate_204,/robots.txt, and/bootstrap-dns. Any other path hitslocation "/", gets logged to a probes log, and returns444(nginx closes the connection with no response). This keeps the box from looking like a live web server to internet scanners. -
Gate clients to your tailnet with
-verify-clients. Without it your relay is an open DERP anyone can bounce traffic through. With it, derper checks each client against the local Tailscale daemon (which must be running on the host). -
Split-port trap. All the nginx and fail2ban machinery is gated on
port != 443. If you run derper directly on 443 (no reverse proxy), the vhost and jails silently vanish — that's intentional, but surprising. -
Backend SSL verify off + buffering off. nginx talks to derper over loopback HTTPS;
proxy_ssl_verify offavoids validating the loopback cert, and buffering off keeps long-lived relay streams from stalling.
Certificate setup¶
The module reads a cert from /var/lib/acme/<acmeHost or hostname>/. You still
have to arrange for that cert to exist; the module works out who may read it.
Nothing in this module runs as root, so access is purely by group membership.
The module looks up the matching security.acme.certs entry and adds the
derper user to that certificate's group — whatever it is. Declare the cert
however you normally would:
The NixOS default for that option is acme, not nginx; either works, since
the group is read rather than assumed. When nginx fronts the relay you will
usually want nginx anyway so the proxy can read the same files, and the
nginx gid is pinned (via mkDefault) so it is stable on minimal hosts.
If the certificate is provisioned outside the NixOS ACME module there is nothing to look up, and the build fails with an assertion telling you to name the group yourself:
Usage¶
{
imports = [ ./modules/tailscale-derp-server ];
services.derp-server = {
enable = true;
hostname = "derp.example.com"; # MUST match the served TLS cert
port = 8443; # derper listener; nginx proxies 443 -> here
stunPort = 3478;
verifyClients = true; # gate to your tailnet
# acmeHost = "example.com"; # if a different-named cert covers hostname
};
# Optional: ban scanners (needs services.fail2ban.enable = true)
services.derp-server.fail2ban.enable = true;
}
Then point your Tailscale/Headscale control plane at the relay in your DERP
map (region with HostName = "derp.example.com", DERPPort = 443,
STUNPort = 3478).
Options¶
| Option | Default | Purpose |
|---|---|---|
enable |
false |
Turn the module on. |
hostname |
(required) | DERP hostname; must match the TLS cert. |
port |
8443 |
derper's own HTTPS listener. 443 = run direct, no nginx. |
stunPort |
3478 |
STUN port (opened on the firewall). |
verifyClients |
true |
Pass -verify-clients; restrict relay to your tailnet. |
acmeHost |
null → hostname |
Which /var/lib/acme/<dir> cert to read. |
acmeGroup |
null → cert's own group |
Group owning the ACME files, which user joins to read them. Auto-detected from security.acme.certs.<dir>.group; set only when the cert is provisioned outside the ACME module. |
user / group |
derper |
System user/group derper runs as. Never root. |
fail2ban.enable |
false |
Register the probe + bad-TLS jails (needs nginx + services.fail2ban). |
fail2ban.action |
null |
fail2ban action for the jails; null = global default. |
fail2ban.bantime |
"168h" |
Ban duration. |
fail2ban jails¶
When fail2ban.enable is set (and nginx is fronting derper), two jails are
registered with matching /etc/fail2ban/filter.d filters:
derp-probes— bans IPs that trip the nginx444responses (i.e. hit paths that aren't real DERP endpoints), read from/var/log/nginx/derp-probes.log.derp-bad-tls— bans IPs producingcert mismatchTLS handshake errors in the derper journal.
Leave fail2ban.action at null to use fail2ban's global banaction, or set
it to a named action of your own.
Security notes¶
The systemd unit is tightly sandboxed: ProtectSystem=strict,
ProtectHome=true, NoNewPrivileges=true, /var/lib/acme mounted read-only,
and the capability set narrowed to just CAP_NET_BIND_SERVICE (the ambient
cap that lets the unprivileged user bind the low STUN/relay ports).
No step in the unit runs as root, including ExecStartPre — the service user
is derper throughout, and two build-time assertions keep it that way: one
rejects user = "root", the other refuses to build unless the owning group of
the ACME material could be determined.
Source¶
modules/tailscale-derp-server/default.nix
# Self-hosted Tailscale DERP relay behind nginx.
#
# Wraps Tailscale's `derper` binary in a hardened systemd service, feeds it
# ACME-managed certificates via `-certmode=manual`, and (unless it runs
# directly on 443) fronts it with nginx so the relay is reachable on the
# standard HTTPS port. See README.md for the why and the traps.
#
# Usage:
# imports = [ ./modules/tailscale-derp-server ];
# services.derp-server = {
# enable = true;
# hostname = "derp.example.com"; # must match the served TLS cert
# # acmeHost = "example.com"; # optional: which ACME cert dir to read
# # port = 8443; # derper's own listener (nginx proxies 443 -> this)
# # stunPort = 3478;
# # verifyClients = true; # gate to your tailnet
# };
#
# You must arrange the ACME certificate yourself, e.g.:
# security.acme.certs."derp.example.com".group = "nginx";
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.derp-server;
derper = pkgs.tailscale.derper;
# Whether nginx fronts derper. When derper listens directly on 443 there is
# no reverse proxy, so the nginx vhost + abuse filtering are irrelevant.
useNginx = cfg.port != 443;
# Directory under /var/lib/acme that holds fullchain.pem / key.pem.
acmeDir = if cfg.acmeHost != null then cfg.acmeHost else cfg.hostname;
acmeCertPath = "/var/lib/acme/${acmeDir}";
# The group that owns the ACME material. Nothing here runs as root, so the
# relay reads its certificate purely through group membership -- which means
# this group has to be right, and is asserted below rather than assumed.
acmeCert = config.security.acme.certs.${acmeDir} or null;
certGroup =
if cfg.acmeGroup != null then
cfg.acmeGroup
else if acmeCert != null then
acmeCert.group
else
null;
in
{
options.services.derp-server = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable DERP relay server";
};
hostname = lib.mkOption {
type = lib.types.str;
example = "derp.example.com";
description = "Hostname for DERP server (must match the TLS certificate served to clients)";
};
port = lib.mkOption {
type = lib.types.port;
default = 8443;
description = ''
HTTPS port derper listens on. When set to anything other than 443,
nginx is enabled and proxies public 443 traffic to this port. Set to
443 to run derper directly with no reverse proxy.
'';
};
stunPort = lib.mkOption {
type = lib.types.port;
default = 3478;
description = "STUN port (opened on the firewall)";
};
verifyClients = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Pass `-verify-clients` to derper so it only relays for nodes in the
local tailnet. Requires the Tailscale daemon to be running on this
host. Prevents the relay from being abused as an open DERP.
'';
};
acmeHost = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "example.com";
description = ''
Name of the ACME certificate directory under /var/lib/acme to read
the cert from. Defaults to `hostname`. Use this when a single
wildcard/SAN cert (issued for a different primary name) covers the
DERP hostname.
'';
};
user = lib.mkOption {
type = lib.types.str;
default = "derper";
description = "System user derper runs as. Must not be root.";
};
acmeGroup = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "nginx";
description = ''
Group owning the ACME certificate files, which the derper user joins in
order to read them. Defaults to the `group` of the matching
`security.acme.certs` entry, so normally you do not set this. Set it
explicitly when the certificate is provisioned outside the NixOS ACME
module and therefore cannot be discovered.
'';
};
group = lib.mkOption {
type = lib.types.str;
default = "derper";
description = "Primary group for the derper user.";
};
fail2ban = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Register fail2ban jails that ban scanners hitting the relay: one on
nginx 444 responses (probe requests to non-DERP paths) and one on
derper "cert mismatch" TLS journal errors. Only active when nginx
fronts derper (port != 443) and `services.fail2ban.enable` is true.
'';
};
action = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "iptables-allports";
description = ''
fail2ban action for the DERP jails. `null` uses fail2ban's global
default (`services.fail2ban.banaction`). Set to a named action if
you want these jails to use a specific one.
'';
};
bantime = lib.mkOption {
type = lib.types.str;
default = "168h";
description = "Ban duration for the DERP jails.";
};
};
};
config = lib.mkIf cfg.enable {
networking.firewall = {
allowedUDPPorts = [
config.services.tailscale.port
cfg.stunPort
];
};
systemd.services.derp-server = {
description = "DERP (Designated Encrypted Relay for Packets) server";
after = [
"network-online.target"
"acme-finished-${acmeDir}.target"
] ++ lib.optional useNginx "nginx.service";
wants = [
"network-online.target"
"acme-finished-${acmeDir}.target"
];
wantedBy = [ "multi-user.target" ];
path = [ pkgs.coreutils ];
serviceConfig = {
Type = "simple";
Restart = "always";
RestartSec = 5;
# Runs as the service user, NOT root: `StateDirectory` already creates
# /var/lib/derper owned by it, and the certificate is reached through
# group membership. Polls first because the ACME files can lag the
# acme-finished target, then symlinks them to the <hostname>.crt/.key
# names derper expects.
#
# The readability test is the real thing rather than a root-side `su`
# emulation of it, so a group misconfiguration fails the unit here with
# a precise message instead of surfacing later as a TLS handshake error.
ExecStartPre = pkgs.writeShellScript "derper-cert-links" ''
set -euo pipefail
i=0
while [ $i -lt 30 ]; do
if [ -r "${acmeCertPath}/fullchain.pem" ] && [ -r "${acmeCertPath}/key.pem" ]; then
break
fi
i=$((i + 1))
sleep 2
done
if [ ! -r "${acmeCertPath}/fullchain.pem" ] || [ ! -r "${acmeCertPath}/key.pem" ]; then
echo "derp-server: cannot read ${acmeCertPath}/{fullchain,key}.pem as $(id -un):$(id -gn)." >&2
echo "derp-server: that material is owned by group ${
if certGroup != null then certGroup else "<unknown>"
}; the ${cfg.user} user must be a member of it." >&2
exit 1
fi
ln -sfn "${acmeCertPath}/fullchain.pem" "/var/lib/derper/${cfg.hostname}.crt"
ln -sfn "${acmeCertPath}/key.pem" "/var/lib/derper/${cfg.hostname}.key"
'';
# -http-port=-1 disables plain HTTP; -certmode=manual reads the
# symlinked ACME cert instead of derper's LetsEncrypt autocert.
# When nginx fronts derper, bind the HTTPS listener to loopback only
# (nginx proxies from 127.0.0.1) so the backend port can never be hit
# directly even if the host firewall/cloud SG leaves it open. On the
# direct-443 path bind all interfaces since there is no proxy.
ExecStart = "${derper}/bin/derper -c=/var/lib/derper/derper.key -hostname=${cfg.hostname} -a=${if useNginx then "127.0.0.1" else ""}:${toString cfg.port} -http-port=-1 -stun-port=${toString cfg.stunPort} -certmode=manual -certdir=/var/lib/derper ${lib.optionalString cfg.verifyClients "-verify-clients"}";
StateDirectory = "derper";
StateDirectoryMode = "0700";
User = cfg.user;
Group = cfg.group;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
NoNewPrivileges = true;
ReadOnlyPaths = [ "/var/lib/acme" ];
ReadWritePaths = [ "/var/lib/derper" ];
# Ambient cap lets the unprivileged user bind low STUN/relay ports.
AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
};
};
assertions = [
{
assertion = cfg.user != "root";
message = ''
services.derp-server.user must not be root. The relay reads its
certificate through membership of the group that owns it, so it never
needs privilege.
'';
}
{
assertion = certGroup != null;
message = ''
services.derp-server cannot determine which group owns the ACME
certificate in ${acmeCertPath}, so it cannot grant the ${cfg.user}
user read access to it.
Either define the certificate through the NixOS ACME module, e.g.
security.acme.certs."${acmeDir}".group = "nginx";
(its `group` is picked up automatically), or, if the certificate is
provisioned some other way, name the owning group explicitly:
services.derp-server.acmeGroup = "<group>";
'';
}
];
users.groups.${cfg.group} = { };
# Pin nginx gid so it is stable on hosts that don't otherwise define it.
users.groups.nginx = lib.mkIf useNginx (lib.mkDefault { gid = 60; });
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
# Read access to the ACME material comes from joining whichever group owns
# it -- derived from the cert definition rather than assumed to be nginx,
# because that only happens to be true when a web server provisions it.
extraGroups = lib.optional (certGroup != null) certGroup;
};
services.fail2ban.jails = lib.mkIf (cfg.fail2ban.enable && useNginx) {
derp-bad-tls.settings = {
enabled = true;
filter = "derp-bad-tls";
backend = "systemd";
maxretry = 3;
findtime = 600;
bantime = cfg.fail2ban.bantime;
} // lib.optionalAttrs (cfg.fail2ban.action != null) { action = cfg.fail2ban.action; };
derp-probes.settings = {
enabled = true;
filter = "derp-probes";
logpath = "/var/log/nginx/derp-probes.log";
backend = "auto";
maxretry = 3;
findtime = 600;
bantime = cfg.fail2ban.bantime;
} // lib.optionalAttrs (cfg.fail2ban.action != null) { action = cfg.fail2ban.action; };
};
environment.etc = lib.mkIf (cfg.fail2ban.enable && useNginx) {
"fail2ban/filter.d/derp-bad-tls.local".text = ''
[Definition]
failregex = ^.*http: TLS handshake error from <HOST>:[0-9]+: .*cert mismatch.*$
journalmatch = _SYSTEMD_UNIT=derp-server.service
'';
"fail2ban/filter.d/derp-probes.local".text = ''
[Definition]
failregex = ^<HOST> - .* "(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH|CONNECT) [^"]*" 444
'';
};
services.nginx.enable = lib.mkDefault useNginx;
services.nginx.virtualHosts.${cfg.hostname} = lib.mkIf useNginx (
let
upstream = "https://127.0.0.1:${toString cfg.port}";
# Backend SSL verification off (derper serves a cert for `hostname` on
# loopback); buffering off so relay streams don't stall.
sharedProxyConfig = ''
proxy_ssl_verify off;
proxy_ssl_protocols TLSv1.2 TLSv1.3;
proxy_ssl_server_name on;
proxy_ssl_name ${cfg.hostname};
proxy_ssl_session_reuse on;
proxy_set_header Host ${cfg.hostname};
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_request_buffering off;
'';
plainLocation = {
proxyPass = upstream;
recommendedProxySettings = false;
extraConfig = sharedProxyConfig;
};
upgradeLocation = {
proxyPass = upstream;
proxyWebsockets = true;
recommendedProxySettings = false;
extraConfig = sharedProxyConfig + ''
proxy_connect_timeout 10m;
proxy_send_timeout 10m;
proxy_read_timeout 10m;
'';
};
in
{
forceSSL = true;
useACMEHost = acmeDir;
# HTTP/2 OFF: DERP uses HTTP/1.1 Upgrade semantics.
http2 = false;
extraConfig = ''
server_tokens off;
access_log /var/log/nginx/derp-access.log combined;
error_log /var/log/nginx/derp-error.log warn;
'';
# Enumerate only real DERP endpoints; everything else returns 444
# (connection closed, no response) so the box isn't a probe target.
locations = {
"= /derp" = upgradeLocation;
"= /derp/probe" = plainLocation;
"= /derp/latency-check" = plainLocation;
"= /generate_204" = plainLocation;
"= /robots.txt" = plainLocation;
"= /bootstrap-dns" = plainLocation;
"/" = {
extraConfig = ''
access_log /var/log/nginx/derp-probes.log combined;
return 444;
'';
};
};
}
);
};
}