{ config, lib, pkgs, ... }: with lib; let cfg = config.services.caddy; virtualHosts = attrValues cfg.virtualHosts; acmeVHosts = filter (hostOpts: hostOpts.useACMEHost != null) virtualHosts; mkVHostConf = hostOpts: let sslCertDir = config.security.acme.certs.${hostOpts.useACMEHost}.directory; in '' ${hostOpts.hostName} ${concatStringsSep " " hostOpts.serverAliases} { bind ${concatStringsSep " " hostOpts.listenAddresses} ${optionalString (hostOpts.useACMEHost != null) "tls ${sslCertDir}/cert.pem ${sslCertDir}/key.pem"} log { ${hostOpts.logFormat} } ${hostOpts.extraConfig} } ''; configFile = let Caddyfile = pkgs.writeText "Caddyfile" '' { ${cfg.globalConfig} } ${cfg.extraConfig} ''; Caddyfile-formatted = pkgs.runCommand "Caddyfile-formatted" { nativeBuildInputs = [ cfg.package ]; } '' ${cfg.package}/bin/caddy fmt ${Caddyfile} > $out ''; in if pkgs.stdenv.buildPlatform == pkgs.stdenv.hostPlatform then Caddyfile-formatted else Caddyfile; acmeHosts = unique (catAttrs "useACMEHost" acmeVHosts); mkCertOwnershipAssertion = import ../../../security/acme/mk-cert-ownership-assertion.nix; in { imports = [ (mkRemovedOptionModule [ "services" "caddy" "agree" ] "this option is no longer necessary for Caddy 2") (mkRenamedOptionModule [ "services" "caddy" "ca" ] [ "services" "caddy" "acmeCA" ]) (mkRenamedOptionModule [ "services" "caddy" "config" ] [ "services" "caddy" "extraConfig" ]) ]; # interface options.services.caddy = { enable = mkEnableOption "Caddy web server"; user = mkOption { default = "caddy"; type = types.str; description = '' User account under which caddy runs. If left as the default value this user will automatically be created on system activation, otherwise you are responsible for ensuring the user exists before the Caddy service starts. ''; }; group = mkOption { default = "caddy"; type = types.str; description = '' Group account under which caddy runs. If left as the default value this user will automatically be created on system activation, otherwise you are responsible for ensuring the user exists before the Caddy service starts. ''; }; package = mkOption { default = pkgs.caddy; defaultText = literalExpression "pkgs.caddy"; type = types.package; description = '' Caddy package to use. ''; }; dataDir = mkOption { type = types.path; default = "/var/lib/caddy"; description = '' The data directory for caddy. If left as the default value this directory will automatically be created before the Caddy server starts, otherwise you are responsible for ensuring the directory exists with appropriate ownership and permissions. Caddy v2 replaced CADDYPATH with XDG directories. See . ''; }; logDir = mkOption { type = types.path; default = "/var/log/caddy"; description = '' Directory for storing Caddy access logs. If left as the default value this directory will automatically be created before the Caddy server starts, otherwise the sysadmin is responsible for ensuring the directory exists with appropriate ownership and permissions. ''; }; logFormat = mkOption { type = types.lines; default = '' level ERROR ''; example = literalExpression '' mkForce "level INFO"; ''; description = '' Configuration for the default logger. See for details. ''; }; configFile = mkOption { type = types.path; default = configFile; defaultText = "A Caddyfile automatically generated by values from services.caddy.*"; example = literalExpression '' pkgs.writeText "Caddyfile" ''' example.com root * /var/www/wordpress php_fastcgi unix//run/php/php-version-fpm.sock file_server '''; ''; description = '' Override the configuration file used by Caddy. By default, NixOS generates one automatically. ''; }; adapter = mkOption { default = "caddyfile"; example = "nginx"; type = types.str; description = '' Name of the config adapter to use. See for the full list. Any value other than caddyfile is only valid when providing your own . ''; }; resume = mkOption { default = false; type = types.bool; description = '' Use saved config, if any (and prefer over any specified configuration passed with --config). ''; }; globalConfig = mkOption { type = types.lines; default = ""; example = '' debug servers { protocol { experimental_http3 } } ''; description = '' Additional lines of configuration appended to the global config section of the Caddyfile. Refer to for details on supported values. ''; }; extraConfig = mkOption { type = types.lines; default = ""; example = '' example.com { encode gzip log root /srv/http } ''; description = '' Additional lines of configuration appended to the automatically generated Caddyfile. ''; }; virtualHosts = mkOption { type = with types; attrsOf (submodule (import ./vhost-options.nix { inherit cfg; })); default = {}; example = literalExpression '' { "hydra.example.com" = { serverAliases = [ "www.hydra.example.com" ]; extraConfig = ''' encode gzip root /srv/http '''; }; }; ''; description = '' Declarative specification of virtual hosts served by Caddy. ''; }; acmeCA = mkOption { default = "https://acme-v02.api.letsencrypt.org/directory"; example = "https://acme-staging-v02.api.letsencrypt.org/directory"; type = with types; nullOr str; description = '' The URL to the ACME CA's directory. It is strongly recommended to set this to Let's Encrypt's staging endpoint for testing or development. Set it to null if you want to write a more fine-grained configuration manually. ''; }; email = mkOption { default = null; type = with types; nullOr str; description = '' Your email address. Mainly used when creating an ACME account with your CA, and is highly recommended in case there are problems with your certificates. ''; }; }; # implementation config = mkIf cfg.enable { assertions = [ { assertion = cfg.adapter != "caddyfile" -> cfg.configFile != configFile; message = "Any value other than 'caddyfile' is only valid when providing your own `services.caddy.configFile`"; } ] ++ map (name: mkCertOwnershipAssertion { inherit (cfg) group user; cert = config.security.acme.certs.${name}; groups = config.users.groups; }) acmeHosts; services.caddy.extraConfig = concatMapStringsSep "\n" mkVHostConf virtualHosts; services.caddy.globalConfig = '' ${optionalString (cfg.email != null) "email ${cfg.email}"} ${optionalString (cfg.acmeCA != null) "acme_ca ${cfg.acmeCA}"} log { ${cfg.logFormat} } ''; systemd.packages = [ cfg.package ]; systemd.services.caddy = { wants = map (hostOpts: "acme-finished-${hostOpts.useACMEHost}.target") acmeVHosts; after = map (hostOpts: "acme-selfsigned-${hostOpts.useACMEHost}.service") acmeVHosts; before = map (hostOpts: "acme-${hostOpts.useACMEHost}.service") acmeVHosts; wantedBy = [ "multi-user.target" ]; startLimitIntervalSec = 14400; startLimitBurst = 10; serviceConfig = { # https://www.freedesktop.org/software/systemd/man/systemd.service.html#ExecStart= # If the empty string is assigned to this option, the list of commands to start is reset, prior assignments of this option will have no effect. ExecStart = [ "" "${cfg.package}/bin/caddy run --config ${cfg.configFile} --adapter ${cfg.adapter} ${optionalString cfg.resume "--resume"}" ]; ExecReload = [ "" "${cfg.package}/bin/caddy reload --config ${cfg.configFile} --adapter ${cfg.adapter}" ]; ExecStartPre = "${cfg.package}/bin/caddy validate --config ${cfg.configFile} --adapter ${cfg.adapter}"; User = cfg.user; Group = cfg.group; ReadWriteDirectories = cfg.dataDir; StateDirectory = mkIf (cfg.dataDir == "/var/lib/caddy") [ "caddy" ]; LogsDirectory = mkIf (cfg.logDir == "/var/log/caddy") [ "caddy" ]; Restart = "on-abnormal"; SupplementaryGroups = mkIf (length acmeVHosts != 0) [ "acme" ]; # TODO: attempt to upstream these options NoNewPrivileges = true; PrivateDevices = true; ProtectHome = true; }; }; users.users = optionalAttrs (cfg.user == "caddy") { caddy = { group = cfg.group; uid = config.ids.uids.caddy; home = cfg.dataDir; }; }; users.groups = optionalAttrs (cfg.group == "caddy") { caddy.gid = config.ids.gids.caddy; }; security.acme.certs = let reloads = map (useACMEHost: nameValuePair useACMEHost { reloadServices = [ "caddy.service" ]; }) acmeHosts; in listToAttrs reloads; }; }