Skip to content

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.

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

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):

Terminal window
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 <<EOF
OPENPRM_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')'
EOF
sudo chmod 600 /etc/homefirst/*
configuration.nix
{ 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:

Terminal window
curl -fsS localhost:8080/healthz # tendant
curl -fsS localhost:8081/healthz # openPRM
journalctl -u tendant -u openprm -f
  • Postgres: enables services.postgresql, creates a role and database per app (tendant, openprm) owned by the service user, and for tendant installs pgvector and runs CREATE EXTENSION vector as 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) and tsgo (editor completion) are on tendant’s PATH. Completion is on by default; set gatescript.compile = true to compile scripts on the server (the compiler runs inside the service’s sandbox, not in a separate one).

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.

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";
};

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 = true forbids network access.
  • Expect around 1–2 GB of RAM per loaded checkpoint. models picks which ones load (english by default).

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;
Terminal window
sudo tailscale serve --bg --https=443 http://127.0.0.1:8080 # tendant
sudo tailscale serve --bg --https=8443 http://127.0.0.1:8081 # openPRM

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

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

Terminal window
systemctl list-timers '*-backup*'
sudo systemctl start tendant-backup # take one now
ls -l /var/backup/homefirst/tendant/

Copy that directory off the machine (restic, borg, a NAS). Store the credentials keys separately (see above).

Terminal window
APP=tendant # or openprm
DUMP=$(ls -1 /var/backup/homefirst/$APP/$APP-*.dump | sort | tail -n1)
sudo systemctl stop $APP
sudo -u postgres dropdb $APP
sudo -u postgres createdb -O $APP $APP
# the backup dir is readable only by the app user, so feed the dump on stdin
sudo -u postgres pg_restore --dbname=$APP --single-transaction --exit-on-error < "$DUMP"
sudo systemctl start $APP
curl -fsS localhost:8080/healthz

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

  1. Read the release notes.
  2. Take a backup: sudo systemctl start tendant-backup openprm-backup.
  3. nix flake update homefirst && sudo nixos-rebuild switch.
  4. 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.