Help with file permissions (packing for NIxOS)

I’ve had a manual zip install running on NixOS for over a year using Caddy, PHP-FPM, and MariaDB. Since, Invoice Ninja isn’t in the official nixpkgs repo I’ve decided to learn Nix and package it for my favorite Linux distribution.

For those unformiliar with NixOS, systems are defined declaratively using the Nix functional programming language. Packaging is, also, done using the Nix language. For something like Invoice Ninja a package derivation and module must be written. The derivation downloads Invoice Ninja into the nix store while the module defines how to run it. The nix store, located under /nix/store, is where packages are installed. Everything in the nix store is owned by root, package files are world readable and directories are world read and executable.

What I have works and is running a backup of my main install. My problem is with files and directories that need to be writable. When I run the “Health Check”, it complained about the vite.config.ts file not being writable. After fixing that the “Health Check” is now complaining about [email protected] not being writable. I couldn’t find a file with that name so I moved /public/images to a writable location with a link back to /public/images. This didn’t fix [email protected] not being writable. Any ideas on what file it’s talking about?

The derivation and module file follow:

Derivation (default.nix):

{ lib
, php
, openssl
, writers
, fetchFromGitHub
, dataDir ? "/var/lib/invoice-ninja"
, runtimeDir ? "/run/invoice-ninja"
}:

let
  # Helper script to generate an APP_KEY for .env
  generate-invoice-ninja-app-key = writers.writeBashBin "generate-laravel-key" ''
    echo "APP_KEY=base64:$(${openssl}/bin/openssl rand -base64 32)"
  '';
