Root-caused a "logo upload hangs, connection refused in browser console" report: the qemu-vm module's defaults (1 core, 1024MB) are too tight for this stack (MySQL + Redis + PHP-FPM + chromium for PDF generation, each PDF request spawning a full multi-process browser instance) under real concurrent load, and this VM boots with panic_on_oom - so exhausting memory doesn't degrade gracefully, the whole VM goes down and every connection starts refusing. Reproduced directly: at the 1024MB default, even 2 concurrent PDF generation requests triggered `redis-server invoked oom-killer` and a kernel panic. Bumped to 2 cores / 2560MB, which comfortably survives that same load (~1.4GB still free afterward) - verified via the exact concurrent-request scenario that crashed the 1024MB config, not just absence of errors. virtualisation.cores/memorySize aren't options on a plain nixosSystem in this nixpkgs (only nixosTest nodes get the qemu-vm module automatically), so this also adds an explicit import of it. Documented the underlying resource cost (chromium is a full browser per PDF request, pm.max_children=8 means up to 8 can run concurrently) in CLAUDE.md as an operational note for real deployments, not just this test VM. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
11 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this repo is
A Nix flake that packages Invoice Ninja (a PHP/Laravel app with a
separate React frontend) and provides a NixOS module (services.invoiceninja) to deploy it as a full service:
PHP-FPM pool, nginx or Caddy reverse proxy, MariaDB, Redis, a queue-worker systemd unit, and a one-shot
migration/setup unit.
There is no application source code here — this is packaging/deployment code only. All behavior changes come from editing Nix expressions.
Commands
This project uses just (see justfile) and Nix directly.
just build-vm [proxy]— cleans previous artifacts and builds the test VM (nixosConfigurations.test-<proxy>) vianom build.proxyisnginx(default) orcaddy.just boot-vm— boots the built VM (result/bin/run-nixos-vm) in the current terminal. Serves Invoice Ninja onlocalhost:8080(seeQEMU_NET_OPTSin thejustfile) with root logintoor.just clean— removesresult/nixos.qcow2and kills any runningqemuprocess.just test— runs the automated test suite (nix flake check -L).nix build .#invoiceninja— build just the Invoice Ninja package.nix build .#checks.x86_64-linux.invoiceninja -L— run only the automated VM test, with build/test log output.nix flake check— evaluate the flake (module options, overlay, package derivation) and run the automated test.
tests/
common.nix— the sharedservices.invoiceninjaconfig used by both the interactive VM and the automated test: imports the module and points at the checked-in test fixtures (invoice_ninja_test_password,test.env). It's a function taking{ proxyServer ? "nginx" }so callers can pick which proxy backend to test.default.nix— the interactive VM config (nixosConfigurations.test-nginx/test-caddyinflake.nix), used for manual, click-around verification viajust build-vm [proxy]/just boot-vm. It's also a function taking{ proxyServer ? "nginx" }, forwarded straight through tocommon.nix, soflake.nixcan instantiate onenixosConfigurationsentry per proxy backend. Adds the qemu-guest profile, a root login, andnixpkgs.config.allowUnfree = true(required to buildpkgs.chromium). It also sets a dummyfileSystems."/"and disablesboot.loader.grubpurely to satisfy NixOS's built-in assertions whennix flake checkevaluatesconfig.system.build.topleveldirectly — the qemu-vm module overrides both with a tmpfs root and direct kernel boot when actually buildingconfig.system.build.vm(whatjust build-vmuses), so these values are otherwise unused. Also raisesvirtualisation.cores/memorySizeto 2/2560 (qemu-vm's defaults, 1 core/1024MB, are too tight for real interactive use — see the module architecture notes below on PDF generation's memory cost).nixos-test.nix— the automated test (pkgs.testers.nixosTest, exposed aschecks.<system>.invoiceninjainflake.nix). Builds its ownpkgs(withallowUnfreebaked in —nixosTestforbids settingnixpkgs.configon a node oncepkgsis externally supplied) and boots two nodes fromcommon.nix— one withproxyServer = "nginx", one withproxyServer = "caddy"— so both proxy backends get exercised. The shared assertion suite (factored into acheck_invoiceninja(machine, proxy_unit)function in the test script) runs against each node: waits for mysql/redis/phpfpm/queue-worker/proxy to be active, checks theinvoiceninja-data-setupmarker files (.initial-migration,.db-seeded,.admin-created) to confirm the one-time setup actually completed (not just that the oneshot unit exited), curls/for the app shell, exercises theinvoiceninja-managewrapper, logs in via/api/v1/loginusing the test admin credentials as an end-to-end check that migrations/seeding/account creation all worked, checks the full language list is available (see thecache:forget languagesnote above), and exercises PDF generation end-to-end (POSTs to/api/v1/previewwith no entity, which falls back to a self-contained mock PDF built via the same chromium/snappdf pipeline real invoices use, and checks for the%PDFmagic bytes — no client/invoice setup needed). This is the only automated test in the repo — there is no separate unit-test suite.
Architecture
package.nix
Builds Invoice Ninja in two stages:
ui— the separateinvoiceninja/uiReact frontend repo, built withbuildNpmPackage. Patches.env.exampleto disable test mode and swaps invite.config.ts.reactbefore building.- Main package — built with
php82.buildComposerProjectfrom theinvoiceninja/invoiceninjaPHP repo. Theuibuild'sdist/output is copied intopublic/before the PHP build (preConfigure), andpublic/index.htmlis copied toresources/views/react/index.blade.phpso Laravel serves the React shell.
Both source fetches (fetchFromGitHub) run their lockfiles through npm-lockfile-fix in postFetch to patch
missing integrity fields — required for fetchNpmDeps/buildNpmPackage to work with these upstream lockfiles.
Because Laravel needs writable directories that can't live in the read-only Nix store, postInstall renames
bootstrap → bootstrap-static and storage → storage-static inside the derivation, then symlinks
$out/.env, $out/storage, $out/public/storage, and $out/bootstrap to paths under the module's dataDir /
runtimeDir. This means the package output is only fully functional when paired with the NixOS module (or
something else that populates those runtime paths the same way) — building the package alone will produce
dangling symlinks.
When bumping the Invoice Ninja or UI version in package.nix, all four hashes (src.hash, uiSrc.hash,
ui's npmDepsHash, and the main package's vendorHash/npmDeps.hash) generally need to be recomputed —
expect Nix to report the correct hash on a failed build (or use nix-prefetch/lib.fakeHash workflow).
nixos-module/
default.nix— the flake'snixosModulesoutput. Exposes theinvoiceninjamodule and a smalloverlayNixpkgsForThisInstancemodule that applies the flake's overlay (registerspkgs.invoiceninja) to whatever nixpkgs instance imports it.invoiceninja.nix— the actualservices.invoiceninjamodule. Key structural points:cfg.settingsis a submodule with afreeformType(pkgs.formats.keyValue), so any Laravel.envkey can be set even if not explicitly declared as an option; explicitly declared options (APP_ENV, MAIL_MAILER, SESSION_DRIVER, etc.) exist mainly to add type-checking/enums and sensible computed defaults (e.g. SESSION_DRIVER/QUEUE_CONNECTION/CACHE_DRIVER default to redis → database → file depending on what's enabled).- Secrets are deliberately kept out of
cfg.settings/the Nix store.secretFile(required) andadminAccount.passwordFile(required) are consumed via systemdLoadCredentialin theinvoiceninja-data-setupservice, not written into the generated.envderivation. The final.envis assembled at activation time by concatenating the Nix-store-rendered non-secret settings with the secret file contents directly on disk indataDir. invoiceninja-data-setup.service(oneshot, runs on every activation) does all first-run and idempotent setup: clears bootstrap/opcode cache, rebuilds.env, rsyncs static storage intodataDir, symlinks the runtimeapp.php, and guards one-time actions (migrate,db:seed, admin account creation) behind marker files (.initial-migration,.db-seeded,.admin-created) indataDir.phpfpm-invoiceninja.serviceandinvoiceninja-queue-worker.serviceboth depend on it. It also unconditionally runsinvoiceninja-manage cache:forget languageson every activation, working around an upstream bug: migration2025_08_31_224356_additional_languages_af_ca_id.phpruns duringmigrate(beforedb:seed'sLanguageSeederhas populated the other 42 rows) and doesCache::forever('languages', Language::all()), permanently caching just the 3 languages it sees at that moment — without this, the language dropdown in the app stays stuck at 3 entries forever, sinceStaticServiceProviderchecks that cache key before ever querying the DB again.invoiceninja-scheduler.timer/.servicerunartisan schedule:runevery minute — this is what drives Invoice Ninja's own internal cron-scheduled jobs (recurring invoices, scheduled reports, etc.); without it the app's dashboard reports "cron jobs are not enabled". Laravel's scheduler is just a dispatcher, so this one timer is sufficient regardless of how many scheduled tasks Invoice Ninja itself defines.invoiceninja-manageis a generated wrapper script (invoiceninja-manage <artisan-args>) thatcds into the package output and runsphp artisanas the configured service user (viasudoif invoked as another user). It's added toenvironment.systemPackages, so it's the normal way to run artisan commands (migrate,queue:work,ninja:create-account, etc.) by hand on a deployed system.- Both
nginxandcaddyproxy backends are supported (proxy.server), each independently duplicating the PHP-FPM passthrough config — if changing one, check whether the other needs the same change. Neither sets CORS headers itself: Laravel's own CORS middleware already sends a complete set on every response, and an earlier version of this module additionally set a second, static set ofAccess-Control-*headers at the proxy level, which browsers reject as conflicting duplicates (server-to-server/curl checks don't notice, since only browsers enforce CORS header validation). - Caddy matches vhosts on the exact
Hostheader, unlike nginx (where a single vhost acts as the default regardless ofHost). Whenhostname == "localhost", the caddy vhost addsserverAliasesfor127.0.0.1:80/[::1]:80too — otherwise visiting the VM via the IP literal instead oflocalhostmatches no vhost and Caddy silently returns an empty200 OK(a blank page with no error anywhere). - Chromium is required at runtime for PDF generation (
PDF_GENERATOR=snappdf,SNAPPDF_CHROMIUM_PATH) and is added to the PHP-FPM and data-setup services'path. Each PDF request launches a full chromium instance (not a lightweight renderer), and PHP-FPM's defaultpm.max_children = 8means up to 8 of these can run concurrently — on a memory-constrained deployment this can exhaust RAM (confirmed: even a couple of concurrent PDF requests OOM-killed a 1024MB VM). Size deployments accordingly, or lowerphpfpm.settings."pm.max_children".
flake.nix
Standard flake shape: overlays.default registers the package, packages.<system>.invoiceninja exposes it,
nixosModules re-exports nixos-module/default.nix, and nixosConfigurations.test-nginx/test-caddy each build
the VM defined in tests/default.nix with the corresponding proxyServer. Only x86_64-linux is currently
supported.
Conventions
- Nix files: 2-space indentation (see
.editorconfig); Unix line endings; final newline required. - Prefer
lib.mkDefault/lib.mkIf/lib.mkMergefor module config so downstream users can override cleanly — this is used pervasively ininvoiceninja.nixand should be followed for new options/config.