Deploy on NixOS
The Home First flake ships NixOS modules for both apps. Each runs as a hardened systemd service with its own local Postgres database (peer auth over the unix socket, so no database password exists anywhere), optional nightly backups, and an optional local decision model (Laya) for tendant.
This is the recommended way to run Home First on your own hardware.
1. Add the flake
Section titled “1. Add the flake”# flake.nix of your host{ inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; inputs.homefirst.url = "git+https://git.bcnelson.dev/home-first/homefirst";
outputs = { nixpkgs, homefirst, ... }: { nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { system = "x86_64-linux"; modules = [ homefirst.nixosModules.default # tendant + openprm + laya ./configuration.nix ]; }; };}Accept the flake’s binary cache when Nix asks (home-first.cachix.org), so
the servers download prebuilt instead of compiling Flutter and Go.
2. Create the secrets
Section titled “2. Create the secrets”Secrets never go into your Nix configuration — the Nix store is world-readable.
Put them in root-only files (by hand, or with sops-nix / agenix, which produce
files under /run/secrets):
sudo install -d -m 700 /etc/homefirst# tendant: the device-pairing password. Without it, no device can pair.openssl rand -base64 18 | sudo tee /etc/homefirst/tendant-password# tendant: AES-256 key sealing source credentials and cloud keys.openssl rand -base64 32 | sudo tee /etc/homefirst/tendant-credentials-key# openPRM: env file (it reads secrets from the environment).sudo tee /etc/homefirst/openprm.env >/dev/null <<EOFOPENPRM_CREDENTIALS_KEY=$(openssl rand -base64 32)OPENPRM_OWNER_PASSWORD_HASH='$(nix shell git+https://git.bcnelson.dev/home-first/homefirst#openprm -c openPRM hash-password 'your-password')'EOFsudo chmod 600 /etc/homefirst/*3. Configure the services
Section titled “3. Configure the services”{ config, ... }:{ services.homefirst.tendant = { enable = true; httpAddr = "127.0.0.1:8080";
credentials = { TENDANT_PASSWORD = "/etc/homefirst/tendant-password"; TENDANT_CREDENTIALS_KEY = "/etc/homefirst/tendant-credentials-key"; };
# tendant.toml, rendered for you. Anything from tendant.example.toml goes # here, including the lists env vars can't express. settings = { server.public_url = "https://myhost.tail1234.ts.net:8443"; llm_connections = [{ name = "local-ollama"; provider = "openai"; # Ollama speaks the OpenAI protocol base_url = "http://127.0.0.1:11434"; model = "gemma4:12b"; reasoning_effort = "none"; # gemma4 thinks by default; off is ~6x faster }]; agent.connection = "local-ollama"; overseer.connection = "local-ollama"; embedding = { provider = "openai"; model = "nomic-embed-text"; base_url = "http://127.0.0.1:11434/v1"; dimension = 768; }; };
gatescript.compile = true; # compile gate scripts in the editor (asc) backup.enable = true; };
services.homefirst.openprm = { enable = true; httpAddr = "127.0.0.1:8081"; # tendant has :8080 environmentFile = "/etc/homefirst/openprm.env"; settings = { OPENPRM_LLM_BACKEND = "ollama"; OPENPRM_LLM_ENDPOINT = "http://127.0.0.1:11434"; OPENPRM_LLM_MODEL = "gemma4:12b"; OPENPRM_LLM_THINK = "false"; }; backup.enable = true; };
# Local models for both apps. services.ollama = { enable = true; loadModels = [ "gemma4:12b" "nomic-embed-text" ]; };}nixos-rebuild switch, then check:
curl -fsS localhost:8080/healthz # tendantcurl -fsS localhost:8081/healthz # openPRMjournalctl -u tendant -u openprm -fWhat the modules do for you
Section titled “What the modules do for you”- Postgres: enables
services.postgresql, creates a role and database per app (tendant,openprm) owned by the service user, and for tendant installs pgvector and runsCREATE EXTENSION vectoras the superuser (the app role can’t). The servers start only after the databases exist. - Migrations run automatically every time a server starts.
- Hardening: no capabilities, read-only system, private
/tmp, restricted syscalls. All state lives in Postgres. - Gate-script tools:
asc(compiler) andtsgo(editor completion) are on tendant’sPATH. Completion is on by default; setgatescript.compile = trueto compile scripts on the server (the compiler runs inside the service’s sandbox, not in a separate one).
Secrets in tendant’s settings
Section titled “Secrets in tendant’s settings”Values in settings end up in the Nix store. For a secret (an API key, a
source token), reference it and deliver the value as a credential:
services.homefirst.tendant = { credentials.ANTHROPIC_API_KEY = "/run/secrets/anthropic"; settings.llm_connections = [{ name = "claude"; provider = "anthropic"; model = "claude-sonnet-4-6"; api_key = "\${env:ANTHROPIC_API_KEY}"; # resolved at start, from the credential }];};${file:/path} works too. If you’d rather manage the whole TOML yourself,
set configFile = "/run/secrets/tendant.toml"; instead of settings. Plain
non-secret environment variables go in environment.
Push notifications
Section titled “Push notifications”Both apps can push to ntfy or Gotify. The tokens are files, loaded as systemd credentials:
services.homefirst.tendant.notify.ntfy = { url = "https://ntfy.sh"; # or your own ntfy server topic = "our-house-8f3k2"; # hard to guess if the server is public tokenFile = "/run/secrets/ntfy-token"; # optional};services.homefirst.openprm.notify.gotify = { url = "https://gotify.example.com"; tokenFile = "/run/secrets/gotify-openprm";};4. Optional: Laya decision model
Section titled “4. Optional: Laya decision model”Laya is a small local model that answers tendant’s triage questions (“is this
a real task?”, “how much is at stake?”) in one call instead of an LLM
conversation, and serves decide.ask in gate scripts.
services.homefirst.laya.enable = true;This runs laya-serve on 127.0.0.1:8000 (CPU) and registers it in tendant as
the laya decision connection, used by default for intake triage. Disable the
wiring with services.homefirst.tendant.laya.connect = false.
- The first start downloads the model weights from Hugging Face into
/var/lib/laya(outbound HTTPS needed once; a few hundred MB). After that,services.homefirst.laya.offline = trueforbids network access. - Expect around 1–2 GB of RAM per loaded checkpoint.
modelspicks which ones load (englishby default).
5. Expose it on your tailnet only
Section titled “5. Expose it on your tailnet only”Both apps are built for one household. They don’t need to be on the public
internet, and shouldn’t be. Keep httpAddr on 127.0.0.1 and publish over
Tailscale:
services.tailscale.enable = true;sudo tailscale serve --bg --https=443 http://127.0.0.1:8080 # tendantsudo tailscale serve --bg --https=8443 http://127.0.0.1:8081 # openPRMDevices on your tailnet reach them at https://myhost.<tailnet>.ts.net, with
a valid certificate. Set tendant’s server.public_url and openPRM’s
OPENPRM_PUBLIC_URL to those addresses. Because tailscale serve is a
proxy on the same machine, also let the apps trust its forwarded headers:
services.homefirst.tendant.settings.server.trust_proxy_headers = true;services.homefirst.openprm.settings.OPENPRM_TRUSTED_PROXIES = "127.0.0.1,::1";If you do need a public hostname, each module has an nginx option
(nginx.enable, nginx.serverName, ACME) — use it together with an identity
provider in front, not on its own. With nginx.enable the modules also tell
the apps to trust the proxy’s forwarded headers (tendant’s
server.trust_proxy_headers, openPRM’s OPENPRM_TRUSTED_PROXIES), so
rate limiting sees real client addresses. If you put your own proxy in front
instead, set those yourself.
6. Backups
Section titled “6. Backups”backup.enable = true adds a <app>-backup timer (daily at 03:15, catches up
after downtime). It runs pg_dump --format=custom, checks the dump is
readable, and keeps the newest backup.keep (default 14) in
/var/backup/homefirst/<app>/.
systemctl list-timers '*-backup*'sudo systemctl start tendant-backup # take one nowls -l /var/backup/homefirst/tendant/Copy that directory off the machine (restic, borg, a NAS). Store the credentials keys separately (see above).
Restore
Section titled “Restore”APP=tendant # or openprmDUMP=$(ls -1 /var/backup/homefirst/$APP/$APP-*.dump | sort | tail -n1)
sudo systemctl stop $APPsudo -u postgres dropdb $APPsudo -u postgres createdb -O $APP $APP# the backup dir is readable only by the app user, so feed the dump on stdinsudo -u postgres pg_restore --dbname=$APP --single-transaction --exit-on-error < "$DUMP"sudo systemctl start $APPcurl -fsS localhost:8080/healthzRestoring on a new machine: deploy the same configuration first (so the roles and pgvector exist), put the same credentials keys in place, then run the steps above.
7. Upgrades
Section titled “7. Upgrades”- Read the release notes.
- Take a backup:
sudo systemctl start tendant-backup openprm-backup. nix flake update homefirst && sudo nixos-rebuild switch.- Watch
journalctl -u tendant -u openprm: migrations run at start; a failed migration stops the service before it serves anything.
To roll back, restore the dump you took in step 2, then
nixos-rebuild switch --rollback. Migrations are forward-only, so an older
build can’t open a database a newer one has migrated — restore the dump first.
Durable workflows that were in flight (a task waiting on your approval, say)
resume on the new version. The apps pin one stable workflow version across
releases, so any change to a workflow’s steps ships behind dbos.Patch /
dbos.DeprecatePatch and in-flight work isn’t replayed against steps that
changed under it. If a release note tells you to let workflows drain first,
resolve or cancel the waiting items before upgrading.