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>
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
.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.