8 cfg = config.services.headscale;
10 dataDir = "/var/lib/headscale";
11 runDir = "/run/headscale";
14 # Turn off update checks since the origin of our package
15 # is nixpkgs and not Github.
16 disable_check_updates = true;
18 unix_socket = "${runDir}/headscale.sock";
21 settingsFormat = pkgs.formats.yaml { };
22 configFile = settingsFormat.generate "headscale.yaml" cfg.settings;
23 cliConfigFile = settingsFormat.generate "headscale.yaml" cliConfig;
25 assertRemovedOption = option: message: {
26 assertion = !lib.hasAttrByPath option cfg;
28 "The option `services.headscale.${lib.options.showOption option}` was removed. " + message;
33 services.headscale = {
34 enable = lib.mkEnableOption "headscale, Open Source coordination server for Tailscale";
36 package = lib.mkPackageOption pkgs "headscale" { };
39 default = "headscale";
42 User account under which headscale runs.
45 If left as the default value this user will automatically be created
46 on system activation, otherwise you are responsible for
47 ensuring the user exists before the headscale service starts.
52 group = lib.mkOption {
53 default = "headscale";
56 Group under which headscale runs.
59 If left as the default value this group will automatically be created
60 on system activation, otherwise you are responsible for
61 ensuring the user exists before the headscale service starts.
66 address = lib.mkOption {
68 default = "127.0.0.1";
70 Listening address of headscale.
76 type = lib.types.port;
79 Listening port of headscale.
84 settings = lib.mkOption {
86 Overrides to {file}`config.yaml` as a Nix attribute set.
87 Check the [example config](https://github.com/juanfont/headscale/blob/main/config-example.yaml)
90 type = lib.types.submodule {
91 freeformType = settingsFormat.type;
94 server_url = lib.mkOption {
96 default = "http://127.0.0.1:8080";
98 The url clients will connect to.
100 example = "https://myheadscale.example.com:443";
103 noise.private_key_path = lib.mkOption {
104 type = lib.types.path;
105 default = "${dataDir}/noise_private.key";
107 Path to noise private key file, generated automatically if it does not exist.
114 Each prefix consists of either an IPv4 or IPv6 address,
115 and the associated prefix length, delimited by a slash.
116 It must be within IP ranges supported by the Tailscale
117 client - i.e., subnets of 100.64.0.0/10 and fd7a:115c:a1e0::/48.
122 type = lib.types.str;
123 default = "100.64.0.0/10";
124 description = prefDesc;
128 type = lib.types.str;
129 default = "fd7a:115c:a1e0::/48";
130 description = prefDesc;
133 allocation = lib.mkOption {
134 type = lib.types.enum [
139 default = "sequential";
141 Strategy used for allocation of IPs to nodes, available options:
142 - sequential (default): assigns the next free IP from the previous given IP.
143 - random: assigns the next free IP from a pseudo-random IP generator (crypto/rand).
149 urls = lib.mkOption {
150 type = lib.types.listOf lib.types.str;
151 default = [ "https://controlplane.tailscale.com/derpmap/default" ];
153 List of urls containing DERP maps.
154 See [How Tailscale works](https://tailscale.com/blog/how-tailscale-works/) for more information on DERP maps.
158 paths = lib.mkOption {
159 type = lib.types.listOf lib.types.path;
162 List of file paths containing DERP maps.
163 See [How Tailscale works](https://tailscale.com/blog/how-tailscale-works/) for more information on DERP maps.
167 auto_update_enable = lib.mkOption {
168 type = lib.types.bool;
171 Whether to automatically update DERP maps on a set frequency.
176 update_frequency = lib.mkOption {
177 type = lib.types.str;
180 Frequency to update DERP maps.
185 server.private_key_path = lib.mkOption {
186 type = lib.types.path;
187 default = "${dataDir}/derp_server_private.key";
189 Path to derp private key file, generated automatically if it does not exist.
194 ephemeral_node_inactivity_timeout = lib.mkOption {
195 type = lib.types.str;
198 Time before an inactive ephemeral node is deleted.
204 type = lib.mkOption {
205 type = lib.types.enum [
210 example = "postgres";
213 Database engine to use.
214 Please note that using Postgres is highly discouraged as it is only supported for legacy reasons.
215 All new development, testing and optimisations are done with SQLite in mind.
220 path = lib.mkOption {
221 type = lib.types.nullOr lib.types.str;
222 default = "${dataDir}/db.sqlite";
223 description = "Path to the sqlite3 database file.";
226 write_ahead_log = lib.mkOption {
227 type = lib.types.bool;
230 Enable WAL mode for SQLite. This is recommended for production environments.
231 https://www.sqlite.org/wal.html
238 host = lib.mkOption {
239 type = lib.types.nullOr lib.types.str;
241 example = "127.0.0.1";
242 description = "Database host address.";
245 port = lib.mkOption {
246 type = lib.types.nullOr lib.types.port;
249 description = "Database host port.";
252 name = lib.mkOption {
253 type = lib.types.nullOr lib.types.str;
255 example = "headscale";
256 description = "Database name.";
259 user = lib.mkOption {
260 type = lib.types.nullOr lib.types.str;
262 example = "headscale";
263 description = "Database user.";
266 password_file = lib.mkOption {
267 type = lib.types.nullOr lib.types.path;
269 example = "/run/keys/headscale-dbpassword";
271 A file containing the password corresponding to
272 {option}`database.user`.
279 level = lib.mkOption {
280 type = lib.types.str;
288 format = lib.mkOption {
289 type = lib.types.str;
292 headscale log format.
299 magic_dns = lib.mkOption {
300 type = lib.types.bool;
303 Whether to use [MagicDNS](https://tailscale.com/kb/1081/magicdns/).
308 base_domain = lib.mkOption {
309 type = lib.types.str;
312 Defines the base domain to create the hostnames for MagicDNS.
313 This domain must be different from the {option}`server_url`
315 {option}`base_domain` must be a FQDN, without the trailing dot.
316 The FQDN of the hosts will be `hostname.base_domain` (e.g.
317 `myhost.tailnet.example.com`).
319 example = "tailnet.example.com";
323 global = lib.mkOption {
324 type = lib.types.listOf lib.types.str;
327 List of nameservers to pass to Tailscale clients.
332 search_domains = lib.mkOption {
333 type = lib.types.listOf lib.types.str;
336 Search domains to inject to Tailscale clients.
338 example = [ "mydomain.internal" ];
343 issuer = lib.mkOption {
344 type = lib.types.str;
347 URL to OpenID issuer.
349 example = "https://openid.example.com";
352 client_id = lib.mkOption {
353 type = lib.types.str;
356 OpenID Connect client ID.
360 client_secret_path = lib.mkOption {
361 type = lib.types.nullOr lib.types.str;
364 Path to OpenID Connect client secret file. Expands environment variables in format ''${VAR}.
368 scope = lib.mkOption {
369 type = lib.types.listOf lib.types.str;
376 Scopes used in the OIDC flow.
380 extra_params = lib.mkOption {
381 type = lib.types.attrsOf lib.types.str;
384 Custom query parameters to send with the Authorize Endpoint request.
387 domain_hint = "example.com";
391 allowed_domains = lib.mkOption {
392 type = lib.types.listOf lib.types.str;
395 Allowed principal domains. if an authenticated user's domain
396 is not in this list authentication request will be rejected.
398 example = [ "example.com" ];
401 allowed_users = lib.mkOption {
402 type = lib.types.listOf lib.types.str;
405 Users allowed to authenticate even if not in allowedDomains.
407 example = [ "alice@example.com" ];
410 strip_email_domain = lib.mkOption {
411 type = lib.types.bool;
414 Whether the domain part of the email address should be removed when generating namespaces.
419 tls_letsencrypt_hostname = lib.mkOption {
420 type = lib.types.nullOr lib.types.str;
423 Domain name to request a TLS certificate for.
427 tls_letsencrypt_challenge_type = lib.mkOption {
428 type = lib.types.enum [
434 Type of ACME challenge to use, currently supported types:
435 `HTTP-01` or `TLS-ALPN-01`.
439 tls_letsencrypt_listen = lib.mkOption {
440 type = lib.types.nullOr lib.types.str;
443 When HTTP-01 challenge is chosen, letsencrypt must set up a
444 verification endpoint, and it will be listening on:
449 tls_cert_path = lib.mkOption {
450 type = lib.types.nullOr lib.types.path;
453 Path to already created certificate.
457 tls_key_path = lib.mkOption {
458 type = lib.types.nullOr lib.types.path;
461 Path to key for already created certificate.
466 mode = lib.mkOption {
467 type = lib.types.enum [
473 The mode can be "file" or "database" that defines
474 where the ACL policies are stored and read from.
478 path = lib.mkOption {
479 type = lib.types.nullOr lib.types.path;
482 If the mode is set to "file", the path to a
483 HuJSON file containing ACL policies.
493 imports = with lib; [
494 (mkRenamedOptionModule
495 [ "services" "headscale" "derp" "autoUpdate" ]
496 [ "services" "headscale" "settings" "derp" "auto_update_enable" ]
498 (mkRenamedOptionModule
499 [ "services" "headscale" "derp" "paths" ]
500 [ "services" "headscale" "settings" "derp" "paths" ]
502 (mkRenamedOptionModule
503 [ "services" "headscale" "derp" "updateFrequency" ]
504 [ "services" "headscale" "settings" "derp" "update_frequency" ]
506 (mkRenamedOptionModule
507 [ "services" "headscale" "derp" "urls" ]
508 [ "services" "headscale" "settings" "derp" "urls" ]
510 (mkRenamedOptionModule
511 [ "services" "headscale" "ephemeralNodeInactivityTimeout" ]
512 [ "services" "headscale" "settings" "ephemeral_node_inactivity_timeout" ]
514 (mkRenamedOptionModule
515 [ "services" "headscale" "logLevel" ]
516 [ "services" "headscale" "settings" "log" "level" ]
518 (mkRenamedOptionModule
519 [ "services" "headscale" "openIdConnect" "clientId" ]
520 [ "services" "headscale" "settings" "oidc" "client_id" ]
522 (mkRenamedOptionModule
523 [ "services" "headscale" "openIdConnect" "clientSecretFile" ]
524 [ "services" "headscale" "settings" "oidc" "client_secret_path" ]
526 (mkRenamedOptionModule
527 [ "services" "headscale" "openIdConnect" "issuer" ]
528 [ "services" "headscale" "settings" "oidc" "issuer" ]
530 (mkRenamedOptionModule
531 [ "services" "headscale" "serverUrl" ]
532 [ "services" "headscale" "settings" "server_url" ]
534 (mkRenamedOptionModule
535 [ "services" "headscale" "tls" "certFile" ]
536 [ "services" "headscale" "settings" "tls_cert_path" ]
538 (mkRenamedOptionModule
539 [ "services" "headscale" "tls" "keyFile" ]
540 [ "services" "headscale" "settings" "tls_key_path" ]
542 (mkRenamedOptionModule
543 [ "services" "headscale" "tls" "letsencrypt" "challengeType" ]
544 [ "services" "headscale" "settings" "tls_letsencrypt_challenge_type" ]
546 (mkRenamedOptionModule
547 [ "services" "headscale" "tls" "letsencrypt" "hostname" ]
548 [ "services" "headscale" "settings" "tls_letsencrypt_hostname" ]
550 (mkRenamedOptionModule
551 [ "services" "headscale" "tls" "letsencrypt" "httpListen" ]
552 [ "services" "headscale" "settings" "tls_letsencrypt_listen" ]
555 (mkRemovedOptionModule [ "services" "headscale" "openIdConnect" "domainMap" ] ''
556 Headscale no longer uses domain_map. If you're using an old version of headscale you can still set this option via services.headscale.settings.oidc.domain_map.
560 config = lib.mkIf cfg.enable {
563 # This is stricter than it needs to be but is exactly what upstream does:
564 # https://github.com/kradalby/headscale/blob/adc084f20f843d7963c999764fa83939668d2d2c/hscontrol/types/config.go#L799
567 dns.use_username_in_magic_dns or false
568 || dns.base_domain == ""
569 || !lib.hasInfix dns.base_domain server_url;
570 message = "server_url cannot contain the base_domain, this will cause the headscale server and embedded DERP to become unreachable from the Tailscale node.";
573 assertion = with cfg.settings; dns.magic_dns -> dns.base_domain != "";
574 message = "dns.base_domain must be set when using MagicDNS";
576 (assertRemovedOption [ "settings" "acl_policy_path" ] "Use `policy.path` instead.")
577 (assertRemovedOption [ "settings" "db_host" ] "Use `database.postgres.host` instead.")
578 (assertRemovedOption [ "settings" "db_name" ] "Use `database.postgres.name` instead.")
579 (assertRemovedOption [
582 ] "Use `database.postgres.password_file` instead.")
583 (assertRemovedOption [ "settings" "db_path" ] "Use `database.sqlite.path` instead.")
584 (assertRemovedOption [ "settings" "db_port" ] "Use `database.postgres.port` instead.")
585 (assertRemovedOption [ "settings" "db_type" ] "Use `database.type` instead.")
586 (assertRemovedOption [ "settings" "db_user" ] "Use `database.postgres.user` instead.")
587 (assertRemovedOption [ "settings" "dns_config" ] "Use `dns` instead.")
588 (assertRemovedOption [ "settings" "dns_config" "domains" ] "Use `dns.search_domains` instead.")
589 (assertRemovedOption [
593 ] "Use `dns.nameservers.global` instead.")
596 services.headscale.settings = lib.mkMerge [
599 listen_addr = lib.mkDefault "${cfg.address}:${toString cfg.port}";
601 tls_letsencrypt_cache_dir = "${dataDir}/.cache";
606 # Headscale CLI needs a minimal config to be able to locate the unix socket
607 # to talk to the server instance.
608 etc."headscale/config.yaml".source = cliConfigFile;
610 systemPackages = [ cfg.package ];
613 users.groups.headscale = lib.mkIf (cfg.group == "headscale") { };
615 users.users.headscale = lib.mkIf (cfg.user == "headscale") {
616 description = "headscale user";
622 systemd.services.headscale = {
623 description = "headscale coordination server for Tailscale";
624 wants = [ "network-online.target" ];
625 after = [ "network-online.target" ];
626 wantedBy = [ "multi-user.target" ];
629 ${lib.optionalString (cfg.settings.database.postgres.password_file != null) ''
630 export HEADSCALE_DATABASE_POSTGRES_PASS="$(head -n1 ${lib.escapeShellArg cfg.settings.database.postgres.password_file})"
633 exec ${lib.getExe cfg.package} serve --config ${configFile}
638 capabilityBoundingSet = [ "CAP_CHOWN" ] ++ lib.optional (cfg.port < 1024) "CAP_NET_BIND_SERVICE";
647 RuntimeDirectory = "headscale";
648 # Allow headscale group access so users can be added and use the CLI.
649 RuntimeDirectoryMode = "0750";
651 StateDirectory = "headscale";
652 StateDirectoryMode = "0750";
654 ProtectSystem = "strict";
657 PrivateDevices = true;
658 ProtectKernelTunables = true;
659 ProtectControlGroups = true;
660 RestrictSUIDSGID = true;
661 PrivateMounts = true;
662 ProtectKernelModules = true;
663 ProtectKernelLogs = true;
664 ProtectHostname = true;
666 ProtectProc = "invisible";
668 RestrictNamespaces = true;
672 CapabilityBoundingSet = capabilityBoundingSet;
673 AmbientCapabilities = capabilityBoundingSet;
674 NoNewPrivileges = true;
675 LockPersonality = true;
676 RestrictRealtime = true;
682 SystemCallArchitectures = "native";
683 RestrictAddressFamilies = "AF_INET AF_INET6 AF_UNIX";
688 meta.maintainers = with lib.maintainers; [