Packages HortusFox from source via php.buildComposerProject and provides a NixOS module (services.hortusfox-web) to deploy it as a full service (mariadb + phpfpm + nginx/caddy + ACME + cron timers). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
15 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
A Nix flake that packages HortusFox (a self-hosted, PHP/Laravel-style collaborative plant-management app) from source, and provides a NixOS module to deploy it as a full service (mariadb + phpfpm + nginx/caddy + ACME + cron timers).
flake.nix— flake entry point: exposespackages.hortusfox-web,overlays.default,nixosModules.default,devShells.default,checks.<system>.{build,vm-test,vm-test-caddy}, and a throwawaynixosConfigurations.testVM for manual testing. Onlypackages/devShells/checksare produced per-system viautils.lib.eachSystem;overlays.default,nixosModules.default, andnixosConfigurations.testare merged in afterwards with//since those output types are keyed by name, not by system — don't move them back inside theeachSystemcallback.package.nix— the derivation. Usesphp.buildComposerProjectto fetchdanielbrendel/hortusfox-webfrom GitHub and vendor its Composer deps. Installs to$out/share/php/hortusfox-web/. Bothsrc.hashandvendorHashare fixed-output hashes pinned to a specific upstreamversion/rev— bumping the app version means updating both.nixos-module/default.nix— theservices.hortusfox-webNixOS module (see Architecture below).tests/default.nix— a minimal NixOS system config used as thenixosConfigurations.testVM (built/booted viajust build-vm/just boot-vm), plus a secrets file (tests/test.env) and an admin password file (tests/hortusfox_web_test_password, currentlypassword) consumed by the module.tests/vm-test.nix—{ pkgs, nixosModule, proxyServer }: pkgs.testers.nixosTest { ... }, the actual automated boot test.flake.nixcalls it once per proxy backend (checks.vm-testfor nginx,checks.vm-test-caddyfor caddy) rather than inlining the test twice.justfile— VM dev-loop wrapper aroundnix build/QEMU.
There is no application source code in this repo — HortusFox itself is fetched from upstream at build time. This repo is purely packaging/deployment glue.
Commands
just build-vm # nix build the test VM (nixosConfigurations.test), via `nom build`
just boot-vm # run the built VM in-terminal (QEMU, serial console, host 127.0.0.1:8080 -> guest 80)
just clean # kill this project's own qemu instance, remove ./result and ./nixos.qcow2
nix build .#hortusfox-web # build just the HortusFox package
nix build # same (default package)
nix develop # dev shell: matching PHP+extensions build, composer, just, nix-output-monitor
nix flake show # list outputs
nix flake check # evaluate all outputs; builds the package + runs both nixosTest VM checks
nix flake check -L # same, with full build/VM logs streamed (slow: boots two real VMs)
justfile's QEMU networking is bound to 127.0.0.1 explicitly (hostfwd=tcp:127.0.0.1:8080-:80) — a bare hostfwd=tcp::8080-:80 (empty host address) binds 0.0.0.0, exposing this throwaway VM (root password toor, firewall disabled, seeded test credentials) to the whole network. Likewise just clean kills only this project's own qemu process (pkill -f "$(pwd)/nixos.qcow2"), not pkill qemu generally, which would kill any unrelated QEMU VM on the host.
checks.<system>.vm-test/vm-test-caddy (tests/vm-test.nix) boot the module with proxy.ssl = false (no ACME/internet in the sandbox), settings.APP_DEBUG = true (required for migrations, see below), and a real adminAccount.passwordFile, then assert: the backend's own unit + phpfpm + mysql all come up; port 80 responds; the response contains no "Asatru PHP - Exception" marker (Asatru renders uncaught exceptions as HTML without failing the HTTP status — see below); a real static asset (/img/pattern.jpg) is served with no Permission denied in the proxy's own log; and — the strongest assertion — a full cookie-jar login as the seeded admin actually lands away from /auth rather than bouncing back to it. Each of these exists because a weaker check was previously fooled by a real bug during development (see the architecture notes below); don't weaken or remove any of them without understanding why. There's no other test suite; there's no lint/format command configured. just --list shows the same recipes as above.
Architecture (NixOS module)
The module (nixos-module/default.nix, config.services.hortusfox-web) wires HortusFox into a full host:
- Read-only store vs. writable state, reconciled at boot. The Composer-built package (
cfg.package, defaulting topkgs.callPackage ../package.nix { }) lives read-only in the Nix store (appRoot). Ahortusfox-setuponeshot systemd service (before phpfpm starts) mirrors it into a runtime tree (runDir = /run/hortusfox/www, tmpfs) via a real, fullcp -r— notcp -rs(per-file symlinks back into the store). This was a real, previously-shipped bug: PHP resolves__DIR__/__FILE__through a symlink to its real target, so a symlinked file underrunDirreports its own location as the read-only store, notrunDir. HortusFox computes its whole app root (and from there,.envdiscovery) via__DIR__-relative paths, so every symlinked file silently looked for config in the store instead ofrunDir— breaking DB/session config on every real web request while looking fine oncurl. Verified by a minimal local repro before fixing; don't reintroduce per-file symlinking here. The tradeoff is real: this duplicates the app into tmpfs on every boot instead of deduping via the store, which is an acceptable cost for a small PHP app. cpcarries the store's read-only mode onto the copy. Every directorycp -rcreates inherits the store's555(no write) mode, so the script reopens them for writing (find "${runDir}" -type d -exec chmod u+w {} +, directories only, never individual files) before it canrm/relink anything under them.- Writable subdirs are seeded, not just replaced.
cache,app/logs,public/img,public/attachments,public/backup,public/themesget swapped for symlinks into persistent state (stateDir = /var/lib/hortusfox). Two of these (public/img,public/themes) ship real default assets in the package itself (login background, default theme) — naively replacing them with an empty persistent dir on first boot silently deletes those defaults and breaks page rendering (Exception: Resource "img/pattern.jpg" not found). The fix: before symlinking,cp -rn(no-clobber) the package's copy of each writable dir intostateDir/<dir>first, so shipped defaults get seeded in (and any new ones added by a future upstream bump get picked up too) without ever touching a file the user/app has already written. - Nested writable dirs need their own tmpfiles rule for the parent, too.
systemd.tmpfiles.rulesdeclares adline per entry inwritableDirs, butapp/logsand the fourpublic/*entries are nested underapp/public, which have no rule of their own.systemd-tmpfilesauto-vivifies a missing parent for a nesteddline, but does so with defaultroot:root 0755ownership and does not reliably go on to create the requested leaf — soapp/logs(and all ofpublic/*) silently never got created, and the hortusfox-setup service's ownmkdir -p "${stateDir}/${d}"would fail withPermission deniedtrying to create the leaf inside a root-owned, not-writable-by-cfg.userparent. Fixed by computingwritableDirParents(the unique set of intermediate dirs, e.g.app,public) and giving each its own explicit tmpfilesdrule, ordered before the leaves. If you add a new nested entry towritableDirs, make sure its parent is covered by this too. RuntimeDirectoryModeandstateDir's tmpfiles rules must both stay world-readable (0755). nginx/caddy run as their own user, notcfg.user, and need tostat/read everything underrunDirand intostateDir's writable subdirs (public/imgetc., reached through symlinks fromrunDir) to servepublic/directly. At0750the proxy can't even traverse the directory,tryFiles/file_serverstats fail withPermission denied, and — this is the trap — the site can still appear to work, because PHP-FPM (running ascfg.user) serves pages fine over fastcgi regardless of the proxy's own filesystem access; only requests for static files (not routed through PHP) actually exercise this path. A plaincurlon/won't catch it; the VM tests fetch a real static asset and grep the proxy's own log forPermission deniedspecifically because of it. Nothing secret is exposed by the world-readable mode:.envis a symlink tostateDir/.env, which stays600independently..envrendering. A base attrset of env vars (envVars) is merged with the freeformcfg.settingsattrset (envVars // cfg.settings, sosettingsalways wins) and serialized to KEY=VALUE lines viapkgs.writeText.envVarsisn't just our curated options (domain/timezone/database.name/smtp.*) — it mirrors the entire default set from upstream's.env.example(app metadata, session duration, photo-resize factors,SMTP_AUTH,APP_BASEDIR, ...). Leaving any of these out isn't cosmetic: several app code paths read$_ENV[...]directly with noisset()guard (APP_BASEDIRin particular), so an omitted key produces aPHP Warning: Undefined array keyon every single request, not just a missing feature.APP_VERSIONderives frompackage.versionrather than being duplicated by hand, so it can't drift frompackage.nixon a version bump. Secrets are handled separately: ifcfg.secretFileis set, its contents arecat'd verbatim onto the end of the rendered file at runtime — it is not parsed or filtered, so it can carry arbitrary keys (APP_KEY,SMTP_PASSWORD, custom API keys, …). The rendered file lands instateDir/.env, then is symlinked intorunDir.settings.APP_DEBUGmatters more than it looks. Upstream'sasatruCLI (app root/asatrufile) refuses to run at all unlessAPP_DEBUGis true — it just prints a warning and exits 0. That means thehortusfox-setupservice'sphp asatru migrate:fresh/migrate:upgradecalls are silent no-ops unlesssettings.APP_DEBUG = true(or it's set viasecretFile). The module's own default isAPP_DEBUG = false, so migrations do not run out of the box — this is whytests/default.nixand bothvm-tests explicitly setsettings.APP_DEBUG = true.- First-boot vs. upgrade migrations.
stateDir/.installedis the marker: absent →php asatru migrate:fresh(destructive schema create) runs and then creates the marker; present →php asatru migrate:upgrade || trueruns instead. This is the guard against re-running a destructive migration against real data — don't remove it without understanding why it's there. adminAccount.passwordFileactually seeds an admin account. This was previously a documented no-op; it isn't anymore, because there is no other way to log in at all.migrate:freshcreates schema only, no rows. HortusFox has no CLI/seed hook of its own, and its only "create user" flow lives in the admin panel — which requires already being logged in as an admin. So on first boot only (same.installedguard as migrations), if this is set, the setup script bcrypt-hashes the file's contents viaphp -r '...password_hash(...)...'(matchingUserModel's own hashing) andINSERTs a row directly into theUserModeltable (nameadmin, emailadmin@<domain>, admin flag set), bypassing the app's normal create-then-promote two-step flow since neither step is reachable yet. Bothvm-tests exercise a full cookie-jar login against this seeded account, including scraping the CSRF token from/authand checking the final URL isn't/authagain — page content alone isn't reliable here, since the dashboard's own (hidden) change-password form makes "does the response contain a password field" a false-positive-prone check.- Serving.
proxy.serverselects the reverse-proxy backend:"nginx"(default) or"caddy", each gated bylib.mkIf (cfg.proxy.server == "...").proxy.ssl(defaulttrue) toggles HTTPS/ACME on the vhost — nginx viaforceSSL/enableACME, caddy by prefixing the site address withhttp://(which disables its automatic HTTPS for that vhost specifically) — set it tofalsefor local/test deployments with no real domain or internet access to reach Let's Encrypt (bothvm-tests andtests/default.nixdo this).services.phpfpm.pools.hortusfoxruns ascfg.user/cfg.groupregardless of proxy backend, but its fastcgi socket'slisten.owner/listen.groupmust match whichever proxy is actually enabled (config.services.caddy.user/.groupvsconfig.services.nginx.user/.group) — this was hardcoded to nginx before caddy support existed. nginx uses explicittryFiles/location blocks (an Asatru-framework-style rewrite-to-index.phprule, mirroring upstream's.htaccess); caddy'sphp_fastcgidirective covers the same rewrite-then-serve behavior natively. Dotfiles are denied on both. Whenproxy.ssl = true, the deploying host config must set nginx'ssecurity.acme.{acceptTerms,defaults.email}or caddy'sservices.caddy.emailglobally (see comment at the bottom of the module). - Cron. HortusFox's cron endpoints (task overdue/tomorrow/recurring reminders, calendar reminders, auto-backup) are driven by
curling the app's own HTTP cron routes from systemd timers (hortusfox-cron-<name>service+timer pairs), rather than an in-process scheduler. These live in the samesystemd.servicesattrset ashortusfox-setup/phpfpm-hortusfox(merged in with//, generated vialib.mapAttrs'overcronjobs) — keep it that way; splitting the cron services into a second top-levelsystemd.services = ...;binding is a Nix duplicate-attribute error (partial-path assignments likesystemd.services.hortusfox-setup = ...can't coexist with a later fullsystemd.services = ...reassignment in the same module).
Current state / known issues
nix flake checkpasses cleanly for all outputs (packages,checks.build,checks.vm-test,checks.vm-test-caddy,overlays,nixosModules,nixosConfigurations.test), and the VM actually serves a working, loggable-into site end-to-end for both proxy backends (verified via the automatedvm-tests and by bootingnixosConfigurations.testmanually and logging in over HTTP withtests/hortusfox_web_test_password). Notetests/default.nixcarries afileSystems."/"/boot.loader.grub.devices/security.acmestanza purely soconfig.system.build.toplevel(whatnix flake checkbuilds) evaluates — the VM is actually built and run via.config.system.build.vm(seejustfile'sbuild-vm/boot-vm), which doesn't use any of that.- Upstream's own templates still emit a
PHP Deprecated: htmlspecialchars(): Passing null to parameter #1notice on some pages (PHP 8.1+ deprecation forhtmlspecialchars(null)). Harmless and not something this repo can fix — it's app code, not packaging.