Verified from screenshots: - Q6/Q7 → Lists & calendars discovered by NAME, not by ID. Names confirmed: "Persönlich" (personal), "DLR" (work), "Einkaufsliste" (shopping), "Wunschliste" (wishes). Self-healing: #nexa:config runs daily and on cache-miss, so user-added lists become routable without code changes. Routing logic in docs/07 expanded from 2-way to 4-way switch with a Resolver step that consults the Qdrant _config cache before fanning out; default route is "Persönlich" when SAIA confidence < 0.7. - Q8 → Single Nextcloud Mail account fkrebs@nucli.de. No separate IMAP. The "Waiting" folder is honored as a manual user signal — items there are excluded from digest. - Q9 → Pocket-ID SSO is global at the Zoraxy layer; Nexa surfaces inherit it. No app-level basic-auth. Q10 folded in. - Q11 → Capacity confirmed: 22 threads (Core Ultra 7 155H), 62 GiB RAM (~32 GiB used, ~24 GiB of which is ZFS ARC), 1.64 TiB disk (0.35%). Phase-3 budget (~7 GB) is comfortable; two levers documented (raise LXC cap, or cap zfs_arc_max). Earlier "16 CPU" references corrected. Propagation: - system_prime.txt rewritten with the four context labels and the new list names; legacy NC_LIST_ID_* placeholders removed. - docs/04 integration matrix uses the German names directly. - docs/05 documents that #nexa:config also runs scheduled and on cache-miss, with the example output reflecting real list IDs. - docs/09 step 6 mirrors the same flow + lists the Waiting-folder semantics. - CLAUDE.md gets an authoritative block of homelab decisions so future agents don't re-litigate any of these. New section in docs/12 (#17–25): Proxmox host tuning suggestions based on the live node summary — cap ZFS ARC (frees ~16 GiB), enable KSM (~1-3 GiB savings across 40 containers), suppress the no-subscription banner, drop swappiness, verify scrub/SMART/fstrim timers, NTP via AdGuard.
13 KiB
09 — Deployment
Pragmatic deployment guide that assumes the existing homelab and adds only what's missing.
What's already running (no action required)
Surveyed from Homepage / Dozzle / Proxmox / Zoraxy:
| Service | Host / port | URL |
|---|---|---|
| Memos | docker LXC 104 → :5230 |
https://memos.nuclide.systems |
| n8n | docker LXC 104 → :5678 |
https://n8n.nuclide.systems |
| LiteLLM (SAIA gateway) | docker LXC 104 → :4000 |
https://ai.nuclide.systems (proxies LobeHub UI :3210; API on :4000) |
| Nextcloud | LXC 105 | https://nc.nuclide.systems |
| ntfy | docker LXC 104 → :7998 |
https://ntfy.nuclide.systems |
| Karakeep | docker LXC 104 → :3090 |
https://hoarder.nuclide.systems (legacy host alias kept for compatibility) |
| Home Assistant | VM 100 (HAOS) | https://ha.nuclide.systems |
| Pocket-ID (OAuth/SSO) | docker LXC 104 → :1411 |
https://id.nuclide.systems |
| Vaultwarden | docker LXC 104 → :11001 |
https://vault.nuclide.systems |
| Backrest | LXC 103 | (internal) |
| AdGuard DNS | LXC 102 | (internal) |
| Zoraxy reverse proxy | LXC 108 → 192.168.1.4:8000 |
TLS for *.nuclide.systems |
qdrant_scientific (existing) |
docker LXC 104 | reused — Nexa uses nexa_* collections in this instance |
The deployment task is not "spin up the stack" — most of the stack is already up. It is wire Nexa across these services + add the small bits that are missing.
What's missing for Nexa
- Qdrant collection for Nexa (
nexa_knowledge) inside the existingqdrant_scientificinstance — vector dim follows Q15 (1024 forbge-m3, 768 fornomic-embed-text). - TEI (HF text-embeddings-inference) on the docker host for self-hosted embeddings (LiteLLM key is not authorised for OpenAI embeddings — see 11/Q3+Q15). Lighter than Ollama: single Rust binary, ~500 MB image, no LLM runtime.
- n8n workflows (
./nexa-core/n8n-workflows/) imported into the running n8n. - Nextcloud lists & calendars for Work / Personal / Shopping / Wishes (auto-discovered via
#nexa:config). - Memos webhook → n8n wired through the Memos config.
- LiteLLM virtual key for the
nexauser with chat-only access (no embeddings — handled by Ollama). - A Zoraxy host entry is not needed — Memos / n8n / LiteLLM are already proxied.
- (Phase 3.4) Ontotext GraphDB for the SPARQL pillar — see add-on at the bottom of this doc.
Step 1 — Secrets
Copy nexa-core/.env.example → nexa-core/.env and fill only the secrets:
cd nexa-core
cp .env.example .env
$EDITOR .env # MEMOS_API_KEY, SAIA_API_KEY, NC_APP_PASSWORD, QDRANT_API_KEY
The .env is only used at bootstrap time. Everything else (list IDs, calendar IDs, collection sizes) is discovered at runtime via #nexa:config (see 05). No secrets should ever live in n8n workflow JSON — use n8n credentials instead.
Step 2 — Qdrant collection (nexa_knowledge_text)
Phase 3.1 ships Path A (text-only) but the schema and naming already make room for Path C (text + visual) so adding a nexa_knowledge_visual collection later is a pure additive operation — no rename, no migration, no n8n rewiring.
# adjust QDRANT_HOST in .env first
source nexa-core/.env
# create the text collection from the schema file
curl -X PUT "$QDRANT_HOST/collections/nexa_knowledge_text" \
-H "Content-Type: application/json" \
-H "api-key: $QDRANT_API_KEY" \
-d @nexa-core/config/qdrant_schema.json
The collection name is always suffixed with the modality (_text, _visual) so logic in n8n and SPARQL stays modality-aware from day one. Indexed rows carry these payload fields (source):
| Field | Why it's there now |
|---|---|
modality |
Always "text" in _text, "image" in _visual. Future-proofs cross-modality filters. |
source_type |
memo / mail / obsidian / screenshot / image — used by classification and digest workflows. |
media_uri |
memos://…, nextcloud://…, obsidian://…. Empty for text-only rows; populated when Path C ships. |
graph_iri |
IRI of the corresponding nexa:Note in GraphDB. The same value is stored on the GraphDB side as nexa:vectorId — this is the cross-pillar bridge. |
content_hash |
de-dup. |
context |
work / personal. |
Targets the existing
qdrant_scientificinstance — just an extra collection, no new container. Thevectors.sizefield follows Q15: 1024 forbge-m3, 768 fornomic-embed-text-v1.5.
Image attachments today (queue them)
Memos can already attach images. Until Phase 3.2 the indexer does not embed them, but it does record them so they can be replayed later:
- Memo with an image → text body still goes into
nexa_knowledge_text. - The image attachment(s) are written as
nexa:Notetriples in GraphDB withnexa:modality "image"andnexa:vectorIdleft empty (nexa:pendingVisualIndex true). - A Phase-3.2 backfill workflow will pick up everything where
?n nexa:pendingVisualIndex trueand embed it through the visual collection.
This means no data is lost between 3.1 and 3.2 — the queue is the GraphDB itself.
Step 3 — Self-hosted embeddings (TEI)
Use HuggingFace text-embeddings-inference — single Rust binary, ~500 MB image, OpenAI-compatible API, loads exactly one model. Lighter than Ollama because there's no LLM runtime, no GGUF loader, no model registry.
# on the docker host (LXC 104)
docker run -d --name nexa-embed \
--restart unless-stopped \
-p 127.0.0.1:8080:80 \
-v tei-data:/data \
ghcr.io/huggingface/text-embeddings-inference:cpu-1.5 \
--model-id BAAI/bge-m3
Memory budget: ~1.1 GB resident (bge-m3 is ~1 GB + ~100 MB overhead). First start downloads the model into the named volume; subsequent restarts are instant.
Register it inside LiteLLM (admin UI → Models) with the OpenAI-compatible adapter:
- model name:
nexa-embed - provider:
openai - model:
bge-m3 - api_base:
http://nexa-embed:80/v1 - api_key: any non-empty string (TEI ignores it)
Now n8n only ever talks to LiteLLM and the model is swappable without touching workflows.
Step 4 — LiteLLM virtual key
In the LiteLLM admin UI (ai.nuclide.systems):
- Create user
nexa. - Issue a virtual key with access to:
- one chat model (already-available model from your SAIA gateway).
- the
nexa-embedmodel from Step 3.
- Paste the key into
SAIA_API_KEYin.env.
Step 4 — n8n workflows
Import the JSON exports — credentials are filled inside n8n, not in the JSON:
# n8n personal access token from the n8n UI: Settings → API
N8N_URL=https://n8n.nuclide.systems
N8N_TOKEN=... # from the n8n UI
for f in nexa-core/n8n-workflows/phase-1/*.json \
nexa-core/n8n-workflows/phase-2/*.json; do
curl -X POST "$N8N_URL/api/v1/workflows" \
-H "X-N8N-API-KEY: $N8N_TOKEN" \
-H "Content-Type: application/json" \
--data-binary "@$f"
done
Inside n8n, attach credentials to the imported nodes:
- Memos → HTTP header
Authorization: Bearer $MEMOS_API_KEY - LiteLLM → header
Authorization: Bearer $SAIA_API_KEY(chat +nexa-embed) - Nextcloud Tasks / Calendar / WebDAV (Obsidian vault under
Notizen/) → one app password ($NC_APP_PASSWORD), reused across all three node types - Qdrant → header
api-key: $QDRANT_API_KEY
Activate each workflow individually after smoke-test.
Step 5 — Memos webhook
In the Memos admin UI, set the webhook URL to the production address of the discovery workflow:
https://n8n.nuclide.systems/webhook/memos
The same URL is the one the workflow exposes; verify with:
curl -i https://n8n.nuclide.systems/webhook/memos
# expect 200 / 405, never 404
Step 6 — Bootstrap commands via Memos
Create a memo with body #nexa:config — the discovery workflow:
- Lists Nextcloud Tasks lists, looking for
Persönlich,DLR,Einkaufsliste,Wunschliste(and any new lists added later — see 05). Cachesname → id. - Lists Nextcloud Calendars by the same names; caches IDs.
- Verifies the Nextcloud Mail account
fkrebs@nucli.deand the folder list (Posteingang,Archiv,Junk,Waiting). - Counts existing Qdrant points in
nexa_knowledge_text. - Verifies LiteLLM reachability + lists available models for the Nexa virtual key.
- Replies as a comment with a runtime-config snapshot that's stored in the Qdrant
_confignamespace (and mirrored asnexa-core/config/runtime_config.json, gitignored).
The same workflow is also triggered by:
- A daily cron inside n8n (so newly added Nextcloud lists become routable without intervention).
- Cache-miss in the task-router — if a list ID 404s, the router fires
#nexa:configonce and retries.
After this point, .env is read-once. Subsequent runs read config from Qdrant.
Step 7 — Smoke tests
# (1) Memos round-trip — should produce a comment within ~5 s
curl -X POST https://memos.nuclide.systems/api/v1/memos \
-H "Authorization: Bearer $MEMOS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"content":"- [ ] testing the router #nexa"}'
# (2) Classification dry-run
curl -X POST https://memos.nuclide.systems/api/v1/memos \
-H "Authorization: Bearer $MEMOS_API_KEY" \
-d '{"content":"#nexa:route-test buy milk"}'
# (3) RAG test (requires at least one indexed memo/note)
curl -X POST https://memos.nuclide.systems/api/v1/memos \
-H "Authorization: Bearer $MEMOS_API_KEY" \
-d '{"content":"#nexa:ask what is the goal of nexa?"}'
Step 8 — Reverse proxy
Already done — Zoraxy at 192.168.1.4:8000 terminates TLS for *.nuclide.systems and forwards to docker LXC 104 (192.168.1.40). No new entry is required for Nexa: every service Nexa talks to already has a host entry.
Step 9 — Backups
Already covered by Backrest (LXC 103). Add:
- n8n workflows →
nexa-core/scripts/backup_workflows.sh(already present) into a Backrest schedule. - Qdrant snapshots → schedule a daily
POST /collections/nexa_knowledge/snapshotsand rsync to S3 (s3.nuclide.systems). Add as a Backrest pre-hook on the docker host.
For deeper detail: 10 — Operations.
Phase add-on: Ontotext GraphDB (Phase 3.4)
Defer until 3.1–3.3 ship.
# nexa-core/docker-compose.graph.yml
services:
graphdb:
image: ontotext/graphdb:10.7.0
container_name: nexa-graphdb
ports: ["127.0.0.1:7200:7200"]
environment:
GDB_JAVA_OPTS: "-Xmx4g -Xms1g"
volumes:
- ./data/graphdb:/opt/graphdb/home
restart: unless-stopped
After first start, create the repository (one-time):
curl -X POST http://localhost:7200/rest/repositories \
-H 'Content-Type: application/json' \
-d '{
"id": "nexa_knowledge",
"title": "Nexa Knowledge Graph",
"type": "graphdb",
"params": {
"ruleset": {"value": "rdfsplus-optimized"},
"baseURL": {"value": "https://nuclide.systems/nexa/"}
}
}'
Optional Zoraxy entry graph.nuclide.systems → 192.168.1.40:7200 if you want the SPARQL Workbench in a browser; otherwise n8n talks to it on the docker network at http://nexa-graphdb:7200.
For schema and example queries: 08-graphrag-architecture.
Phase add-on: visual collection (Phase 3.2)
Adds Path C — image embeddings without disturbing the text path. Schema is already in nexa-core/config/qdrant_schema_visual.json.
# (1) replace TEI with infinity (or run alongside) for CLIP-family support
docker rm -f nexa-embed
docker run -d --name nexa-embed \
--restart unless-stopped \
-p 127.0.0.1:8080:80 \
-v infinity-data:/app/.cache \
michaelf34/infinity:latest \
v2 \
--model-id BAAI/bge-m3 \
--model-id jinaai/jina-clip-v2 \
--port 80
# (2) create the visual collection
curl -X PUT "$QDRANT_HOST/collections/nexa_knowledge_visual" \
-H "Content-Type: application/json" \
-H "api-key: $QDRANT_API_KEY" \
-d @nexa-core/config/qdrant_schema_visual.json
# (3) register the second model in LiteLLM as `nexa-embed-visual`
# (same OpenAI-compatible route, different model id)
# (4) backfill queued images:
# SPARQL: SELECT ?note ?uri WHERE { ?note nexa:pendingVisualIndex true ; nexa:mediaUri ?uri }
# For each row: fetch the bytes, embed via nexa-embed-visual, upsert into the visual collection,
# UPDATE GraphDB to set nexa:vectorId and DELETE nexa:pendingVisualIndex.
n8n RAG workflow gains a parallel branch: text-query → both nexa-embed-text and nexa-embed-visual text encoders → kNN against both collections → merge by score before SAIA prompt.
Step-back / rollback
- Disable any Nexa workflow in n8n — deactivates the side effect immediately, Memos webhooks become no-ops.
- Drop a Qdrant collection —
curl -X DELETE $QDRANT_HOST/collections/nexa_knowledge_text -H "api-key: $QDRANT_API_KEY". - Re-discover —
#nexa:reset-configthen#nexa:config.