CronjobsController requires a `cronpw` query param matching AppModel.cronjob_pw, but cronjob_pw is generated at install time and INSERTed straight into the DB -- nothing ever surfaced it to the cron units' curl invocations, so every cron request has 403'd since this service existed. Cache the value to a cfg.user-only-readable file at generation time (backfilling it from the DB for already-installed deployments), and have each cron unit read it back and append it as `?cronpw=`. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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. Read by hortusfox-setup, which runs as the module's system user, not root — make sure its owner/group lets that user read it. |
backupMaxAge |
null or string | "1d" |
How long HortusFox's own export/auto-backup zips (public/backup, systemd-tmpfiles "age" syntax, e.g. "1d", "12h") are kept before auto-deletion. HortusFox names these with a fully predictable hf_backup_<timestamp>.zip pattern and serves them from the public webroot without ever cleaning them up itself, so anyone who knows or guesses the timestamp can otherwise download a full data export indefinitely. null disables cleanup entirely (not recommended for internet-facing deployments). |
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. Read by hortusfox-setup under the same non-root user as secretFile above. |
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
.envfrom the module's curated + mirrored-upstream defaults,settings(always wins), andsecretFile(appended verbatim). - Runs
php asatru migrate:freshon first boot (schema only) andmigrate:upgradeon every boot after, guarded by astateDir/.installedmarker 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.