awkawbandClaude Sonnet 5 8ec061bede Point cron jobs at the app's own loopback address, not its public appUrl
Cron previously curled appUrl (the externally-reachable domain, with
cfg.port appended when set). That meant every cron tick depended on
the host being able to route back to its own public domain (hairpin
NAT, not guaranteed), and whenever cfg.port was set to an
externally-forwarded port nginx/caddy don't themselves listen on (e.g.
the QEMU test VM's hostfwd), cron would curl a port nothing is
listening on at all and fail outright.

curl --resolve now pins the connection to 127.0.0.1 on the port
nginx/caddy actually listen on (80, or 443 under proxy.ssl), while
keeping the Host header/SNI as cfg.domain so vhost routing and cert
validation still work normally. Also fixes the cron services'
"after" dependency, which was hardcoded to nginx.service even when
proxy.server = "caddy".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:21:27 -04:00

hortusfox-web-nixos

A Nix flake that packages HortusFox — 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.<system>.build Builds the package.
checks.<system>.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

{
  inputs.hortusfox-web.url = "github:<you>/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.username string "admin" Name for the seeded admin account — purely a display name, since HortusFox logs in by email rather than this field.
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

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.

S
Description
No description provided
Readme
338 KiB
Languages
Nix 97.3%
Just 2.7%