# hortusfox-web-nixos A Nix flake that packages [HortusFox](https://github.com/danielbrendel/hortusfox-web) — a self-hosted, PHP/Laravel-style collaborative plant-management app — from source, and provides a NixOS module to run it as a full service: mariadb + phpfpm + nginx or caddy + ACME + the app's own cron endpoints on systemd timers. There is no HortusFox source code in this repo — the app itself is fetched from upstream at build time via `php.buildComposerProject`. This repo is purely packaging and deployment glue: a `package.nix` derivation and a `services.hortusfox-web` NixOS module that wires the built package into a real, stateful host. ## Flake outputs | Output | What it is | | --- | --- | | `packages.hortusfox-web` / `packages.default` | The built HortusFox package. | | `overlays.default` | Adds `hortusfox-web` to `pkgs`. | | `nixosModules.default` | The `services.hortusfox-web` module. | | `devShells.default` | PHP (matching the module's build) + extensions, composer, `just`, `nix-output-monitor`. | | `checks..build` | Builds the package. | | `checks..vm-test` / `vm-test-caddy` | Boots a full nixosTest VM per proxy backend and exercises a real login. | | `nixosConfigurations.test` | A throwaway VM (`tests/`) for manual poking — not meant for real deployments. | ## Usage ```nix { inputs.hortusfox-web.url = "github:/hortusfox-web-nixos"; outputs = { self, nixpkgs, hortusfox-web, ... }: { nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { system = "x86_64-linux"; modules = [ hortusfox-web.nixosModules.default { services.hortusfox-web = { enable = true; domain = "plants.example.com"; adminAccount.passwordFile = "/run/secrets/hortusfox-admin-password"; adminAccount.email = "you@example.com"; secretFile = "/run/secrets/hortusfox.env"; # APP_KEY, SMTP_PASSWORD, ... }; # proxy.ssl defaults to true (forceSSL + ACME); the host config # must then set one of these globally: security.acme = { acceptTerms = true; defaults.email = "you@example.com"; }; # or, if services.hortusfox-web.proxy.server = "caddy": # services.caddy.email = "you@example.com"; } ]; }; }; } ``` ## `services.hortusfox-web` options | Option | Type | Default | Description | | --- | --- | --- | --- | | `enable` | bool | `false` | Enable the service. | | `domain` | string | `"localhost"` | FQDN served over HTTPS; used for the nginx/caddy vhost and ACME. | | `port` | null or port | `null` | Appended to the app's own canonical URL (`APP_URL`) and, when set, overrides the `SERVER_PORT` fastcgi param/env seen by PHP. Needed whenever the proxy is only reachable through a forwarded/non-standard port (e.g. the test VM's QEMU hostfwd) — Asatru's own `url()`/`asset()` helpers build every absolute URL from the *request's* `SERVER_PORT`, not from `APP_URL`, so without this every generated asset/redirect URL silently drops the real external port. Does not change what port nginx/caddy actually listen on. | | `package` | package | built from `package.nix` | The HortusFox package to deploy. | | `proxy.server` | `"nginx"` \| `"caddy"` | `"nginx"` | Reverse proxy/webserver fronting phpfpm. | | `proxy.ssl` | bool | `true` | Force HTTPS + request an ACME cert for the vhost. Set `false` for local/test deployments with no real domain or internet access to reach Let's Encrypt. | | `phpPackage` | package | `pkgs.php83` | Base PHP the phpfpm environment is built from (extensions are added automatically). | | `user` / `group` | string | `"hortusfox"` | System user/group the app and its phpfpm pool run as. | | `timezone` | string | `"UTC"` | App timezone (`APP_TIMEZONE`). | | `database.name` | string | `"hortusfox"` | MariaDB database name. | | `secretFile` | null or path | `null` | File of `KEY=VALUE` lines (`APP_KEY`, `SMTP_PASSWORD`, API keys, ...) appended verbatim onto the generated `.env` at runtime — not parsed, so it can carry arbitrary keys. Keep it out of the Nix store in production (agenix/sops-nix); a store path is fine for throwaway test configs. | | `adminAccount.passwordFile` | null or path | `null` | Plaintext password file for a seeded initial admin account. HortusFox has no CLI/seed hook of its own and its only "create user" flow lives behind the admin panel, so without this a fresh deployment has no way to log in at all. Only takes effect on first boot; changing it later has no effect on an existing account. | | `adminAccount.email` | string | `"admin@${domain}"` | Email for the seeded admin account. The default is just a guess at a real mailbox — set it to an address you actually control if you intend to use HortusFox's "Restore password" flow, which emails it. | | `settings` | attrs of str/int/bool | `{ }` | Extra/overriding `.env` entries, merged over the module's generated defaults (`settings` always wins). Note `php asatru` (used for migrations) only runs when `APP_DEBUG` is true, so migrations silently no-op unless it's set here or via `secretFile`. | | `smtp.host` | string | `""` | SMTP server host. | | `smtp.port` | port | `587` | SMTP server port. | | `smtp.username` | string | `""` | SMTP username. | | `smtp.fromName` | string | `"HortusFox"` | SMTP from-name. | | `smtp.fromAddress` | string | `"hortusfox@localhost"` | SMTP from-address. | | `smtp.encryption` | `"tls"` \| `"ssl"` \| `""` | `"tls"` | SMTP transport encryption. | SMTP password isn't a module option — set `SMTP_PASSWORD` via `secretFile` instead, alongside `APP_KEY`. ## What the module actually does - Copies the read-only Nix store package into a writable runtime tree (`/run/hortusfox/www`) on every boot, then symlinks a handful of subdirectories (`cache`, `app/logs`, `public/img`, `public/attachments`, `public/backup`, `public/themes`) into persistent state under `/var/lib/hortusfox`, seeding them from the package's own shipped defaults first so nothing gets silently deleted on first boot. - Renders `.env` from the module's curated + mirrored-upstream defaults, `settings` (always wins), and `secretFile` (appended verbatim). - Runs `php asatru migrate:fresh` on first boot (schema only) and `migrate:upgrade` on every boot after, guarded by a `stateDir/.installed` marker so a destructive fresh migration never runs twice. - Optionally seeds a single admin account directly into the database on first boot, since HortusFox has no other way to create the very first user. - Drives HortusFox's own HTTP cron endpoints (task/calendar reminders, auto-backup) via systemd timers rather than an in-process scheduler. See `nixos-module/default.nix` and `CLAUDE.md` for the full reasoning and the gotchas each of these design choices worked around (several were real, previously-shipped bugs). ## Local development / testing ```bash nix develop # dev shell: PHP+extensions, composer, just, nix-output-monitor just build-vm # build the throwaway test VM (nixosConfigurations.test) just boot-vm # boot it in-terminal (QEMU, serial console); http://127.0.0.1:8080 just clean # tear down local VM artifacts (this project's own qemu instance + build outputs) nix flake check # evaluate all outputs; builds the package + boots both proxy-backend VM tests nix flake check -L # same, with full build/VM logs streamed (slow: boots two real VMs) ``` The test VM's seeded admin account logs in with the password in `tests/hortusfox_web_test_password` (currently `password`) — see `tests/default.nix` for its full config.