Laravel's own CORS middleware already sends a complete, correct set of Access-Control-* headers on every response. The nginx and caddy vhost configs were additionally setting a second, static set of the same headers, producing duplicates with conflicting values (e.g. two Access-Control-Allow-Methods headers, one "*" and one the app's real list). Browsers reject responses with duplicate/conflicting CORS headers - curl-based checks never noticed since only browsers enforce CORS header validation, but it broke real browser usage (login/API calls failing silently). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
9.5 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.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, and logs in via/api/v1/loginusing the test admin credentials as an end-to-end check that migrations/seeding/account creation all worked. 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.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). - 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.
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.