Systemd giving error when creating a writable directory

I’m trying to set up a systemd service to run a package. It needs to have a directory in which it can write to persistent files on disk. I have a ReadWritePaths key set, and I’m using preStart to ensure the directory exists. But when I start it up, I get this error:

× my-app.service
     Loaded: loaded (/etc/systemd/system/my-app.service; enabled; preset: enabled)
     Active: failed (Result: exit-code) since Tue 2023-11-14 05:42:47 UTC; 731ms ago
   Duration: 8ms
    Process: 11248 ExecStartPre=/nix/store/6l2vbwidanfi0y8ybkk8v9qcrljrq08y-unit-script-my-app-pre-start/bin/my-app-pre-start (code=exited, status=226/NAMESPACE)
         IP: 0B in, 0B out
        CPU: 2ms

Nov 14 05:42:47 cloud systemd[1]: Starting my-app.service...
Nov 14 05:42:47 cloud (re-start)[11248]: my-app.service: Failed to set up mount namespacing: /run/systemd/unit-root/var/lib/my-app: No such file or directory
Nov 14 05:42:47 cloud (re-start)[11248]: my-app.service: Failed at step NAMESPACE spawning /nix/store/6l2vbwidanfi0y8ybkk8v9qcrljrq08y-unit-script-my-app-pre-start/bin/my-app-pre-start: No such file or directory
Nov 14 05:42:47 cloud systemd[1]: my-app.service: Control process exited, code=exited, status=226/NAMESPACE

There seems to be something I’m missing here, since it’s complaining about the path not existing, even though I’m specifically trying to make sure it does exist. I even tried disabling PrivateTmp, ProtectHome, and ProtectSystem, but it still fails in the same way. Here’s the module that defines the service.

      nixosModules.default = { lib, config, ... }: let
        cfg = config.services.jonah-id-app;
        mkEnableOption = lib.mkEnableOption;
        mkOption = lib.mkOption;
        mkIf = lib.mkIf;
        types = lib.types;
      in {
        options.services.my-app = {
          enable = mkEnableOption "my app service";
          writableDir = mkOption {
            type = types.str;
            default = "/var/lib/my-app/";
          };
        };
        config = mkIf cfg.enable {
          systemd.services.my-app = {
            wantedBy = ["multi-user.target"];
            preStart = "mkdir -p ${cfg.writableDir}";
            serviceConfig = {
              ExecStart = "${self.packages.x86_64-linux.my-app}/bin/my-app ${cfg.writableDir}";
              ReadWritePaths = [cfg.writableDir];
            };
          };
        };
      };

I think the directory has to exist before the service starts (yee, even before preStart, that’s part of the unit) for ReadWritePaths to work. It’s intended to allow you to escape the sandbox for specific paths on the host, not create state directories for units. Its manual entry even comes with ample warning to not use it the way you do.

Maybe if you use the + prefix on an explicit ExecStartPre the mount namespacing is skipped, and you can do it this way.

That said, you’ll find StateDirectory is much closer to what you want. Even defaults to the directory you specify.

1 Like

Oh my gosh a purpose-built feature! That being an unintended solution really explains why it was so hard :joy: . Thank you so much, as soon as I dropped everything but ExecStart and StateDirectory, it works like a charm now.