295 lines
11 KiB
Markdown
295 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.
|
|
|
|
### 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).
|
|
|
|
```nix
|
|
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:
|
|
|
|
```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: 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 |
|
|
|
|
```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 `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) |
|