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

293 lines
11 KiB
Markdown

# 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](#1-prerequisites)
2. [Add the flake to your system inputs](#2-add-the-flake-to-your-system-inputs)
3. [Secrets (`APP_PASSWORD`, API keys)](#3-secrets-app_password-api-keys)
4. [Enable the service](#4-enable-the-service)
5. [TLS reverse proxy (`enableNginx`)](#5-tls-reverse-proxy-enablenginx)
6. [First deploy & smoke test](#6-first-deploy--smoke-test)
7. [Operations: logs, backups, updates](#7-operations-logs-backups-updates)
8. [Module option reference](#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):
```nix
{
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:
```nix
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](https://github.com/ryantm/agenix)
or [sops-nix](https://github.com/Mic92/sops-nix). Minimal agenix setup:
```bash
# 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:
```nix
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
```nix
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.
### ChatGPT web driver (`enableBrowserDriver`)
The `puppeteer-openai` driver answers through the ChatGPT web UI: a headless
Firefox from the nix store runs **inside the service** and drives
`chatgpt.com` via puppeteer-core (WebDriver BiDi). Anonymous sessions work —
no ChatGPT login needed — and every browser launch gets a throwaway profile
(mkdtemp under `/tmp`, deleted on exit).
```nix
services.kisassistant = {
enable = true;
# …
enableBrowserDriver = true; # adds firefox to the service PATH and sets
}; # PUPPETEER_ENABLED/HEADLESS/EXECUTABLE
```
Overridable through `extraEnv` (values win over the module defaults):
`PUPPETEER_LOGIN_TIMEOUT_S` (default 300), `PUPPETEER_RESPONSE_TIMEOUT_S`
(default 300). The browser only starts on the **first request** that uses the
`puppeteer-openai:chatgpt` model — 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:
```nix
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 on CI-less builds:
cookie banner accepted, message sent, response streamed). If ChatGPT ever
starts headless-shaming, fall back to `PUPPETEER_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 |
```nix
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
```bash
sudo nixos-rebuild switch --flake .#myhost
```
Check the unit and the proxy:
```bash
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:
```bash
sudo systemctl edit kisassistant # [Service] Environment=DRIVER_DEBUG=true
sudo systemctl restart kisassistant
```
**Backups** — everything is in `/var/lib/kisassistant`:
```bash
# 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:
```bash
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 `puppeteer-openai` ChatGPT web driver (sets `PUPPETEER_ENABLED`/`PUPPETEER_HEADLESS`/`PUPPETEER_EXECUTABLE`, overridable via `extraEnv`) |
| `extraEnv` | attrs of str, `{}` | extra environment (non-secret values only) |