awkawbandClaude Sonnet 5 e2ac57d9c6 Escape adminAccount.username/email before interpolating into the seed INSERT
Both fields were spliced directly into a single-quoted SQL string. An
ordinary value containing an apostrophe (e.g. adminAccount.username =
"O'Brien") broke the INSERT's SQL syntax outright, failing
hortusfox-setup on first boot under set -euo pipefail; a crafted value
could inject arbitrary SQL. Escape backslashes and quotes to match
MySQL/MariaDB's default string literal rules.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:19:24 -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%