Files
2026-08-11 10:40:10 +01:00

160 lines
8.3 KiB
Markdown

# Deployment — relata.dixon.cx
Relata runs on `server.dixon.cx` as a Docker Compose stack managed by systemd, behind the
existing nginx.
`relata.dixon.cx` is **grey-clouded** in Cloudflare — DNS only, no proxy — so requests reach
this server directly. That was a deliberate change: the zone's managed challenge blocks
non-browser clients, which made API tokens unusable, and Cloudflare's 100-second proxy timeout
would have cut off answer generation. The cost is that the origin address is public.
```
client ──► nginx :443 ──► 127.0.0.1:8477 ──► app container
├── mongodb (compose network only)
├── qdrant (compose network only)
└── ollama (host, via bridge proxy)
```
## Files this deployment owns
| Path | What |
|---|---|
| `deploy/docker-compose.prod.yml` | The stack. Committed; contains no secrets. |
| `/etc/relata/relata.env` | Site configuration **and secrets**. Mode 600, root. Not in the repository. |
| `/etc/systemd/system/relata.service` | Brings the stack up at boot. |
| `/etc/systemd/system/relata-ollama-proxy.{socket,service}` | Bridges the host's Ollama onto the Docker network. |
| `/etc/systemd/system/ollama.service.d/keep-alive.conf` | Keeps models resident for 30 minutes. |
| `/etc/nginx/sites-available/relata.dixon.cx.conf` | TLS termination and proxying. |
| `/etc/letsencrypt/live/relata.dixon.cx/` | Certificate, renewed by the existing certbot timer. |
## Everyday operations
```bash
sudo systemctl status relata # is it up
sudo systemctl restart relata # restart the stack
sudo systemctl stop relata # stop it
docker compose -f deploy/docker-compose.prod.yml logs -f app # follow the app log
curl -s http://127.0.0.1:8477/health/ready | jq . # dependency status
```
**Note:** `/etc/relata/relata.env` is root-readable only, so `docker compose` must be run as
root (or through systemd). Running it as your own user fails with a permission error on the
env file — that is the file permissions working, not a fault.
## Deploying a new version
```bash
cd /home/andy/src/relata
make check # do not deploy something that does not pass
cd deploy
RELATA_VERSION=1.0.2 sudo -E docker compose -f docker-compose.prod.yml build app
sudo docker tag relata:1.0.2 relata:latest # systemd deploys the `latest` tag
sudo systemctl restart relata
```
Tagging both a version and `latest` means `latest` is always a tag you can name, so a rollback
is `docker tag relata:1.0.1 relata:latest && sudo systemctl restart relata`.
## Why the pieces are as they are
**MongoDB and Qdrant publish no ports.** They are reachable only over the compose network.
The development compose file publishes them for tooling; production has no reason to, and this
host has no firewall, so anything published is a decision to publish.
**Ollama is the host's, not a container.** The host already runs one with the models pulled; a
second copy would duplicate several gigabytes of weights and, on a CPU-only host, would not be
any faster — the bottleneck is the CPU, not the process boundary. The host binds it to
`127.0.0.1`, which containers cannot reach, so `relata-ollama-proxy.socket` listens on the
Docker bridge address (`172.17.0.1:11434`) and forwards to it.
The obvious alternative — `OLLAMA_HOST=0.0.0.0` — would have been one line, and on a host with
no firewall it publishes an unauthenticated model API to the internet. The socket proxy exposes
it to containers on the bridge and nothing else. That is still slightly wider than Relata alone:
any container on this host can reach Ollama through it.
**Ollama runs as a systemd service, and did not used to.** It was previously an `ollama serve`
process started by hand inside a tmux session, owned by `andy` and orphaned to init. It worked,
but it would not have survived a reboot — and Relata is enabled at boot and depends on it, so a
restart would have brought the site up with no embedding and no analysis.
It is now `ollama.service`, enabled, with the 6.8GB model store moved from `/home/andy/.ollama`
to `/usr/share/ollama/.ollama` where the service's own user can read it. `relata.service`
declares `After=ollama.service`.
**Models stay resident for 30 minutes** (`/etc/systemd/system/ollama.service.d/keep-alive.conf`).
Measured on this host: an answer takes **47s cold and 17s warm**, and almost all of the
difference is loading 5.2GB of `llama3.1:8b`. The default of 5 minutes expired between one
question and the next, so nearly every answer paid the load. The two models total ~5.8GB
against 31GB of RAM.
**The Cloudflare real-IP snippet is deliberately NOT included.** With the hostname grey-clouded,
`$remote_addr` is already the true client. That snippet trusts `CF-Connecting-IP` from any
Cloudflare address — with no Cloudflare proxy actually in front, anyone willing to route a
request through a Cloudflare Worker could then set their own address and give themselves a
fresh login-rate-limit bucket.
**If this hostname is ever put back behind the orange cloud, re-add the include.** Without it
every request would appear to come from a Cloudflare edge node and rate limiting would bucket
the whole internet together.
**nginx adds no security headers.** Relata sets its own CSP, X-Frame-Options,
X-Content-Type-Options and Referrer-Policy on every response. `add_header` in nginx appends
rather than replaces, and two conflicting X-Frame-Options headers are worse than one.
**Two locations have long timeouts.** `GET /search` and `POST /api/v1/search/answer` run a
language model inside the request. Everything else gets 120s.
## Known constraints
### Answer generation is slow, because this host has no GPU
8 cores, 31GB RAM, CPU-only inference. Measured with `llama3.1:8b`:
| | Time |
|---|---|
| Answer, model warm | ~17s |
| Answer, model cold | ~47s |
| Full ingestion pipeline, one short note | ~120s |
Ingestion is asynchronous — jobs run in the background and nobody waits — so 120s is a
non-issue. Answer generation is synchronous and is what a person actually feels.
A second Ollama container would not change any of this: same CPU, same weights, plus several
gigabytes of duplicated model store. What would:
1. A GPU. That is the whole of the gap.
2. `RELATA_ANALYSIS_MODEL=llama3.2:3b` — several times faster, noticeably worse, and the
safeguards still hold (an uncited answer is downgraded, a schema-echo answer is rejected).
3. `RELATA_SEARCH_ANSWER_MAX_CHUNKS=4` to send the model less material.
### Cloudflare (resolved, but worth knowing)
Both of the following applied while `relata.dixon.cx` was proxied, and are the reason it is now
grey-clouded. They return the moment the orange cloud goes back on:
- **Non-browser clients are challenged.** Requests without a browser get `403` with
`cf-mitigated: challenge` at the edge. This is zone-wide on `dixon.cx` — `grafana.dixon.cx`
and `intel.dixon.cx` behave the same way; `blackwall.cx` has an exception. API tokens are
unusable through it.
- **A 100-second proxy timeout** on non-enterprise plans, which cannot be raised from the
origin, returns 524 for a slow answer even though nginx and Relata would both have waited.
If you want the proxy back for its other benefits, both are fixable from the Cloudflare
dashboard: a **WAF → Custom rules → Skip** matching
`(http.host eq "relata.dixon.cx" and starts_with(http.request.uri.path, "/api/v1/"))` skipping
*Managed Challenge* and *Bot Fight Mode*, and either keeping answers under 100s (see above) or
accepting that they will be cut off. Re-add `snippets/cloudflare-realip.conf` to the site at the
same time.
### Everything else
- **First sign-in provisions a private workspace.** Each Blackwall identity gets its own, and
relationship discovery never crosses between them. See
[ADR 8](../docs/decisions/0008-federated-identity-private-workspaces.md).
- **Local password login is off.** Set `RELATA_LOCAL_LOGIN_ENABLED=true` in
`/etc/relata/relata.env` and restart to open the break-glass form if Blackwall is ever
unreachable. `relata createadmin` works regardless.
- **Backups.** `scripts/backup.sh` covers MongoDB and Qdrant. The compose volumes are
`relata-prod_mongo_data`, `relata-prod_qdrant_data` and `relata-prod_uploads`.