in
php.buildComposerProject (finalAttrs: {
  pname = "invoice-ninja";
  version = "5.8.57";

  src = fetchFromGitHub {
    owner = "invoiceninja";
    repo = "invoiceninja";
    rev = "v${finalAttrs.version}";
    hash = "sha256-KB5YUMedLythk9c6SDRB5IwPNHcxGzki2vF1Htk+1Sw=";
  };

  vendorHash = "sha256-xDlj0LYy2+mseYQSk7Ed9fI/ow58ut7lxQqlpNPyO/k=";

  propagatedBuildInput = [ generate-invoice-ninja-app-key ];

  # Upstream composer.json has invalid license, webpatser/laravel-countries package is pointing
  # to commit-ref, and php required in require and require-dev
  composerStrictValidation = false;

  postInstall = ''
    mv "$out/share/php/${finalAttrs.pname}"/* $out
    rm -R $out/bootstrap/cache

    # Move static contents for the NixOS module to pick it up, if needed.
    mv $out/bootstrap $out/bootstrap-static
    mv $out/storage $out/storage-static
    mv $out/vite.config.ts $out/vite.config.ts.static
    mv $out/public/images $out/public/images-static

    ln -s ${dataDir}/images $out/public
    ln -s ${dataDir}/vite.config.ts $out/
    ln -s ${dataDir}/.env $out/.env
    ln -s ${dataDir}/storage $out/
    ln -s ${dataDir}/storage/app/public $out/public/storage
    ln -s ${runtimeDir} $out/bootstrap

    chmod +x $out/artisan
  '';

  meta = {
    description = "Open-source, self-hosted invoicing application";
    homepage = "https://www.invoiceninja.com/";
    license = with lib.licenses; {
      fullName = "Elastic License 2.0";
      shortName = "Elastic-2.0";
      free = false;
    };
    platforms = lib.platforms.all;
  };
})

Module (invoice-ninja.nix):

{ config, lib, pkgs, ... }:

with lib;

let
  cfg = config.services.invoice-ninja;
  user = cfg.user;
  group = cfg.group;
  testing = pkgs.callPackage ./default.nix { inherit lib;php=pkgs.php;fetchFromGitHub=pkgs.fetchFromGitHub; };
  invoice-ninja = testing.override { inherit (cfg) dataDir runtimeDir; };
  configFile = pkgs.writeText "invoice-ninja-env" (lib.generators.toKeyValue { } cfg.settings);

  # PHP environment
  phpPackage = cfg.phpPackage.buildEnv {
    extensions = { enabled, all }: enabled ++ (with all;
      [bcmath ctype curl fileinfo gd gmp iconv mbstring mysqli openssl pdo tokenizer zip]
    );

    extraConfig = "memory_limit = 1024M";
  };

  # Chromium is required for PDF invoice generation
  extraPrograms = with pkgs; [ chromium ];

  # Management script
  invoice-ninja-manage = pkgs.writeShellScriptBin "invoice-ninja-manage" ''
    cd ${invoice-ninja}
    sudo=exec
    if [[ "$USER" != ${user} ]]; then
      sudo='exec /run/wrappers/bin/sudo -u ${user}'
    fi
    $sudo ${phpPackage}/bin/php artisan "$@"
  '';
in
{
  options.services.invoice-ninja = {
    enable = mkEnableOption "invoice-ninja";

    package = mkPackageOption pkgs "invoice-ninja" { };
		
		phpPackage = mkPackageOption pkgs "php82" { };

    user = mkOption {
      type = types.str;
      default = "invoiceninja";
      description = ''
        User account under which Invoice Ninja runs.

        ::: {.note}
        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 Invoice Ninja application starts.
        :::
      '';
    };

    group = mkOption {
      type = types.str;
      default = "invoiceninja";
      description = ''
        Group account under which Invoice Ninja runs.

        ::: {.note}
        If left as the default value this group will automatically be created
        on system activation, otherwise you are responsible for
        ensuring the group exists before the Invoice Ninja application starts.
        :::
      '';
    };

    dataDir = mkOption {
      type = types.str;
      default = "/var/lib/invoice-ninja";
      description = ''
        State directory of the `invoice-ninja` user which holds
        the application's state and data.
      '';
    };

    runtimeDir = mkOption {
      type = types.str;
      default = "/run/invoice-ninja";
      description = ''
        Rutime directory of the `invoice-ninja` user which holds
        the application's caches and temporary files.
      '';
    };

    schedulerInterval = mkOption {
      type = types.str;
      default = "1d";
      description = "How often the Invoice Ninja cron task should run.";
    };

    webServer = mkOption {
      type = types.str;
      default = "caddy";
      description = ''
				Web server to nun Invoice Ninja web application. Supported options are
				`caddy` or `nginx`.
			'';
    };

    domain = mkOption {
      type = types.str;
      default = "localhost";
      description = ''
        FQDN for the Invoice Ninja instance.
      '';
    };

    phpfpm.settings = mkOption {
      type = with types; attrsOf (oneOf [ int str bool ]);
      default = {
        "listen.owner" = user;
        "listen.group" = group;
        "listen.mode" = "0660";

        "pm" = "dynamic";
        "pm.start_servers" = "2";
        "pm.min_spare_servers" = "2";
        "pm.max_spare_servers" = "4";
        "pm.max_children" = "8";
        "pm.max_requests" = "500";

        "request_terminate_timeout" = 300;

        "php_admin_value[error_log]" = "stderr";
        "php_admin_flag[log_errors]" = true;

        "catch_workers_output" = true;
      };

      description = ''
        Options for Invoice Ninja's PHPFPM pool.
      '';
    };

    secretFile = mkOption {
      type = types.path;
      description = ''
        A secret file to be sourced for the .env settings.
        Place `APP_KEY`, `UPDATE_SECRET`, and other settings that should not end up in the Nix store here.
      '';
    };

    settings = mkOption {
      type = with types; (attrsOf (oneOf [ bool int str ]));
      description = ''
        .env settings for Invoice Ninja.
        Secrets should use `secretFile` option instead.
      '';
    };

    database = {
      createLocally = mkEnableOption
        ("a local database using UNIX socket authentication") // {
          default = true;
        };

      name = mkOption {
        type = types.str;
        default = "invoiceninja";
        description = "Database name for Invoice Ninja.";
      };
    };
  };

  config = mkIf cfg.enable {
    users.users.invoiceninja = mkIf (cfg.user == "invoiceninja") {
      isSystemUser = true;
      home = cfg.dataDir;
      createHome = true;
      group = cfg.group;
    };

    users.groups.invoiceninja = mkIf (cfg.group == "invoiceninja") { };

    environment.systemPackages = [ invoice-ninja-manage ] ++ extraPrograms;

    services.invoice-ninja.settings = let
            app_http_url = "http://${cfg.domain}";
            app_https_url = "https://${cfg.domain}";
            react_http_url = "http://${cfg.domain}:3001";
            react_https_url = "https://${cfg.domain}:3001";
						chromium = lists.findSingle (x: x == pkgs.chromium) "none" "multiple" extraPrograms;
        in
        mkMerge [
      ({
        APP_NAME = mkDefault "\"Invoice Ninja\"";
        APP_ENV = mkDefault "production";
        APP_DEBUG = mkDefault false;
        APP_URL = mkDefault (if (cfg.domain != "localhost") then "${app_https_url}" else "${app_http_url}");
        REACT_URL = mkDefault (if (cfg.domain != "localhost") then "${react_https_url}" else "${react_http_url}");
        DB_CONNECTION = mkDefault "mysql";
        MULTI_DB_ENABLED = mkDefault false;
        DEMO_MODE = mkDefault false;
        BROADCAST_DRIVER = mkDefault "log";
        LOG_CHANNEL = mkDefault "stack";
        CACHE_DRIVER = mkDefault "file";
        QUEUE_CONNECTION = mkDefault "database";
        SESSION_DRIVER = mkDefault "file";
        SESSION_LIFETIME = mkDefault "120";
        REQUIRE_HTTPS = mkDefault (if (cfg.domain != "localhost") then true else false);
        TRUSTED_PROXIES = mkDefault "127.0.0.1";
        NINJA_ENVIRONMENT = mkDefault "selfhost";
        PDF_GENERATOR = mkDefault "snappdf";
				SNAPPDF_CHROMIUM_PATH = mkDefault "${chromium}/bin/chromium";
      })
      (mkIf (cfg.database.createLocally) {
        DB_CONNECTION = mkDefault "mysql";
        DB_HOST = mkDefault "localhost";
        DB_SOCKET = mkDefault "/run/mysqld/mysqld.sock";
        DB_DATABASE = mkDefault cfg.database.name;
        DB_USERNAME = mkDefault user;
      })
    ];

    services.phpfpm.pools.invoiceninja = {
      inherit user group phpPackage;
      inherit (cfg.phpfpm) settings;
    };

		users.users."${config.services.caddy.user}" = mkIf (cfg.webServer == "caddy") { extraGroups = [ cfg.group ]; };
		services.caddy = mkIf (cfg.webServer == "caddy"){
			enable = true;

			email = "[email protected]";

			virtualHosts.${cfg.domain} = {
				extraConfig = ''
					root * ${invoice-ninja}/public
					php_fastcgi * unix/${config.services.phpfpm.pools.invoiceninja.socket}

					encode zstd gzip
					file_server
					try_files {path} /public/{path}
				'';
			};
		};

		users.users."${config.services.nginx.user}" = mkIf (cfg.webServer == "nginx") { extraGroups =  [ cfg.group ]; };
		services.nginx = mkIf (cfg.webServer == "nginx") {
			enable = true;

			clientMaxBodySize = "20m";

			recommendedGzipSettings = true;

			virtualHosts.${cfg.domain} = {
				default = true;

				root = "${invoice-ninja}/public/";

				enableACME = (if (cfg.domain != "localhost") then true else false);

				locations."/index.php" = {
					extraConfig = ''
						fastcgi_pass unix:${config.services.phpfpm.pools.invoiceninja.socket};
						fastcgi_index index.php;
					'';
				};
					
				locations."~ \\.php$" = {
					return = 403;
				};

				locations."/" = {
					tryFiles = "$uri $uri/ /index.php?$query_string";
					extraConfig = ''
						# Add your rewrite rule for non-existent files
						if (!-e $request_filename) {
								rewrite ^(.+)$ /index.php?q=$1 last;
						}
					'';
				};

				locations."~ /\\.ht" = {
					extraConfig = ''
						deny all;
					'';
				};

				extraConfig = ''
					add_header X-Frame-Options "SAMEORIGIN";
					add_header X-XSS-Protection "1; mode=block";
					add_header X-Content-Type-Options "nosniff";
					index index.php index.html index.htm;
					error_page 404 /index.php;
				'';
			};
		};

    services.mysql = mkIf (cfg.database.createLocally) {
      enable = mkDefault true;
      package = mkDefault pkgs.mariadb;
      ensureDatabases = [ cfg.database.name ];
      ensureUsers = [{
        name = user;
        ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; };
      }];
    };

    systemd.services.phpfpm-invoice-ninja.after = [ "invoice-ninja-data-setup.service" ];
    systemd.services.phpfpm-invoice-ninja.requires = [ "invoice-ninja-data-setup.service" ]
      ++ lib.optional cfg.database.createLocally "mysql.service";
    # Ensure chromium is available
    systemd.services.phpfpm-invoice-ninja.path = extraPrograms;

    systemd.timers.invoice-ninja-cron = {
      description = "Invoice Ninja periodic tasks timer";
      after = [ "invoice-ninja-data-setup.service" ];
      requires = [ "phpfpm-invoice-ninja.service" ];
      wantedBy = [ "timers.target" ];

      timerConfig = {
        OnBootSec = cfg.schedulerInterval;
        OnUnitActiveSec = cfg.schedulerInterval;
      };
    };

    systemd.services.invoice-ninja-cron = {
      description = "Invoice Ninja periodic tasks";

      serviceConfig = {
        ExecStart = "${invoice-ninja-manage}/bin/invoice-ninja-manage schedule:run";
        User = user;
        Group = group;
        StateDirectory = lib.mkIf (cfg.dataDir == "/var/lib/invoice-ninja") "invoice-ninja";
      };
    };

    systemd.services.invoice-ninja-data-setup = {
      description =
        "Invoice Ninja setup: migrations, environment file update, cache reload, data changes";
      wantedBy = [ "multi-user.target" ];
      after = lib.optional cfg.database.createLocally "mysql.service";
      requires = lib.optional cfg.database.createLocally "mysql.service";
      path = with pkgs; [ bash invoice-ninja-manage rsync ] ++ extraPrograms;

      serviceConfig = {
        Type = "oneshot";
        User = user;
        Group = group;
        StateDirectory = lib.mkIf (cfg.dataDir == "/var/lib/invoice-ninja") "invoice-ninja";
        StateDirectoryMode = "0750";
        LoadCredential = "env-secrets:${cfg.secretFile}";
        UMask = "077";
      };

      script = ''
        # Before running any PHP program, cleanup the code cache.
        # It's necessary if you upgrade the application otherwise you might
        # try to import non-existent modules.
        rm -f ${cfg.runtimeDir}/app.php
        rm -rf ${cfg.runtimeDir}/cache/*

        # Concatenate non-secret .env and secret .env
        rm -f ${cfg.dataDir}/.env
        cp --no-preserve=all ${configFile} ${cfg.dataDir}/.env
        # echo -e '\n' >> ${cfg.dataDir}/.env
        cat "$CREDENTIALS_DIRECTORY/env-secrets" >> ${cfg.dataDir}/.env

        # Link the static storage (package provided) to the runtime storage
        # Necessary for cities.json and static images.
        mkdir -p ${cfg.dataDir}/storage
        rsync -av --no-perms ${invoice-ninja}/storage-static/ ${cfg.dataDir}/storage
        chmod -R +w ${cfg.dataDir}/storage

        chmod g+x ${cfg.dataDir}/storage ${cfg.dataDir}/storage/app
        chmod -R g+rX ${cfg.dataDir}/storage/app/public

        # Link the app.php in the runtime folder.
        # We cannot link the cache folder only because bootstrap folder needs to be writeable.
        ln -sf ${invoice-ninja}/bootstrap-static/app.php ${cfg.runtimeDir}/app.php

        # https://laravel.com/docs/10.x/filesystem#the-public-disk
        # Creating the public/storage → storage/app/public link
        # is unnecessary as it's part of the installPhase of Invoice Ninja.

				# Link the static public/images (package provided) to the runtime public/images
				mkdir -p ${cfg.dataDir}/images
				rsync -av --no-perms ${invoice-ninja}/public/images-static/ ${cfg.dataDir}/images
				chmod -R +w ${cfg.dataDir}/images
				chmod -R g+rwX ${cfg.dataDir}/images

				# Link the static vite.config.ts (package provided to the runtime vite.config.ts
				cp ${invoice-ninja}/vite.config.ts.static ${cfg.dataDir}/vite.config.ts

        invoice-ninja-manage route:cache
        invoice-ninja-manage view:cache
        invoice-ninja-manage config:cache
      '';
    };

    systemd.tmpfiles.rules = [
      # Cache must live across multiple systemd units runtimes.
      "d ${cfg.runtimeDir}/                         0700 ${user} ${group} - -"
      "d ${cfg.runtimeDir}/cache                    0700 ${user} ${group} - -"
    ];
  };
}

Hi,

@david any ideas?

I just tested with the latest Invoice Ninja release (v5.10.3) with the same results.

Fixed. The file is under /public/react. So, I moved the react folder to the data folder and made a link back to the derivations /public/react folder.