Files
hortusfox-web-nixos/CLAUDE.md
T
awkawbandClaude Sonnet 5 c7888c3f9d Fix asset/URL port mismatch when proxy.port differs from nginx/caddy's own listen port
Asatru's own url()/asset() helpers build absolute URLs from
$_SERVER['SERVER_PORT'] rather than APP_URL, and nginx/caddy populate
that from the port they're actually listening on -- not an externally
forwarded one (e.g. the QEMU test VM's hostfwd to 8080). That silently
dropped the port from every generated asset/redirect URL, breaking all
static assets when accessed through a forwarded port. Override
SERVER_PORT at the fastcgi layer to cfg.port when set, for both
backends.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 17:14:22 -04:00

16 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: exposes packages.hortusfox-web, overlays.default, nixosModules.default, devShells.default, checks.<system>.{build,vm-test,vm-test-caddy}, and a throwaway nixosConfigurations.test VM for manual testing. Only packages/devShells/checks are produced per-system via utils.lib.eachSystem; overlays.default, nixosModules.default, and nixosConfigurations.test are merged in afterwards with // since those output types are keyed by name, not by system — don't move them back inside the eachSystem callback.
  • package.nix — the derivation. Uses php.buildComposerProject to fetch danielbrendel/hortusfox-web from GitHub and vendor its Composer deps. Installs to $out/share/php/hortusfox-web/. Both src.hash and vendorHash are fixed-output hashes pinned to a specific upstream version/rev — bumping the app version means updating both.
  • nixos-module/default.nix — the services.hortusfox-web NixOS module (see Architecture below).
  • tests/default.nix — a minimal NixOS system config used as the nixosConfigurations.test VM (built/booted via just build-vm/just boot-vm), plus a secrets file (tests/test.env) and an admin password file (tests/hortusfox_web_test_password, currently password) consumed by the module.
  • tests/vm-test.nix — { pkgs, nixosModule, proxyServer }: pkgs.testers.nixosTest { ... }, the actual automated boot test. flake.nix calls it once per proxy backend (checks.vm-test for nginx, checks.vm-test-caddy for caddy) rather than inlining the test twice.
  • justfile — VM dev-loop wrapper around nix 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 to pkgs.callPackage ../package.nix { }) lives read-only in the Nix store (appRoot). A hortusfox-setup oneshot systemd service (before phpfpm starts) mirrors it into a runtime tree (runDir = /run/hortusfox/www, tmpfs) via a real, full cp -r — not cp -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 under runDir reports its own location as the read-only store, not runDir. HortusFox computes its whole app root (and from there, .env discovery) via __DIR__-relative paths, so every symlinked file silently looked for config in the store instead of runDir — breaking DB/session config on every real web request while looking fine on curl. 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.
  • cp carries the store's read-only mode onto the copy. Every directory cp -r creates inherits the store's 555 (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 can rm/relink anything under them.
  • Writable subdirs are seeded, not just replaced. cache, app/logs, public/img, public/attachments, public/backup, public/themes get 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 into stateDir/<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.rules declares a d line per entry in writableDirs, but app/logs and the four public/* entries are nested under app/public, which have no rule of their own. systemd-tmpfiles auto-vivifies a missing parent for a nested d line, but does so with default root:root 0755 ownership and does not reliably go on to create the requested leaf — so app/logs (and all of public/*) silently never got created, and the hortusfox-setup service's own mkdir -p "${stateDir}/${d}" would fail with Permission denied trying to create the leaf inside a root-owned, not-writable-by-cfg.user parent. Fixed by computing writableDirParents (the unique set of intermediate dirs, e.g. app, public) and giving each its own explicit tmpfiles d rule, ordered before the leaves. If you add a new nested entry to writableDirs, make sure its parent is covered by this too.
  • RuntimeDirectoryMode and stateDir's tmpfiles rules must both stay world-readable (0755). nginx/caddy run as their own user, not cfg.user, and need to stat/read everything under runDir and into stateDir's writable subdirs (public/img etc., reached through symlinks from runDir) to serve public/ directly. At 0750 the proxy can't even traverse the directory, tryFiles/file_server stats fail with Permission denied, and — this is the trap — the site can still appear to work, because PHP-FPM (running as cfg.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 plain curl on / won't catch it; the VM tests fetch a real static asset and grep the proxy's own log for Permission denied specifically because of it. Nothing secret is exposed by the world-readable mode: .env is a symlink to stateDir/.env, which stays 600 independently.
  • .env rendering. A base attrset of env vars (envVars) is merged with the freeform cfg.settings attrset (envVars // cfg.settings, so settings always wins) and serialized to KEY=VALUE lines via pkgs.writeText. envVars isn'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 no isset() guard (APP_BASEDIR in particular), so an omitted key produces a PHP Warning: Undefined array key on every single request, not just a missing feature. APP_VERSION derives from package.version rather than being duplicated by hand, so it can't drift from package.nix on a version bump. Secrets are handled separately: if cfg.secretFile is set, its contents are cat'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 in stateDir/.env, then is symlinked into runDir.
  • settings.APP_DEBUG matters more than it looks. Upstream's asatru CLI (app root/asatru file) refuses to run at all unless APP_DEBUG is true — it just prints a warning and exits 0. That means the hortusfox-setup service's php asatru migrate:fresh/migrate:upgrade calls are silent no-ops unless settings.APP_DEBUG = true (or it's set via secretFile). The module's own default is APP_DEBUG = false, so migrations do not run out of the box — this is why tests/default.nix and both vm-tests explicitly set settings.APP_DEBUG = true.
  • First-boot vs. upgrade migrations. stateDir/.installed is the marker: absent → php asatru migrate:fresh (destructive schema create) runs and then creates the marker; present → php asatru migrate:upgrade || true runs 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.passwordFile actually 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:fresh creates 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 .installed guard as migrations), if this is set, the setup script bcrypt-hashes the file's contents via php -r '...password_hash(...)...' (matching UserModel's own hashing) and INSERTs a row directly into the UserModel table (name admin, email admin@<domain>, admin flag set), bypassing the app's normal create-then-promote two-step flow since neither step is reachable yet. Both vm-tests exercise a full cookie-jar login against this seeded account, including scraping the CSRF token from /auth and checking the final URL isn't /auth again — 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.server selects the reverse-proxy backend: "nginx" (default) or "caddy", each gated by lib.mkIf (cfg.proxy.server == "..."). proxy.ssl (default true) toggles HTTPS/ACME on the vhost — nginx via forceSSL/enableACME, caddy by prefixing the site address with http:// (which disables its automatic HTTPS for that vhost specifically) — set it to false for local/test deployments with no real domain or internet access to reach Let's Encrypt (both vm-tests and tests/default.nix do this). services.phpfpm.pools.hortusfox runs as cfg.user/cfg.group regardless of proxy backend, but its fastcgi socket's listen.owner/listen.group must match whichever proxy is actually enabled (config.services.caddy.user/.group vs config.services.nginx.user/.group) — this was hardcoded to nginx before caddy support existed. nginx uses explicit tryFiles/location blocks (an Asatru-framework-style rewrite-to-index.php rule, mirroring upstream's .htaccess); caddy's php_fastcgi directive covers the same rewrite-then-serve behavior natively. Dotfiles are denied on both. When proxy.ssl = true, the deploying host config must set nginx's security.acme.{acceptTerms,defaults.email} or caddy's services.caddy.email globally (see comment at the bottom of the module).
  • port also has to override SERVER_PORT at the fastcgi layer, not just APP_URL. Asatru's own url()/asset() helpers (vendor/danielbrendel/asatru-php-framework/src/helper.php) never read APP_URL at all — they build every absolute URL straight from $_SERVER['SERVER_NAME']/SERVER_PORT. Nginx's/Caddy's default fastcgi env populates SERVER_PORT from the port the proxy itself is listening on (e.g. the QEMU test VM's unforwarded guest port 80), not an externally-reachable/forwarded one (e.g. the hostfwd'd 8080) — so with only APP_URL fixed, page HTML still loaded fine (that request itself hits the forwarded port directly) but every asset/CSS/JS URL the app generates silently dropped the port, breaking every static asset (net::ERR_CONNECTION_REFUSED in the browser, since nothing listens on host port 80). Fixed by explicitly overriding the SERVER_PORT fastcgi param/env var to cfg.port whenever it's set — nginx via an extra fastcgi_param SERVER_PORT "..."; line after include fastcgi_params (later same-name fastcgi_param wins), caddy via php_fastcgi's env SERVER_PORT ... sub-directive. Verified by booting nixosConfigurations.test manually and confirming assets load over the forwarded port.
  • 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 same systemd.services attrset as hortusfox-setup/phpfpm-hortusfox (merged in with //, generated via lib.mapAttrs' over cronjobs) — keep it that way; splitting the cron services into a second top-level systemd.services = ...; binding is a Nix duplicate-attribute error (partial-path assignments like systemd.services.hortusfox-setup = ... can't coexist with a later full systemd.services = ... reassignment in the same module).

Current state / known issues

  • nix flake check passes 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 automated vm-tests and by booting nixosConfigurations.test manually and logging in over HTTP with tests/hortusfox_web_test_password). Note tests/default.nix carries a fileSystems."/"/boot.loader.grub.devices/security.acme stanza purely so config.system.build.toplevel (what nix flake check builds) evaluates — the VM is actually built and run via .config.system.build.vm (see justfile's build-vm/boot-vm), which doesn't use any of that.
  • Upstream's own templates still emit a PHP Deprecated: htmlspecialchars(): Passing null to parameter #1 notice on some pages (PHP 8.1+ deprecation for htmlspecialchars(null)). Harmless and not something this repo can fix — it's app code, not packaging.