about summary refs log tree commit diff
path: root/nixos
diff options
context:
space:
mode:
Diffstat (limited to 'nixos')
-rw-r--r--nixos/doc/manual/release-notes/rl-2305.section.md8
-rw-r--r--nixos/modules/module-list.nix4
-rw-r--r--nixos/modules/programs/neovim.nix47
-rw-r--r--nixos/modules/programs/tmux.nix5
-rw-r--r--nixos/modules/services/backup/borgmatic.nix3
-rw-r--r--nixos/modules/services/home-automation/esphome.nix136
-rw-r--r--nixos/modules/services/misc/pufferpanel.nix176
-rw-r--r--nixos/modules/services/networking/ivpn.nix51
-rw-r--r--nixos/modules/services/networking/wstunnel.nix4
-rw-r--r--nixos/modules/services/system/cachix-watch-store.nix6
-rw-r--r--nixos/modules/services/web-apps/monica.nix468
-rw-r--r--nixos/modules/services/web-apps/plausible.nix14
-rw-r--r--nixos/tests/all-tests.nix3
-rw-r--r--nixos/tests/esphome.nix41
-rw-r--r--nixos/tests/pufferpanel.nix74
-rw-r--r--nixos/tests/web-apps/monica.nix33
16 files changed, 1038 insertions, 35 deletions
diff --git a/nixos/doc/manual/release-notes/rl-2305.section.md b/nixos/doc/manual/release-notes/rl-2305.section.md
index 31bf126787758..10224db34288c 100644
--- a/nixos/doc/manual/release-notes/rl-2305.section.md
+++ b/nixos/doc/manual/release-notes/rl-2305.section.md
@@ -63,6 +63,8 @@ In addition to numerous new and upgraded packages, this release has the followin
 
 - [opensearch](https://opensearch.org), a search server alternative to Elasticsearch. Available as [services.opensearch](options.html#opt-services.opensearch.enable).
 
+- [monica](https://www.monicahq.com), an open source personal CRM. Available as [services.monica](options.html#opt-services.monica.enable).
+
 - [authelia](https://www.authelia.com/), is an open-source authentication and authorization server. Available under [services.authelia](options.html#opt-services.authelia.enable).
 
 - [goeland](https://github.com/slurdge/goeland), an alternative to rss2email written in golang with many filters. Available as [services.goeland](#opt-services.goeland.enable).
@@ -73,6 +75,8 @@ In addition to numerous new and upgraded packages, this release has the followin
 
 - [atuin](https://github.com/ellie/atuin), a sync server for shell history. Available as [services.atuin](#opt-services.atuin.enable).
 
+- [esphome](https://esphome.io), a dashboard to configure ESP8266/ESP32 devices for use with Home Automation systems. Available as [services.esphome](#opt-services.esphome.enable).
+
 - [networkd-dispatcher](https://gitlab.com/craftyguy/networkd-dispatcher), a dispatcher service for systemd-networkd connection status changes. Available as [services.networkd-dispatcher](#opt-services.networkd-dispatcher.enable).
 
 - [mmsd](https://gitlab.com/kop316/mmsd), a lower level daemon that transmits and recieves MMSes. Available as [services.mmsd](#opt-services.mmsd.enable).
@@ -87,6 +91,8 @@ In addition to numerous new and upgraded packages, this release has the followin
 
 - [ulogd](https://www.netfilter.org/projects/ulogd/index.html), a userspace logging daemon for netfilter/iptables related logging. Available as [services.ulogd](options.html#opt-services.ulogd.enable).
 
+- [PufferPanel](https://pufferpanel.com), game server management panel designed to be easy to use. Available as [services.pufferpanel](#opt-services.pufferpanel.enable).
+
 - [jellyseerr](https://github.com/Fallenbagel/jellyseerr), a web-based requests manager for Jellyfin, forked from Overseerr. Available as [services.jellyseerr](#opt-services.jellyseerr.enable).
 
 - [photoprism](https://photoprism.app/), a AI-Powered Photos App for the Decentralized Web. Available as [services.photoprism](options.html#opt-services.photoprism.enable).
@@ -113,6 +119,8 @@ In addition to numerous new and upgraded packages, this release has the followin
 
 - [hardware.ipu6](#opt-hardware.ipu6.enable) adds support for ipu6 based webcams on intel tiger lake and alder lake.
 
+- [ivpn](https://www.ivpn.net/), a secure, private VPN with fast WireGuard connections. Available as [services.ivpn](#opt-services.ivpn.enable).
+
 ## Backward Incompatibilities {#sec-release-23.05-incompatibilities}
 
 <!-- To avoid merge conflicts, consider adding your item at an arbitrary place in the list instead. -->
diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix
index 6eb3e25b6c6fb..f1c459f755708 100644
--- a/nixos/modules/module-list.nix
+++ b/nixos/modules/module-list.nix
@@ -515,6 +515,7 @@
   ./services/hardware/usbrelayd.nix
   ./services/hardware/vdr.nix
   ./services/hardware/keyd.nix
+  ./services/home-automation/esphome.nix
   ./services/home-automation/evcc.nix
   ./services/home-automation/home-assistant.nix
   ./services/home-automation/zigbee2mqtt.nix
@@ -669,6 +670,7 @@
   ./services/misc/polaris.nix
   ./services/misc/portunus.nix
   ./services/misc/prowlarr.nix
+  ./services/misc/pufferpanel.nix
   ./services/misc/pykms.nix
   ./services/misc/radarr.nix
   ./services/misc/readarr.nix
@@ -883,6 +885,7 @@
   ./services/networking/iscsi/initiator.nix
   ./services/networking/iscsi/root-initiator.nix
   ./services/networking/iscsi/target.nix
+  ./services/networking/ivpn.nix
   ./services/networking/iwd.nix
   ./services/networking/jibri/default.nix
   ./services/networking/jicofo.nix
@@ -1182,6 +1185,7 @@
   ./services/web-apps/mattermost.nix
   ./services/web-apps/mediawiki.nix
   ./services/web-apps/miniflux.nix
+  ./services/web-apps/monica.nix
   ./services/web-apps/moodle.nix
   ./services/web-apps/netbox.nix
   ./services/web-apps/nextcloud.nix
diff --git a/nixos/modules/programs/neovim.nix b/nixos/modules/programs/neovim.nix
index 4562e5a2c29b8..3f0e9fc173bdf 100644
--- a/nixos/modules/programs/neovim.nix
+++ b/nixos/modules/programs/neovim.nix
@@ -4,12 +4,8 @@ with lib;
 
 let
   cfg = config.programs.neovim;
-
-  runtime' = filter (f: f.enable) (attrValues cfg.runtime);
-
-  runtime = pkgs.linkFarm "neovim-runtime" (map (x: { name = "etc/${x.target}"; path = x.source; }) runtime');
-
-in {
+in
+{
   options.programs.neovim = {
     enable = mkOption {
       type = types.bool;
@@ -70,7 +66,7 @@ in {
 
     configure = mkOption {
       type = types.attrs;
-      default = {};
+      default = { };
       example = literalExpression ''
         {
           customRC = '''
@@ -105,7 +101,7 @@ in {
     };
 
     runtime = mkOption {
-      default = {};
+      default = { };
       example = literalExpression ''
         { "ftplugin/c.vim".text = "setlocal omnifunc=v:lua.vim.lsp.omnifunc"; }
       '';
@@ -115,14 +111,15 @@ in {
 
       type = with types; attrsOf (submodule (
         { name, config, ... }:
-        { options = {
+        {
+          options = {
 
             enable = mkOption {
               type = types.bool;
               default = true;
               description = lib.mdDoc ''
-                Whether this /etc file should be generated.  This
-                option allows specific /etc files to be disabled.
+                Whether this runtime directory should be generated.  This
+                option allows specific runtime files to be disabled.
               '';
             };
 
@@ -147,14 +144,9 @@ in {
 
           };
 
-          config = {
-            target = mkDefault name;
-            source = mkIf (config.text != null) (
-              let name' = "neovim-runtime" + baseNameOf name;
-              in mkDefault (pkgs.writeText name' config.text));
-          };
-
-        }));
+          config.target = mkDefault name;
+        }
+      ));
 
     };
   };
@@ -165,14 +157,17 @@ in {
     ];
     environment.variables.EDITOR = mkIf cfg.defaultEditor (mkOverride 900 "nvim");
 
-    programs.neovim.finalPackage = pkgs.wrapNeovim cfg.package {
-      inherit (cfg) viAlias vimAlias withPython3 withNodeJs withRuby;
-      configure = cfg.configure // {
+    environment.etc = listToAttrs (attrValues (mapAttrs
+      (name: value: {
+        name = "xdg/nvim/${name}";
+        value = value // {
+          target = "xdg/nvim/${value.target}";
+        };
+      })
+      cfg.runtime));
 
-        customRC = (cfg.configure.customRC or "") + ''
-          set runtimepath^=${runtime}/etc
-        '';
-      };
+    programs.neovim.finalPackage = pkgs.wrapNeovim cfg.package {
+      inherit (cfg) viAlias vimAlias withPython3 withNodeJs withRuby configure;
     };
   };
 }
diff --git a/nixos/modules/programs/tmux.nix b/nixos/modules/programs/tmux.nix
index 4fb9175fb8d21..58c9cbc3ed003 100644
--- a/nixos/modules/programs/tmux.nix
+++ b/nixos/modules/programs/tmux.nix
@@ -160,7 +160,10 @@ in {
         default = defaultTerminal;
         example = "screen-256color";
         type = types.str;
-        description = lib.mdDoc "Set the $TERM variable.";
+        description = lib.mdDoc ''
+          Set the $TERM variable. Use tmux-direct if italics or 24bit true color
+          support is needed.
+        '';
       };
 
       secureSocket = mkOption {
diff --git a/nixos/modules/services/backup/borgmatic.nix b/nixos/modules/services/backup/borgmatic.nix
index e7cd6ae4bb573..5ee036e68c7bc 100644
--- a/nixos/modules/services/backup/borgmatic.nix
+++ b/nixos/modules/services/backup/borgmatic.nix
@@ -72,5 +72,8 @@ in
         cfg.configurations;
 
     systemd.packages = [ pkgs.borgmatic ];
+
+    # Workaround: https://github.com/NixOS/nixpkgs/issues/81138
+    systemd.timers.borgmatic.wantedBy = [ "timers.target" ];
   };
 }
diff --git a/nixos/modules/services/home-automation/esphome.nix b/nixos/modules/services/home-automation/esphome.nix
new file mode 100644
index 0000000000000..d7dbb6f0b90e3
--- /dev/null
+++ b/nixos/modules/services/home-automation/esphome.nix
@@ -0,0 +1,136 @@
+{ config, lib, pkgs, ... }:
+
+let
+  inherit (lib)
+    literalExpression
+    maintainers
+    mkEnableOption
+    mkIf
+    mkOption
+    mdDoc
+    types
+    ;
+
+  cfg = config.services.esphome;
+
+  stateDir = "/var/lib/esphome";
+
+  esphomeParams =
+    if cfg.enableUnixSocket
+    then "--socket /run/esphome/esphome.sock"
+    else "--address ${cfg.address} --port ${toString cfg.port}";
+in
+{
+  meta.maintainers = with maintainers; [ oddlama ];
+
+  options.services.esphome = {
+    enable = mkEnableOption (mdDoc "esphome");
+
+    package = mkOption {
+      type = types.package;
+      default = pkgs.esphome;
+      defaultText = literalExpression "pkgs.esphome";
+      description = mdDoc "The package to use for the esphome command.";
+    };
+
+    enableUnixSocket = mkOption {
+      type = types.bool;
+      default = false;
+      description = lib.mdDoc "Listen on a unix socket `/run/esphome/esphome.sock` instead of the TCP port.";
+    };
+
+    address = mkOption {
+      type = types.str;
+      default = "localhost";
+      description = mdDoc "esphome address";
+    };
+
+    port = mkOption {
+      type = types.port;
+      default = 6052;
+      description = mdDoc "esphome port";
+    };
+
+    openFirewall = mkOption {
+      default = false;
+      type = types.bool;
+      description = mdDoc "Whether to open the firewall for the specified port.";
+    };
+
+    allowedDevices = mkOption {
+      default = ["char-ttyS" "char-ttyUSB"];
+      example = ["/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0"];
+      description = lib.mdDoc ''
+        A list of device nodes to which {command}`esphome` has access to.
+        Refer to DeviceAllow in systemd.resource-control(5) for more information.
+        Beware that if a device is referred to by an absolute path instead of a device category,
+        it will only allow devices that already are plugged in when the service is started.
+      '';
+      type = types.listOf types.str;
+    };
+  };
+
+  config = mkIf cfg.enable {
+    networking.firewall.allowedTCPPorts = mkIf (cfg.openFirewall && !cfg.enableUnixSocket) [cfg.port];
+
+    systemd.services.esphome = {
+      description = "ESPHome dashboard";
+      after = ["network.target"];
+      wantedBy = ["multi-user.target"];
+      path = [cfg.package];
+
+      # platformio fails to determine the home directory when using DynamicUser
+      environment.PLATFORMIO_CORE_DIR = "${stateDir}/.platformio";
+
+      serviceConfig = {
+        ExecStart = "${cfg.package}/bin/esphome dashboard ${esphomeParams} ${stateDir}";
+        DynamicUser = true;
+        User = "esphome";
+        Group = "esphome";
+        WorkingDirectory = stateDir;
+        StateDirectory = "esphome";
+        StateDirectoryMode = "0750";
+        Restart = "on-failure";
+        RuntimeDirectory = mkIf cfg.enableUnixSocket "esphome";
+        RuntimeDirectoryMode = "0750";
+
+        # Hardening
+        CapabilityBoundingSet = "";
+        LockPersonality = true;
+        MemoryDenyWriteExecute = true;
+        DevicePolicy = "closed";
+        DeviceAllow = map (d: "${d} rw") cfg.allowedDevices;
+        SupplementaryGroups = ["dialout"];
+        #NoNewPrivileges = true; # Implied by DynamicUser
+        PrivateUsers = true;
+        #PrivateTmp = true; # Implied by DynamicUser
+        ProtectClock = true;
+        ProtectControlGroups = true;
+        ProtectHome = true;
+        ProtectHostname = true;
+        ProtectKernelLogs = true;
+        ProtectKernelModules = true;
+        ProtectKernelTunables = true;
+        ProtectProc = "invisible";
+        ProcSubset = "pid";
+        ProtectSystem = "strict";
+        #RemoveIPC = true; # Implied by DynamicUser
+        RestrictAddressFamilies = [
+          "AF_INET"
+          "AF_INET6"
+          "AF_NETLINK"
+          "AF_UNIX"
+        ];
+        RestrictNamespaces = false; # Required by platformio for chroot
+        RestrictRealtime = true;
+        #RestrictSUIDSGID = true; # Implied by DynamicUser
+        SystemCallArchitectures = "native";
+        SystemCallFilter = [
+          "@system-service"
+          "@mount" # Required by platformio for chroot
+        ];
+        UMask = "0077";
+      };
+    };
+  };
+}
diff --git a/nixos/modules/services/misc/pufferpanel.nix b/nixos/modules/services/misc/pufferpanel.nix
new file mode 100644
index 0000000000000..78ec356469076
--- /dev/null
+++ b/nixos/modules/services/misc/pufferpanel.nix
@@ -0,0 +1,176 @@
+{ config, pkgs, lib, ... }:
+let
+  cfg = config.services.pufferpanel;
+in
+{
+  options.services.pufferpanel = {
+    enable = lib.mkOption {
+      type = lib.types.bool;
+      default = false;
+      description = lib.mdDoc ''
+        Whether to enable PufferPanel game management server.
+
+        Note that [PufferPanel templates] and binaries downloaded by PufferPanel
+        expect [FHS environment]. It is possible to set {option}`package` option
+        to use PufferPanel wrapper with FHS environment. For example, to use
+        `Download Game from Steam` and `Download Java` template operations:
+        ```Nix
+        { lib, pkgs, ... }: {
+          services.pufferpanel = {
+            enable = true;
+            extraPackages = with pkgs; [ bash curl gawk gnutar gzip ];
+            package = pkgs.buildFHSUserEnv {
+              name = "pufferpanel-fhs";
+              runScript = lib.getExe pkgs.pufferpanel;
+              targetPkgs = pkgs': with pkgs'; [ icu openssl zlib ];
+            };
+          };
+        }
+        ```
+
+        [PufferPanel templates]: https://github.com/PufferPanel/templates
+        [FHS environment]: https://wikipedia.org/wiki/Filesystem_Hierarchy_Standard
+      '';
+    };
+
+    package = lib.mkPackageOptionMD pkgs "pufferpanel" { };
+
+    extraGroups = lib.mkOption {
+      type = lib.types.listOf lib.types.str;
+      default = [ ];
+      example = [ "podman" ];
+      description = lib.mdDoc ''
+        Additional groups for the systemd service.
+      '';
+    };
+
+    extraPackages = lib.mkOption {
+      type = lib.types.listOf lib.types.package;
+      default = [ ];
+      example = lib.literalExpression "[ pkgs.jre ]";
+      description = lib.mdDoc ''
+        Packages to add to the PATH environment variable. Both the {file}`bin`
+        and {file}`sbin` subdirectories of each package are added.
+      '';
+    };
+
+    environment = lib.mkOption {
+      type = lib.types.attrsOf lib.types.str;
+      default = { };
+      example = lib.literalExpression ''
+        {
+          PUFFER_WEB_HOST = ":8080";
+          PUFFER_DAEMON_SFTP_HOST = ":5657";
+          PUFFER_DAEMON_CONSOLE_BUFFER = "1000";
+          PUFFER_DAEMON_CONSOLE_FORWARD = "true";
+          PUFFER_PANEL_REGISTRATIONENABLED = "false";
+        }
+      '';
+      description = lib.mdDoc ''
+        Environment variables to set for the service. Secrets should be
+        specified using {option}`environmentFile`.
+
+        Refer to the [PufferPanel source code][] for the list of available
+        configuration options. Variable name is an upper-cased configuration
+        entry name with underscores instead of dots, prefixed with `PUFFER_`.
+        For example, `panel.settings.companyName` entry can be set using
+        {env}`PUFFER_PANEL_SETTINGS_COMPANYNAME`.
+
+        When running with panel enabled (configured with `PUFFER_PANEL_ENABLE`
+        environment variable), it is recommended disable registration using
+        `PUFFER_PANEL_REGISTRATIONENABLED` environment variable (registration is
+        enabled by default). To create the initial administrator user, run
+        {command}`pufferpanel --workDir /var/lib/pufferpanel user add --admin`.
+
+        Some options override corresponding settings set via web interface (e.g.
+        `PUFFER_PANEL_REGISTRATIONENABLED`). Those options can be temporarily
+        toggled or set in settings but do not persist between restarts.
+
+        [PufferPanel source code]: https://github.com/PufferPanel/PufferPanel/blob/master/config/entries.go
+      '';
+    };
+
+    environmentFile = lib.mkOption {
+      type = lib.types.nullOr lib.types.path;
+      default = null;
+      description = lib.mdDoc ''
+        File to load environment variables from. Loaded variables override
+        values set in {option}`environment`.
+      '';
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    systemd.services.pufferpanel = {
+      description = "PufferPanel game management server";
+      wantedBy = [ "multi-user.target" ];
+      after = [ "network.target" ];
+
+      path = cfg.extraPackages;
+      environment = cfg.environment;
+
+      # Note that we export environment variables for service directories if the
+      # value is not set. An empty environment variable is considered to be set.
+      # E.g.
+      #   export PUFFER_LOGS=${PUFFER_LOGS-$LOGS_DIRECTORY}
+      # would set PUFFER_LOGS to $LOGS_DIRECTORY if PUFFER_LOGS environment
+      # variable is not defined.
+      script = ''
+        ${lib.concatLines (lib.mapAttrsToList (name: value: ''
+          export ${name}="''${${name}-${value}}"
+        '') {
+          PUFFER_LOGS = "$LOGS_DIRECTORY";
+          PUFFER_DAEMON_DATA_CACHE = "$CACHE_DIRECTORY";
+          PUFFER_DAEMON_DATA_SERVERS = "$STATE_DIRECTORY/servers";
+          PUFFER_DAEMON_DATA_BINARIES = "$STATE_DIRECTORY/binaries";
+        })}
+        exec ${lib.getExe cfg.package} run --workDir "$STATE_DIRECTORY"
+      '';
+
+      serviceConfig = {
+        Type = "simple";
+        Restart = "always";
+
+        UMask = "0077";
+
+        SupplementaryGroups = cfg.extraGroups;
+
+        StateDirectory = "pufferpanel";
+        StateDirectoryMode = "0700";
+        CacheDirectory = "pufferpanel";
+        CacheDirectoryMode = "0700";
+        LogsDirectory = "pufferpanel";
+        LogsDirectoryMode = "0700";
+
+        EnvironmentFile = cfg.environmentFile;
+
+        # Command "pufferpanel shutdown --pid $MAINPID" sends SIGTERM (code 15)
+        # to the main process and waits for termination. This is essentially
+        # KillMode=mixed we are using here. See
+        # https://freedesktop.org/software/systemd/man/systemd.kill.html#KillMode=
+        KillMode = "mixed";
+
+        DynamicUser = true;
+        ProtectHome = true;
+        ProtectProc = "invisible";
+        ProtectClock = true;
+        ProtectHostname = true;
+        ProtectControlGroups = true;
+        ProtectKernelLogs = true;
+        ProtectKernelModules = true;
+        ProtectKernelTunables = true;
+        PrivateUsers = true;
+        PrivateDevices = true;
+        RestrictRealtime = true;
+        RestrictNamespaces = [ "user" "mnt" ]; # allow buildFHSUserEnv
+        RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
+        LockPersonality = true;
+        DeviceAllow = [ "" ];
+        DevicePolicy = "closed";
+        CapabilityBoundingSet = [ "" ];
+      };
+    };
+  };
+
+  meta.maintainers = [ lib.maintainers.tie ];
+}
diff --git a/nixos/modules/services/networking/ivpn.nix b/nixos/modules/services/networking/ivpn.nix
new file mode 100644
index 0000000000000..6df630c1f1947
--- /dev/null
+++ b/nixos/modules/services/networking/ivpn.nix
@@ -0,0 +1,51 @@
+{ config, lib, pkgs, ... }:
+let
+  cfg = config.services.ivpn;
+in
+with lib;
+{
+  options.services.ivpn = {
+    enable = mkOption {
+      type = types.bool;
+      default = false;
+      description = lib.mdDoc ''
+        This option enables iVPN daemon.
+        This sets {option}`networking.firewall.checkReversePath` to "loose", which might be undesirable for security.
+      '';
+    };
+  };
+
+  config = mkIf cfg.enable {
+    boot.kernelModules = [ "tun" ];
+
+    environment.systemPackages = with pkgs; [ ivpn ivpn-service ];
+
+    # iVPN writes to /etc/iproute2/rt_tables
+    networking.iproute2.enable = true;
+    networking.firewall.checkReversePath = "loose";
+
+    systemd.services.ivpn-service = {
+      description = "iVPN daemon";
+      wantedBy = [ "multi-user.target" ];
+      wants = [ "network.target" ];
+      after = [
+        "network-online.target"
+        "NetworkManager.service"
+        "systemd-resolved.service"
+      ];
+      path = [
+        # Needed for mount
+        "/run/wrappers"
+      ];
+      startLimitBurst = 5;
+      startLimitIntervalSec = 20;
+      serviceConfig = {
+        ExecStart = "${pkgs.ivpn-service}/bin/ivpn-service --logging";
+        Restart = "always";
+        RestartSec = 1;
+      };
+    };
+  };
+
+  meta.maintainers = with maintainers; [ ataraxiasjel ];
+}
diff --git a/nixos/modules/services/networking/wstunnel.nix b/nixos/modules/services/networking/wstunnel.nix
index 440b617f60a39..067d5df487255 100644
--- a/nixos/modules/services/networking/wstunnel.nix
+++ b/nixos/modules/services/networking/wstunnel.nix
@@ -294,7 +294,7 @@ let
         DynamicUser = true;
         SupplementaryGroups = optional (serverCfg.useACMEHost != null) certConfig.group;
         PrivateTmp = true;
-        AmbientCapabilities = optional (serverCfg.listen.port < 1024) [ "CAP_NET_BIND_SERVICE" ];
+        AmbientCapabilities = optionals (serverCfg.listen.port < 1024) [ "CAP_NET_BIND_SERVICE" ];
         NoNewPrivileges = true;
         RestrictNamespaces = "uts ipc pid user cgroup";
         ProtectSystem = "strict";
@@ -340,7 +340,7 @@ let
         EnvironmentFile = optional (clientCfg.environmentFile != null) clientCfg.environmentFile;
         DynamicUser = true;
         PrivateTmp = true;
-        AmbientCapabilities = (optional (clientCfg.soMark != null) [ "CAP_NET_ADMIN" ]) ++ (optional ((clientCfg.dynamicToRemote.port or 1024) < 1024 || (any (x: x.local.port < 1024) clientCfg.localToRemote)) [ "CAP_NET_BIND_SERVICE" ]);
+        AmbientCapabilities = (optionals (clientCfg.soMark != null) [ "CAP_NET_ADMIN" ]) ++ (optionals ((clientCfg.dynamicToRemote.port or 1024) < 1024 || (any (x: x.local.port < 1024) clientCfg.localToRemote)) [ "CAP_NET_BIND_SERVICE" ]);
         NoNewPrivileges = true;
         RestrictNamespaces = "uts ipc pid user cgroup";
         ProtectSystem = "strict";
diff --git a/nixos/modules/services/system/cachix-watch-store.nix b/nixos/modules/services/system/cachix-watch-store.nix
index 85e9509bcc82d..89157b460b9a4 100644
--- a/nixos/modules/services/system/cachix-watch-store.nix
+++ b/nixos/modules/services/system/cachix-watch-store.nix
@@ -62,7 +62,13 @@ in
       after = [ "network-online.target" ];
       path = [ config.nix.package ];
       wantedBy = [ "multi-user.target" ];
+      unitConfig = {
+        # allow to restart indefinitely
+        StartLimitIntervalSec = 0;
+      };
       serviceConfig = {
+        # don't put too much stress on the machine when restarting
+        RestartSec = 1;
         # we don't want to kill children processes as those are deployments
         KillMode = "process";
         Restart = "on-failure";
diff --git a/nixos/modules/services/web-apps/monica.nix b/nixos/modules/services/web-apps/monica.nix
new file mode 100644
index 0000000000000..442044fedb14e
--- /dev/null
+++ b/nixos/modules/services/web-apps/monica.nix
@@ -0,0 +1,468 @@
+{
+  config,
+  lib,
+  pkgs,
+  ...
+}:
+with lib; let
+  cfg = config.services.monica;
+  monica = pkgs.monica.override {
+    dataDir = cfg.dataDir;
+  };
+  db = cfg.database;
+  mail = cfg.mail;
+
+  user = cfg.user;
+  group = cfg.group;
+
+  # shell script for local administration
+  artisan = pkgs.writeScriptBin "monica" ''
+    #! ${pkgs.runtimeShell}
+    cd ${monica}
+    sudo() {
+      if [[ "$USER" != ${user} ]]; then
+        exec /run/wrappers/bin/sudo -u ${user} "$@"
+      else
+        exec "$@"
+      fi
+    }
+    sudo ${pkgs.php}/bin/php artisan "$@"
+  '';
+
+  tlsEnabled = cfg.nginx.addSSL || cfg.nginx.forceSSL || cfg.nginx.onlySSL || cfg.nginx.enableACME;
+in {
+  options.services.monica = {
+    enable = mkEnableOption (lib.mdDoc "monica");
+
+    user = mkOption {
+      default = "monica";
+      description = lib.mdDoc "User monica runs as.";
+      type = types.str;
+    };
+
+    group = mkOption {
+      default = "monica";
+      description = lib.mdDoc "Group monica runs as.";
+      type = types.str;
+    };
+
+    appKeyFile = mkOption {
+      description = lib.mdDoc ''
+        A file containing the Laravel APP_KEY - a 32 character long,
+        base64 encoded key used for encryption where needed. Can be
+        generated with <code>head -c 32 /dev/urandom | base64</code>.
+      '';
+      example = "/run/keys/monica-appkey";
+      type = types.path;
+    };
+
+    hostname = lib.mkOption {
+      type = lib.types.str;
+      default =
+        if config.networking.domain != null
+        then config.networking.fqdn
+        else config.networking.hostName;
+      defaultText = lib.literalExpression "config.networking.fqdn";
+      example = "monica.example.com";
+      description = lib.mdDoc ''
+        The hostname to serve monica on.
+      '';
+    };
+
+    appURL = mkOption {
+      description = lib.mdDoc ''
+        The root URL that you want to host monica on. All URLs in monica will be generated using this value.
+        If you change this in the future you may need to run a command to update stored URLs in the database.
+        Command example: <code>php artisan monica:update-url https://old.example.com https://new.example.com</code>
+      '';
+      default = "http${lib.optionalString tlsEnabled "s"}://${cfg.hostname}";
+      defaultText = ''http''${lib.optionalString tlsEnabled "s"}://''${cfg.hostname}'';
+      example = "https://example.com";
+      type = types.str;
+    };
+
+    dataDir = mkOption {
+      description = lib.mdDoc "monica data directory";
+      default = "/var/lib/monica";
+      type = types.path;
+    };
+
+    database = {
+      host = mkOption {
+        type = types.str;
+        default = "localhost";
+        description = lib.mdDoc "Database host address.";
+      };
+      port = mkOption {
+        type = types.port;
+        default = 3306;
+        description = lib.mdDoc "Database host port.";
+      };
+      name = mkOption {
+        type = types.str;
+        default = "monica";
+        description = lib.mdDoc "Database name.";
+      };
+      user = mkOption {
+        type = types.str;
+        default = user;
+        defaultText = lib.literalExpression "user";
+        description = lib.mdDoc "Database username.";
+      };
+      passwordFile = mkOption {
+        type = with types; nullOr path;
+        default = null;
+        example = "/run/keys/monica-dbpassword";
+        description = lib.mdDoc ''
+          A file containing the password corresponding to
+          <option>database.user</option>.
+        '';
+      };
+      createLocally = mkOption {
+        type = types.bool;
+        default = true;
+        description = lib.mdDoc "Create the database and database user locally.";
+      };
+    };
+
+    mail = {
+      driver = mkOption {
+        type = types.enum ["smtp" "sendmail"];
+        default = "smtp";
+        description = lib.mdDoc "Mail driver to use.";
+      };
+      host = mkOption {
+        type = types.str;
+        default = "localhost";
+        description = lib.mdDoc "Mail host address.";
+      };
+      port = mkOption {
+        type = types.port;
+        default = 1025;
+        description = lib.mdDoc "Mail host port.";
+      };
+      fromName = mkOption {
+        type = types.str;
+        default = "monica";
+        description = lib.mdDoc "Mail \"from\" name.";
+      };
+      from = mkOption {
+        type = types.str;
+        default = "mail@monica.com";
+        description = lib.mdDoc "Mail \"from\" email.";
+      };
+      user = mkOption {
+        type = with types; nullOr str;
+        default = null;
+        example = "monica";
+        description = lib.mdDoc "Mail username.";
+      };
+      passwordFile = mkOption {
+        type = with types; nullOr path;
+        default = null;
+        example = "/run/keys/monica-mailpassword";
+        description = lib.mdDoc ''
+          A file containing the password corresponding to
+          <option>mail.user</option>.
+        '';
+      };
+      encryption = mkOption {
+        type = with types; nullOr (enum ["tls"]);
+        default = null;
+        description = lib.mdDoc "SMTP encryption mechanism to use.";
+      };
+    };
+
+    maxUploadSize = mkOption {
+      type = types.str;
+      default = "18M";
+      example = "1G";
+      description = lib.mdDoc "The maximum size for uploads (e.g. images).";
+    };
+
+    poolConfig = mkOption {
+      type = with types; attrsOf (oneOf [str int bool]);
+      default = {
+        "pm" = "dynamic";
+        "pm.max_children" = 32;
+        "pm.start_servers" = 2;
+        "pm.min_spare_servers" = 2;
+        "pm.max_spare_servers" = 4;
+        "pm.max_requests" = 500;
+      };
+      description = lib.mdDoc ''
+        Options for the monica PHP pool. See the documentation on <literal>php-fpm.conf</literal>
+        for details on configuration directives.
+      '';
+    };
+
+    nginx = mkOption {
+      type = types.submodule (
+        recursiveUpdate
+        (import ../web-servers/nginx/vhost-options.nix {inherit config lib;}) {}
+      );
+      default = {};
+      example = ''
+        {
+          serverAliases = [
+            "monica.''${config.networking.domain}"
+          ];
+          # To enable encryption and let let's encrypt take care of certificate
+          forceSSL = true;
+          enableACME = true;
+        }
+      '';
+      description = lib.mdDoc ''
+        With this option, you can customize the nginx virtualHost settings.
+      '';
+    };
+
+    config = mkOption {
+      type = with types;
+        attrsOf
+        (nullOr
+          (either
+            (oneOf [
+              bool
+              int
+              port
+              path
+              str
+            ])
+            (submodule {
+              options = {
+                _secret = mkOption {
+                  type = nullOr str;
+                  description = lib.mdDoc ''
+                    The path to a file containing the value the
+                    option should be set to in the final
+                    configuration file.
+                  '';
+                };
+              };
+            })));
+      default = {};
+      example = ''
+        {
+          ALLOWED_IFRAME_HOSTS = "https://example.com";
+          WKHTMLTOPDF = "/home/user/bins/wkhtmltopdf";
+          AUTH_METHOD = "oidc";
+          OIDC_NAME = "MyLogin";
+          OIDC_DISPLAY_NAME_CLAIMS = "name";
+          OIDC_CLIENT_ID = "monica";
+          OIDC_CLIENT_SECRET = {_secret = "/run/keys/oidc_secret"};
+          OIDC_ISSUER = "https://keycloak.example.com/auth/realms/My%20Realm";
+          OIDC_ISSUER_DISCOVER = true;
+        }
+      '';
+      description = lib.mdDoc ''
+        monica configuration options to set in the
+        <filename>.env</filename> file.
+
+        Refer to <link xlink:href="https://github.com/monicahq/monica"/>
+        for details on supported values.
+
+        Settings containing secret data should be set to an attribute
+        set containing the attribute <literal>_secret</literal> - a
+        string pointing to a file containing the value the option
+        should be set to. See the example to get a better picture of
+        this: in the resulting <filename>.env</filename> file, the
+        <literal>OIDC_CLIENT_SECRET</literal> key will be set to the
+        contents of the <filename>/run/keys/oidc_secret</filename>
+        file.
+      '';
+    };
+  };
+
+  config = mkIf cfg.enable {
+    assertions = [
+      {
+        assertion = db.createLocally -> db.user == user;
+        message = "services.monica.database.user must be set to ${user} if services.monica.database.createLocally is set true.";
+      }
+      {
+        assertion = db.createLocally -> db.passwordFile == null;
+        message = "services.monica.database.passwordFile cannot be specified if services.monica.database.createLocally is set to true.";
+      }
+    ];
+
+    services.monica.config = {
+      APP_ENV = "production";
+      APP_KEY._secret = cfg.appKeyFile;
+      APP_URL = cfg.appURL;
+      DB_HOST = db.host;
+      DB_PORT = db.port;
+      DB_DATABASE = db.name;
+      DB_USERNAME = db.user;
+      MAIL_DRIVER = mail.driver;
+      MAIL_FROM_NAME = mail.fromName;
+      MAIL_FROM = mail.from;
+      MAIL_HOST = mail.host;
+      MAIL_PORT = mail.port;
+      MAIL_USERNAME = mail.user;
+      MAIL_ENCRYPTION = mail.encryption;
+      DB_PASSWORD._secret = db.passwordFile;
+      MAIL_PASSWORD._secret = mail.passwordFile;
+      APP_SERVICES_CACHE = "/run/monica/cache/services.php";
+      APP_PACKAGES_CACHE = "/run/monica/cache/packages.php";
+      APP_CONFIG_CACHE = "/run/monica/cache/config.php";
+      APP_ROUTES_CACHE = "/run/monica/cache/routes-v7.php";
+      APP_EVENTS_CACHE = "/run/monica/cache/events.php";
+      SESSION_SECURE_COOKIE = tlsEnabled;
+    };
+
+    environment.systemPackages = [artisan];
+
+    services.mysql = mkIf db.createLocally {
+      enable = true;
+      package = mkDefault pkgs.mariadb;
+      ensureDatabases = [db.name];
+      ensureUsers = [
+        {
+          name = db.user;
+          ensurePermissions = {"${db.name}.*" = "ALL PRIVILEGES";};
+        }
+      ];
+    };
+
+    services.phpfpm.pools.monica = {
+      inherit user group;
+      phpOptions = ''
+        log_errors = on
+        post_max_size = ${cfg.maxUploadSize}
+        upload_max_filesize = ${cfg.maxUploadSize}
+      '';
+      settings = {
+        "listen.mode" = "0660";
+        "listen.owner" = user;
+        "listen.group" = group;
+      } // cfg.poolConfig;
+    };
+
+    services.nginx = {
+      enable = mkDefault true;
+      recommendedTlsSettings = true;
+      recommendedOptimisation = true;
+      recommendedGzipSettings = true;
+      recommendedBrotliSettings = true;
+      recommendedProxySettings = true;
+      virtualHosts.${cfg.hostname} = mkMerge [
+        cfg.nginx
+        {
+          root = mkForce "${monica}/public";
+          locations = {
+            "/" = {
+              index = "index.php";
+              tryFiles = "$uri $uri/ /index.php?$query_string";
+            };
+            "~ \.php$".extraConfig = ''
+              fastcgi_pass unix:${config.services.phpfpm.pools."monica".socket};
+            '';
+            "~ \.(js|css|gif|png|ico|jpg|jpeg)$" = {
+              extraConfig = "expires 365d;";
+            };
+          };
+        }
+      ];
+    };
+
+    systemd.services.monica-setup = {
+      description = "Preperation tasks for monica";
+      before = ["phpfpm-monica.service"];
+      after = optional db.createLocally "mysql.service";
+      wantedBy = ["multi-user.target"];
+      serviceConfig = {
+        Type = "oneshot";
+        RemainAfterExit = true;
+        User = user;
+        UMask = 077;
+        WorkingDirectory = "${monica}";
+        RuntimeDirectory = "monica/cache";
+        RuntimeDirectoryMode = 0700;
+      };
+      path = [pkgs.replace-secret];
+      script = let
+        isSecret = v: isAttrs v && v ? _secret && isString v._secret;
+        monicaEnvVars = lib.generators.toKeyValue {
+          mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" {
+            mkValueString = v:
+              with builtins;
+                if isInt v
+                then toString v
+                else if isString v
+                then v
+                else if true == v
+                then "true"
+                else if false == v
+                then "false"
+                else if isSecret v
+                then hashString "sha256" v._secret
+                else throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty {}) v}";
+          };
+        };
+        secretPaths = lib.mapAttrsToList (_: v: v._secret) (lib.filterAttrs (_: isSecret) cfg.config);
+        mkSecretReplacement = file: ''
+          replace-secret ${escapeShellArgs [(builtins.hashString "sha256" file) file "${cfg.dataDir}/.env"]}
+        '';
+        secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths;
+        filteredConfig = lib.converge (lib.filterAttrsRecursive (_: v: ! elem v [{} null])) cfg.config;
+        monicaEnv = pkgs.writeText "monica.env" (monicaEnvVars filteredConfig);
+      in ''
+        # error handling
+        set -euo pipefail
+
+        # create .env file
+        install -T -m 0600 -o ${user} ${monicaEnv} "${cfg.dataDir}/.env"
+        ${secretReplacements}
+        if ! grep 'APP_KEY=base64:' "${cfg.dataDir}/.env" >/dev/null; then
+          sed -i 's/APP_KEY=/APP_KEY=base64:/' "${cfg.dataDir}/.env"
+        fi
+
+        # migrate & seed db
+        ${pkgs.php}/bin/php artisan key:generate --force
+        ${pkgs.php}/bin/php artisan setup:production -v --force
+      '';
+    };
+
+    systemd.services.monica-scheduler = {
+      description = "Background tasks for monica";
+      startAt = "minutely";
+      after = ["monica-setup.service"];
+      serviceConfig = {
+        Type = "oneshot";
+        User = user;
+        WorkingDirectory = "${monica}";
+        ExecStart = "${pkgs.php}/bin/php ${monica}/artisan schedule:run -v";
+      };
+    };
+
+    systemd.tmpfiles.rules = [
+      "d ${cfg.dataDir}                            0710 ${user} ${group} - -"
+      "d ${cfg.dataDir}/public                     0750 ${user} ${group} - -"
+      "d ${cfg.dataDir}/public/uploads             0750 ${user} ${group} - -"
+      "d ${cfg.dataDir}/storage                    0700 ${user} ${group} - -"
+      "d ${cfg.dataDir}/storage/app                0700 ${user} ${group} - -"
+      "d ${cfg.dataDir}/storage/fonts              0700 ${user} ${group} - -"
+      "d ${cfg.dataDir}/storage/framework          0700 ${user} ${group} - -"
+      "d ${cfg.dataDir}/storage/framework/cache    0700 ${user} ${group} - -"
+      "d ${cfg.dataDir}/storage/framework/sessions 0700 ${user} ${group} - -"
+      "d ${cfg.dataDir}/storage/framework/views    0700 ${user} ${group} - -"
+      "d ${cfg.dataDir}/storage/logs               0700 ${user} ${group} - -"
+      "d ${cfg.dataDir}/storage/uploads            0700 ${user} ${group} - -"
+    ];
+
+    users = {
+      users = mkIf (user == "monica") {
+        monica = {
+          inherit group;
+          isSystemUser = true;
+        };
+        "${config.services.nginx.user}".extraGroups = [group];
+      };
+      groups = mkIf (group == "monica") {
+        monica = {};
+      };
+    };
+  };
+}
+
diff --git a/nixos/modules/services/web-apps/plausible.nix b/nixos/modules/services/web-apps/plausible.nix
index f64254d62524e..893dfa10acbc0 100644
--- a/nixos/modules/services/web-apps/plausible.nix
+++ b/nixos/modules/services/web-apps/plausible.nix
@@ -9,6 +9,8 @@ in {
   options.services.plausible = {
     enable = mkEnableOption (lib.mdDoc "plausible");
 
+    package = mkPackageOptionMD pkgs "plausible" { };
+
     releaseCookiePath = mkOption {
       type = with types; either str path;
       description = lib.mdDoc ''
@@ -180,12 +182,12 @@ in {
 
     services.epmd.enable = true;
 
-    environment.systemPackages = [ pkgs.plausible ];
+    environment.systemPackages = [ cfg.package ];
 
     systemd.services = mkMerge [
       {
         plausible = {
-          inherit (pkgs.plausible.meta) description;
+          inherit (cfg.package.meta) description;
           documentation = [ "https://plausible.io/docs/self-hosting" ];
           wantedBy = [ "multi-user.target" ];
           after = optional cfg.database.clickhouse.setup "clickhouse.service"
@@ -233,7 +235,7 @@ in {
             SMTP_USER_NAME = cfg.mail.smtp.user;
           });
 
-          path = [ pkgs.plausible ]
+          path = [ cfg.package ]
             ++ optional cfg.database.postgres.setup config.services.postgresql.package;
           script = ''
             export CONFIG_DIR=$CREDENTIALS_DIRECTORY
@@ -241,10 +243,10 @@ in {
             export RELEASE_COOKIE="$(< $CREDENTIALS_DIRECTORY/RELEASE_COOKIE )"
 
             # setup
-            ${pkgs.plausible}/createdb.sh
-            ${pkgs.plausible}/migrate.sh
+            ${cfg.package}/createdb.sh
+            ${cfg.package}/migrate.sh
             ${optionalString cfg.adminUser.activate ''
-              if ! ${pkgs.plausible}/init-admin.sh | grep 'already exists'; then
+              if ! ${cfg.package}/init-admin.sh | grep 'already exists'; then
                 psql -d plausible <<< "UPDATE users SET email_verified=true;"
               fi
             ''}
diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix
index 061e41f7ed753..8d341dd6653ee 100644
--- a/nixos/tests/all-tests.nix
+++ b/nixos/tests/all-tests.nix
@@ -210,6 +210,7 @@ in {
   envoy = handleTest ./envoy.nix {};
   ergo = handleTest ./ergo.nix {};
   ergochat = handleTest ./ergochat.nix {};
+  esphome = handleTest ./esphome.nix {};
   etc = pkgs.callPackage ../modules/system/etc/test.nix { inherit evalMinimalConfig; };
   activation = pkgs.callPackage ../modules/system/activation/test.nix { };
   etcd = handleTestOn ["x86_64-linux"] ./etcd.nix {};
@@ -420,6 +421,7 @@ in {
   mjolnir = handleTest ./matrix/mjolnir.nix {};
   mod_perl = handleTest ./mod_perl.nix {};
   molly-brown = handleTest ./molly-brown.nix {};
+  monica = handleTest ./web-apps/monica.nix {};
   mongodb = handleTest ./mongodb.nix {};
   moodle = handleTest ./moodle.nix {};
   moonraker = handleTest ./moonraker.nix {};
@@ -586,6 +588,7 @@ in {
   pt2-clone = handleTest ./pt2-clone.nix {};
   pykms = handleTest ./pykms.nix {};
   public-inbox = handleTest ./public-inbox.nix {};
+  pufferpanel = handleTest ./pufferpanel.nix {};
   pulseaudio = discoverTests (import ./pulseaudio.nix);
   qboot = handleTestOn ["x86_64-linux" "i686-linux"] ./qboot.nix {};
   qemu-vm-restrictnetwork = handleTest ./qemu-vm-restrictnetwork.nix {};
diff --git a/nixos/tests/esphome.nix b/nixos/tests/esphome.nix
new file mode 100644
index 0000000000000..b8dbdb0b37957
--- /dev/null
+++ b/nixos/tests/esphome.nix
@@ -0,0 +1,41 @@
+import ./make-test-python.nix ({ pkgs, lib, ... }:
+
+let
+  testPort = 6052;
+  unixSocket = "/run/esphome/esphome.sock";
+in
+with lib;
+{
+  name = "esphome";
+  meta.maintainers = with pkgs.lib.maintainers; [ oddlama ];
+
+  nodes = {
+    esphomeTcp = { ... }:
+      {
+        services.esphome = {
+          enable = true;
+          port = testPort;
+          address = "0.0.0.0";
+          openFirewall = true;
+        };
+      };
+
+    esphomeUnix = { ... }:
+      {
+        services.esphome = {
+          enable = true;
+          enableUnixSocket = true;
+        };
+      };
+  };
+
+  testScript = ''
+    esphomeTcp.wait_for_unit("esphome.service")
+    esphomeTcp.wait_for_open_port(${toString testPort})
+    esphomeTcp.succeed("curl --fail http://localhost:${toString testPort}/")
+
+    esphomeUnix.wait_for_unit("esphome.service")
+    esphomeUnix.wait_for_file("${unixSocket}")
+    esphomeUnix.succeed("curl --fail --unix-socket ${unixSocket} http://localhost/")
+  '';
+})
diff --git a/nixos/tests/pufferpanel.nix b/nixos/tests/pufferpanel.nix
new file mode 100644
index 0000000000000..e7b09c13f90bd
--- /dev/null
+++ b/nixos/tests/pufferpanel.nix
@@ -0,0 +1,74 @@
+import ./make-test-python.nix ({ lib, ... }: {
+  name = "pufferpanel";
+  meta.maintainers = [ lib.maintainers.tie ];
+
+  nodes.machine = { pkgs, ... }: {
+    environment.systemPackages = [ pkgs.pufferpanel ];
+    services.pufferpanel = {
+      enable = true;
+      extraPackages = [ pkgs.netcat ];
+      environment = {
+        PUFFER_PANEL_REGISTRATIONENABLED = "false";
+        PUFFER_PANEL_SETTINGS_COMPANYNAME = "NixOS";
+      };
+    };
+  };
+
+  testScript = ''
+    import shlex
+    import json
+
+    curl = "curl --fail-with-body --silent"
+    baseURL = "http://localhost:8080"
+    adminName = "admin"
+    adminEmail = "admin@nixos.org"
+    adminPass = "admin"
+    adminCreds = json.dumps({
+      "email": adminEmail,
+      "password": adminPass,
+    })
+    stopCode = 9 # SIGKILL
+    serverPort = 1337
+    serverDefinition = json.dumps({
+      "name": "netcat",
+      "node": 0,
+      "users": [
+        adminName,
+      ],
+      "type": "netcat",
+      "run": {
+        "stopCode": stopCode,
+        "command": f"nc -l {serverPort}",
+      },
+      "environment": {
+        "type": "standard",
+      },
+    })
+
+    start_all()
+
+    machine.wait_for_unit("pufferpanel.service")
+    machine.wait_for_open_port(5657) # SFTP
+    machine.wait_for_open_port(8080) # HTTP
+
+    # Note that PufferPanel does not initialize database unless necessary.
+    # /api/config endpoint creates database file and triggers migrations.
+    # On success, we run a command to create administrator user that we use to
+    # interact with HTTP API.
+    resp = json.loads(machine.succeed(f"{curl} {baseURL}/api/config"))
+    assert resp["branding"]["name"] == "NixOS", "Invalid company name in configuration"
+    assert resp["registrationEnabled"] == False, "Expected registration to be disabled"
+
+    machine.succeed(f"pufferpanel --workDir /var/lib/pufferpanel user add --admin --name {adminName} --email {adminEmail} --password {adminPass}")
+
+    resp = json.loads(machine.succeed(f"{curl} -d '{adminCreds}' {baseURL}/auth/login"))
+    assert "servers.admin" in resp["scopes"], "User is not administrator"
+    token = resp["session"]
+    authHeader = shlex.quote(f"Authorization: Bearer {token}")
+
+    resp = json.loads(machine.succeed(f"{curl} -H {authHeader} -H 'Content-Type: application/json' -d '{serverDefinition}' {baseURL}/api/servers"))
+    serverID = resp["id"]
+    machine.succeed(f"{curl} -X POST -H {authHeader} {baseURL}/proxy/daemon/server/{serverID}/start")
+    machine.wait_for_open_port(serverPort)
+  '';
+})
diff --git a/nixos/tests/web-apps/monica.nix b/nixos/tests/web-apps/monica.nix
new file mode 100644
index 0000000000000..29f5cb85bb13a
--- /dev/null
+++ b/nixos/tests/web-apps/monica.nix
@@ -0,0 +1,33 @@
+import ../make-test-python.nix ({pkgs, ...}:
+let
+  cert = pkgs.runCommand "selfSignedCerts" { nativeBuildInputs = [ pkgs.openssl ]; } ''
+    openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -nodes -subj '/CN=localhost' -days 36500
+    mkdir -p $out
+    cp key.pem cert.pem $out
+  '';
+in
+{
+  name = "monica";
+
+  nodes = {
+    machine = {pkgs, ...}: {
+      services.monica = {
+        enable = true;
+        hostname = "localhost";
+        appKeyFile = "${pkgs.writeText "keyfile" "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}";
+        nginx = {
+          forceSSL = true;
+          sslCertificate = "${cert}/cert.pem";
+          sslCertificateKey = "${cert}/key.pem";
+        };
+      };
+    };
+  };
+
+  testScript = ''
+    start_all()
+    machine.wait_for_unit("monica-setup.service")
+    machine.wait_for_open_port(443)
+    machine.succeed("curl -k --fail https://localhost", timeout=10)
+  '';
+})