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} - -"
];
};
}