Files
chat/docs/NIXOS-HOSTING.md
2026-09-21 21:10:09 +02:00

11 KiB

Hosting KisAssistant on a NixOS server

End-to-end guide: build the flake, wire the module into configuration.nix, secure it with agenix/sops-nix, and put it behind nginx with automatic TLS.

Contents:

  1. Prerequisites
  2. Add the flake to your system inputs
  3. Secrets (APP_PASSWORD, API keys)
  4. Enable the service
  5. TLS reverse proxy (enableNginx)
  6. First deploy & smoke test
  7. Operations: logs, backups, updates
  8. Module option reference

1. Prerequisites

  • A NixOS machine (unstable channel or a flake lock close to it) with networking + firewall under your control.
  • Bun is not installed on the server — the flake wraps the runtime.
  • DNS: an A/AAAA record for your domain (e.g. chat.kisakay.com) pointing at the server. ACME (Let's Encrypt) needs port 80/443 reachable during issuance.
  • Ollama reachable from the server (e.g. http://10.66.66.4:11434 on your LAN/tailscale).

2. Add the flake to your system inputs

In your system flake (or flake.nix of your NixOS config):

{
  inputs.kisassistant.url = "http://git.kisakay.com/k/chat";  # adjust to real repo path

  outputs = { self, nixpkgs, kisassistant, ... }: {
    nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [
        ./configuration.nix
        kisassistant.nixosModules.default
      ];
    };
  };
}

If you don't use flakes for your system config, import the module by path:

imports = [ /etc/nixos/kisassistant/module.nix ];
# and point the package at a build:
services.kisassistant.package =
  (builtins.getFlake "git+http://git.kisakay.com/k/chat").packages.x86_64-linux.default;

The frontend is compiled offline inside the Nix build with Bun from frontend/bun.lock (fixed-output deps fetch). If nix build ever fails with a bunDepsHash mismatch, paste the got: sha256-… value from the error into bunDepsHash in flake.nix and rebuild. This is normal after dependency bumps.

3. Secrets (APP_PASSWORD, API keys)

Never put secrets in the Nix store. Use agenix or sops-nix. Minimal agenix setup:

# on your machine, in the secrets repo of the host
npx agenix generate kisassistant-password -a $(readlink -f /etc/ssh/ssh_host_ed25519_key.pub)
# write the admin key into the file, e.g.:
echo "$(openssl rand -base64 24)" | agenix -e kisassistant-password.age   # keep a copy!

Then on the host:

age.secrets.kisassistant-password.file = /etc/nixos/secrets/kisassistant-password.age;

The module consumes it as passwordFile and injects it into the service via systemd LoadCredential — the file is read at runtime only.

4. Enable the service

services.kisassistant = {
  enable = true;

  # REQUIRED — the module refuses to evaluate without it.
  package = kisassistant.packages.${pkgs.system}.default;

  # admin key file (agenix/sops-nix path on the host)
  passwordFile = config.age.secrets.kisassistant-password.path;

  # storage (created by systemd StateDirectory, owned by the service user)
  #   SQLite: /var/lib/kisassistant/data
  #   CDN:    /var/lib/kisassistant/cdn
  dataDir = "/var/lib/kisassistant";

  # LLM backends
  ollamaHost = "http://10.66.66.4:11434";
  extraEnv = {
    MISTRAL_ENABLED = "true";  # non-secret toggles only — see the note below
  };
};

The module automatically sets for the systemd unit:

Env Value
NODE_ENV production
DATA_DIR /var/lib/kisassistant/data
CDN_DIR /var/lib/kisassistant/cdn
DRIVER_DEBUG false
PORT / HOST 127.0.0.1:3000 (configurable)

The module also puts tesseract on the service PATH, so the OCR platform tool (POST /api/tools/ocr, image attachments) works out of the box. OCR_LANG / OCR_MAX_CHARS can be overridden via extraEnv.

API driver keys: extraEnv values land in the unit's Environment=, which is world-readable in the systemd unit file. For driver API keys prefer EnvironmentFile= via a drop-in (or pass them from agenix with systemd.services.kisassistant.serviceConfig.EnvironmentFile), e.g. services.kisassistant.extraEnv = { MISTRAL_ENABLED = "true"; }; plus a drop-in containing the key.

Arcaic backend (enableBrowserDriver)

The arcaic-* sub-drivers (arcaic-openai, arcaic-gemini, arcaic-qwen) answer through web-UI sessions: a headless Firefox from the nix store runs inside the service and drives the sites via a browser-automation session (WebDriver BiDi). Anonymous sessions work — no login needed — and every browser launch gets a throwaway profile (mkdtemp under /tmp, deleted on exit).

services.kisassistant = {
  enable = true;
  # …
  enableBrowserDriver = true;   # adds firefox to the service PATH and sets
};                              # ARCAIC_ENABLED/HEADLESS/EXECUTABLE

Overridable through extraEnv (values win over the module defaults): ARCAIC_LOGIN_TIMEOUT_S (default 300), ARCAIC_RESPONSE_TIMEOUT_S (default 300). The browser only starts on the first request that uses one of the arcaic-*:chat models (arcaic-openai, arcaic-gemini, arcaic-qwen) — boot cost stays zero otherwise.

Full isolation: run the whole app in a declarative NixOS container. The driver launches Firefox as a subprocess, so the browser must live in the same namespace as the backend — the cleanest isolation is an nspawn container wrapping the entire service:

containers.kisassistant = {
  autoStart = true;
  privateNetwork = true;
  hostAddress = "10.99.0.1";
  localAddress = "10.99.0.2";
  config = { config, pkgs, ... }: {
    imports = [ kisassistant.nixosModules.default ];
    services.kisassistant = {
      enable = true;
      package = kisassistant.packages.x86_64-linux.default;
      host = "0.0.0.0";
      enableBrowserDriver = true;
      passwordFile = "/var/lib/kisassistant-password";  # provision inside
    };
  };
};

# Host: nginx proxies to the container (enableNginx stays unset/false)
services.nginx.virtualHosts."chat.kisakay.com" = {
  forceSSL = true;
  enableACME = true;
  locations."/" = {
    proxyPass = "http://10.99.0.2:3000";
    extraConfig = "proxy_buffering off;";
  };
};

Headless mode needs no display server anywhere (validated: cookie banner accepted, message sent, response streamed). If the site ever starts headless-shaming, fall back to ARCAIC_HEADLESS=false inside the container plus an Xvfb service — same driver, virtual display.

5. TLS reverse proxy (enableNginx)

The conventional switch is enableNginx — a tri-state:

Value Behaviour
true nginx vhost + ACME configured (requires domain)
false nginx never touched, even if domain is set
null (default) legacy: nginx configured iff domain is set
services.kisassistant = {
  enableNginx = true;
  domain = "chat.kisakay.com";   # vhost name + ACME cert name
  # host/port default to 127.0.0.1:3000
};

# Required once per host for ACME (Let's Encrypt ToS):
security.acme.acceptTerms = true;
security.acme.defaults.email = "[email protected]";

# Keep the backend loopback-only and let nginx own 80/443:
networking.firewall.allowedTCPPorts = [ 80 443 ];

The generated vhost: forceSSL, enableACME, recommendedProxySettings, and proxy_buffering off on / so SSE token streaming (/api/chat) is never buffered.

Own nginx instead? Set enableNginx = false (or omit domain) and write your own vhost; proxy to http://127.0.0.1:3000 and remember proxy_buffering off; for / (or at least /api/chat).

6. First deploy & smoke test

sudo nixos-rebuild switch --flake .#myhost

Check the unit and the proxy:

systemctl status kisassistant
journalctl -u kisassistant -f            # boot log (APP_PASSWORD errors are fatal)

curl -I https://chat.kisakay.com/       # 200 via nginx
curl -s https://chat.kisakay.com/api/models -H "Authorization: Bearer $TOKEN"

Log in through the web UI as admin with the APP_PASSWORD from your secret file, then create user accounts from the Accounts panel (each gets a one-time ka_… key). See docs/OPERATIONS.md for account management.

If port 3000 is used by something else on the host, change services.kisassistant.port and the proxy target follows automatically.

7. Operations: logs, backups, updates

Logs — per-driver debug lines are off in production (DRIVER_DEBUG=false); flip temporarily with:

sudo systemctl edit kisassistant   # [Service] Environment=DRIVER_DEBUG=true
sudo systemctl restart kisassistant

Backups — everything is in /var/lib/kisassistant:

# SQLite is in WAL mode; prefer the backup API over raw copies:
sqlite3 /var/lib/kisassistant/data/kisassistant.db ".backup '/backup/kisassistant.db'"
systemctl stop kisassistant && tar -C /var/lib -czf /backup/kisassistant-$(date +%F).tgz kisassistant && systemctl start kisassistant

Updates — the usual flake flow:

nix flake update kisassistant          # or update the rev you pin
sudo nixos-rebuild switch --flake .#myhost
sudo nix store gc                      # keep the previous generation around as needed

After dependency bumps the first build may fail with a bunDepsHash mismatch — paste the reported hash into flake.nix (see section 2).

8. Module option reference

Option Type / default Description
enable bool the switch
package null (required) flake package, e.g. kisassistant.packages.${pkgs.system}.default
port port, 3000 backend listen port
host str, 127.0.0.1 backend bind address (keep loopback behind nginx)
dataDir str, /var/lib/kisassistant state root (data/ + cdn/ live inside)
user str, kisassistant system user/group for the service
passwordFile null | path file containing APP_PASSWORD (agenix/sops-nix)
ollamaHost str, http://localhost:11434 Ollama endpoint
domain null | str nginx vhost name, required when enableNginx = true
enableNginx null | bool, null true/false force the bundled reverse proxy on/off; null = nginx iff domain set (legacy)
enableBrowserDriver bool, false headless Firefox in the service for the arcaic driver ("Arcaic-Technology" model; sets ARCAIC_ENABLED/ARCAIC_HEADLESS/ARCAIC_EXECUTABLE, overridable via extraEnv)
extraEnv attrs of str, {} extra environment (non-secret values only)