This guide is for the people who run, extend, deploy, and audit Project N.O.M.A.D. — system administrators, DevOps, security reviewers, and developers contributing patches. End-user task-flow documentation lives in the User Guide; this document focuses on architecture, full configuration reference, operations, security, and internals.
1. Executive Summary
N.O.M.A.D. is an AdonisJS 6 TypeScript application served behind a Docker
Compose stack of seven containers. The admin server speaks Inertia.js to a
React 19 SPA, persists structured state in MySQL 8 via Lucid ORM, queues
background work in Redis 7 via BullMQ, and orchestrates an arbitrary number
of managed services (Kiwix, Ollama, Qdrant, Kolibri, etc.) by talking to
the host Docker socket through the dockerode library. Real-time UI updates
are pushed to the browser via @adonisjs/transmit (SSE over Redis pub/sub).
Two sidecar containers wrap operational concerns the admin can't safely do itself:
disk-collector— privileged read-only mount of the host filesystem, publishes JSON disk telemetry every 120 seconds.updater— receives signed update requests via a shared volume, editscompose.yml, pulls images, and recreates the admin container (so the admin can update itself without a chicken-and-egg problem).
The product target is a single-tenant LAN appliance, so the design deliberately skips authentication, multi-user accounts, and TLS termination inside the container. SSRF protection is the main exposed-surface defense.
| Aspect | Choice | Rationale |
|---|---|---|
| Backend framework | AdonisJS 6 | Opinionated DI container, TypeScript-native, mature MVC |
| Frontend | React 19 + Inertia.js | Server-side routing with SPA UX; no separate JSON API to maintain |
| Database | MySQL 8 | Familiar, ships with healthcheck, lockstep with mysql2 driver |
| Queue | Redis 7 + BullMQ | Persistent, scalable, supports queue priorities |
| Realtime | @adonisjs/transmit over Redis |
SSE; survives reverse proxies; no WebSocket upkeep |
| Container control | dockerode against host socket |
Lets admin install/start/stop/remove sibling services |
| Vector DB (RAG) | Qdrant | Filterable, self-hosted, simple HTTP API |
| Embeddings | nomic-embed-text via Ollama |
Self-contained; no separate embedding service |
| Maps engine | maplibre-gl + Protomaps PMTiles | Vector tiles, fully offline once cached |
| Doc renderer | Markdoc | Sandboxed, structured authoring of in-app docs |
2. Architecture Overview
2.1 Process & container topology
┌───────────────────────────────────────────────┐
│ Host (Debian/Ubuntu) │
│ │
│ /var/run/docker.sock │
│ ▲ │
│ │ (bind-mounted) │
│ ┌────────┴────────┐ │
│ │ nomad_admin │ ─ AdonisJS:8080 ─┐ │
│ │ (this app) │ │ │
│ └─┬───────┬─────┬─┘ │ │
│ │ │ │ ┌─────────────────▼────┐ │
│ │ │ └─►│ nomad_dozzle │ │
│ │ │ │ (read-only logs) │ │
│ │ │ └──────────────────────┘ │
│ ┌─▼───┐ ┌─▼───┐ │
│ │mysql│ │redis│ │
│ └─────┘ └─────┘ │
│ │
│ ┌──────────────┐ ┌────────────────────┐ │
│ │ disk-collec- │ │ updater (sidecar) │ │
│ │ tor sidecar │ │ shared volume IPC │ │
│ └──────────────┘ └────────────────────┘ │
│ │
│ Managed services (created on demand) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ kiwix │ │ ollama │ │ qdrant │ … │
│ └──────────┘ └──────────┘ └──────────┘ │
└───────────────────────────────────────────────┘
▲
│ HTTP :8080 (Inertia + REST)
┌─────────┴─────────┐
│ Browsers on LAN │
└───────────────────┘
Every container shares the project-nomad_default Docker network, named by
Docker Compose. Managed services are joined to the same network on creation
so the admin can reach them by container name (nomad_kiwix_server,
nomad_ollama, …).
Note
The admin container is the only component with access to the Docker socket. Sidecars get scoped capabilities (read-only host mount for
disk-collector, write access tocompose.ymlforupdater). Managed services never touch the socket.
2.2 Component inventory
| Component | Path | Role |
|---|---|---|
| HTTP server | admin/bin/server.ts (compiled) |
AdonisJS HTTP entrypoint, node bin/server.js |
| Routes | admin/start/routes.ts |
All 104 Inertia + REST routes |
| Kernel / middleware | admin/start/kernel.ts |
Server + router middleware stacks |
| Env validator | admin/start/env.ts |
Vine schema for required env vars; fails fast on bad config |
| Controllers | admin/app/controllers/*.ts |
13 thin HTTP layer classes |
| Services | admin/app/services/*.ts |
17 service classes (business logic, ~296 KB of code) |
| Models | admin/app/models/*.ts |
10 Lucid ORM models |
| Validators | admin/app/validators/*.ts |
10 Vine validation schemas |
| Background jobs | admin/app/jobs/*.ts |
BullMQ job processors |
| Constants | admin/constants/*.ts |
Service names, Ollama prompts, Kiwix command, broadcast channels |
| Migrations | admin/database/migrations/*.ts |
23 migrations (numerically sorted) |
| Frontend | admin/inertia/**/*.tsx |
React 19 SPA (Inertia adapter) |
| Install scripts | install/*.sh |
Bash one-shots for install/update/uninstall/migrate |
| Sidecar (disk) | install/sidecar-disk-collector/ |
Bash + lsblk polling loop |
| Sidecar (updater) | install/sidecar-updater/ |
Bash polls /shared/update-request |
2.3 Technology stack
| Layer | Library / runtime | Version pin |
|---|---|---|
| Runtime | Node.js | (image-pinned in GHCR build) |
| Framework | @adonisjs/core |
^6.18.0 |
| HTTP | AdonisJS HTTP server | bundled |
| ORM | @adonisjs/lucid + mysql2 |
^21.8.2 / ^3.14.1 |
| Realtime | @adonisjs/transmit (SSE) + react-adonis-transmit |
^2.0.2 / ^1.0.1 |
| Queues | bullmq |
^5.65.1 |
| Validation | @vinejs/vine |
^3.0.1 |
| Inertia | @adonisjs/inertia + @inertiajs/react |
^3.1.1 / ^2.0.13 |
| UI | React 19 + Tailwind 4 + Headless UI + Tabler Icons | — |
| Docs | @markdoc/markdoc |
^0.5.2 |
| LLM client | ollama SDK + openai SDK |
^0.6.3 / ^6.27.0 |
| Embeddings | @chonkiejs/core (chunking) + Ollama nomic-embed-text |
— |
| Vector DB | @qdrant/js-client-rest |
^1.16.2 |
| Maps | maplibre-gl + @protomaps/basemaps + pmtiles |
^4.7.1 / ^5.7.0 / ^4.4.0 |
| ZIM | @openzim/libzim |
^4.0.0 |
| File parsing | pdf-parse, tesseract.js, pdf2pic, cheerio, fast-xml-parser, jszip, tar, yaml |
— |
| System info | systeminformation, dockerode |
^5.31.0 / ^4.0.7 |
| Image work | sharp |
^0.34.5 |
| Misc | axios, luxon, compression, fuse.js, stopword, url-join |
— |
Note
The
package.jsonin the repo root carries only metadata (name,version,author). All runtime deps live inadmin/package.json. The rootversionfield is what semantic-release bumps; the admin one is pinned at0.0.0.
2.4 Request lifecycle
Browser ──HTTP──► nomad_admin :8080
│
▼
Server middleware stack
├── ContainerBindingsMiddleware (binds HttpContext)
├── @adonisjs/cors (origin '*', credentials true)
├── @adonisjs/vite (asset reload in dev)
├── @adonisjs/inertia (Inertia handshake)
├── @adonisjs/static (public/)
└── MapsStaticMiddleware (storage/maps/*)
│
▼
Router middleware stack (matched routes only)
├── BodyParser (multipart limit 110 MB)
├── @adonisjs/shield (XFrame=DENY, CSP/CSRF disabled)
└── CompressionMiddleware (compression library, gzip)
│
▼
Controller method ──► Service layer ──► Lucid model / Docker / Ollama
│
▼
Response: Inertia HTML, JSON, SSE stream, or static file
Streaming chat responses bypass the response object's normal flow: the
controller flushes SSE headers (Content-Type: text/event-stream,
Cache-Control: no-cache, Connection: keep-alive) before any RAG
preprocessing so the client connection stays open during the (potentially
slow) query rewrite + Qdrant search phase.
2.5 Data flow: AI chat with RAG
1. POST /api/ollama/chat (validated by chatSchema)
2. OllamaController.chat
├── Inject SYSTEM_PROMPTS.default if no system message
├── rewriteQueryWithContext()
│ └─► OllamaService.chat with SYSTEM_PROMPTS.query_rewrite
│ (skips when <3 user turns or qwen2.5:3b not installed)
├── ragService.searchSimilarDocuments(rewritten, 5, 0.3)
│ └─► Qdrant via @qdrant/js-client-rest
├── trim by RAG_CONTEXT_LIMITS based on model size
├── splice SYSTEM_PROMPTS.rag_context(joined) before first user msg
├── checkModelHasThinking() decides "think" param
├── numCtx auto-bump if system prompt > 3k tokens
├── chatStream() → for await chunk → response.write SSE frames
└── on end → chatService.addMessage(assistant, fullContent)
3. System Requirements
| Resource | Minimum | Recommended | Notes |
|---|---|---|---|
| OS | Debian 12 / Ubuntu 22.04 | Debian 12 / Ubuntu 24.04 | install_nomad.sh checks /etc/debian_version |
| Kernel | 5.15+ | 6.x | Required for cgroup v2 + recent overlayfs |
| Docker Engine | 24.x | 27.x | Compose v2 plugin required (v1 is rejected) |
| CPU | 4 cores | 8+ cores (AVX2) | sysbench scoring assumes modern x86_64 |
| RAM | 8 GB | 32 GB | 16 GB bare-minimum once a 7B model and Wikipedia load |
| Storage | 100 GB | 1 TB+ NVMe | Wikipedia full + maps + AI models = 200–500 GB |
| GPU | None | NVIDIA, CUDA 12+ | AMD ROCm not supported; toolkit auto-installed when detected |
| Open ports | 8080 (admin), 9999 (Dozzle) | + 11434 Ollama, + others on demand | installer does not configure firewall |
| Outbound network | One-time only | One-time only | After content download, fully air-gappable |
Note
The installer requires
bash(notsh) and requiressudo—check_is_bashandcheck_has_sudoare mandatory pre-flight gates. The Debian-family check is also enforced (/etc/debian_versionmust exist).
4. Configuration Reference
4.1 Environment variables (validated in admin/start/env.ts)
| Variable | Type | Default | Required | Notes |
|---|---|---|---|---|
NODE_ENV |
enum: development/production/test |
— | yes | Toggles dev tooling, logger pretty-print, cookie secure flag |
PORT |
number | — | yes | In-container HTTP port (do not change; map host port instead) |
APP_KEY |
string | — | yes | ≥ 16 chars; encrypts cookies, signs URLs (@adonisjs/core/helpers Secret) |
HOST |
host string | — | yes | Bind interface; production value is 0.0.0.0 |
URL |
string | — | yes | Public URL the UI is reached at (e.g. http://192.168.1.42:8080) |
LOG_LEVEL |
string | — | yes | debug / info / warn / error (pino levels) |
INTERNET_STATUS_TEST_URL |
string | optional | no | Override the URL used by the internet-status indicator |
DISABLE_COMPRESSION |
boolean | optional | no | Set true behind a compressing reverse proxy |
NOMAD_STORAGE_PATH |
string | optional | no | Reserved for storage relocation; default is /app/storage |
NOMAD_API_URL |
string | optional | no | Override https://api.projectnomad.us for catalog fetching |
DB_HOST |
host | — | yes | mysql in default compose |
DB_PORT |
number | — | yes | 3306 |
DB_USER |
string | — | yes | nomad_user in default compose |
DB_PASSWORD |
string | optional | no | Required when DB enforces auth (default config does) |
DB_DATABASE |
string | — | yes | nomad |
DB_SSL |
boolean | optional | no | Enable when MySQL TLS is configured |
REDIS_HOST |
host | — | yes | redis |
REDIS_PORT |
number | — | yes | 6379 |
APP_NAME |
string | optional | no | Used for the pino logger name |
Important
APP_KEYmust be ≥ 16 characters or AdonisJS refuses to boot — validation happens duringEnv.createat startup. The installer generates 32 random alphanumeric chars viatr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32.
4.2 Container environment (from install/management_compose.yaml)
| Variable | Default | Notes |
|---|---|---|
NODE_ENV |
production |
Compose overrides Adonis runtime env |
PORT |
8080 |
In-container only; remap on host side |
HOST |
0.0.0.0 |
Listen on all interfaces in the container |
LOG_LEVEL |
info |
Switch to debug while triaging |
URL |
(templated) | Replaced by installer with http://<lan-ip>:8080 |
APP_KEY |
(templated) | 32-char random; replace before first start |
DB_HOST |
mysql |
Compose service name resolution |
DB_PORT |
3306 |
Internal MySQL port |
DB_DATABASE |
nomad |
Database name |
DB_USER |
nomad_user |
Non-root MySQL user |
DB_PASSWORD |
(templated) | Must equal MySQL service MYSQL_PASSWORD |
DB_NAME |
nomad |
Legacy alias for DB_DATABASE |
DB_SSL |
false |
TLS disabled by default |
REDIS_HOST |
redis |
|
REDIS_PORT |
6379 |
|
DISABLE_COMPRESSION |
false |
Skip the in-process gzip middleware |
4.3 In-app settings (KV store + benchmark settings)
The kv_store table holds free-form, schema-validated settings. Defined keys
(from admin/constants/kv_store.ts):
| Key | Type | Purpose |
|---|---|---|
chat.suggestionsEnabled |
boolean | Show prompt-starter chips on the chat home |
chat.lastModel |
string | Persist last used Ollama model per server (UI also remembers per-browser) |
ui.hasVisitedEasySetup |
boolean | Suppresses the easy-setup nag once first run completes |
ui.theme |
string | Server-side fallback for theme; React provider owns the actual choice |
system.earlyAccess |
boolean | Reveals beta UI |
ai.assistantCustomName |
string | User-facing rename of "AI Assistant" |
ai.remoteOllamaUrl |
string | Switch to remote LLM mode |
ai.ollamaFlashAttention |
boolean | Pass OLLAMA_FLASH_ATTENTION=1 to the local Ollama container |
The benchmark_settings table holds keys with their own type system:
| Key | Type | Purpose |
|---|---|---|
allow_anonymous_submission |
string ('true'/'false') | Default for the submit-anonymous toggle |
installation_id |
UUID string | Persistent per-install identifier sent with submissions |
last_benchmark_run |
ISO 8601 string | When the last benchmark completed |
4.4 AdonisJS config files (admin/config/*.ts)
| File | Purpose | Notable values |
|---|---|---|
app.ts |
HTTP cookie defaults | maxAge: '2h', httpOnly: true, secure: app.inProduction, sameSite: 'lax' |
bodyparser.ts |
Body parsing | multipart.limit = '110mb' (knowledge-base file ceiling = 100 MB) |
cors.ts |
CORS policy | origin: ['*'], credentials: true, maxAge: 90, methods incl. DELETE/PUT |
database.ts |
Lucid + MySQL | Pool: min 2, max 15, acquireTimeoutMillis: 10_000; debug enabled when NODE_ENV=development |
hash.ts |
Password hashing | scrypt: cost: 16384, blockSize: 8, parallelization: 1, maxMemory: 33_554_432 |
inertia.ts |
Inertia shared props | appVersion, environment, aiAssistantName (cached 60s server-side) |
logger.ts |
Pino logger | Pretty in dev; in production writes to /app/storage/logs/admin.log |
queue.ts |
BullMQ connection | Reads REDIS_HOST + REDIS_PORT |
session.ts |
Sessions | Disabled (entire file commented out — N.O.M.A.D. is sessionless) |
shield.ts |
Security headers | csp.enabled: false, csrf.enabled: false, xFrame.enabled: true / DENY, hsts.enabled: false, contentTypeSniffing.enabled: true |
static.ts |
Static asset middleware | etag: true, lastModified: true, dotFiles: 'ignore', acceptRanges: true |
transmit.ts |
SSE transport | Redis driver, pingInterval: '30s', keyPrefix: 'transmit:' |
vite.ts |
Vite asset bundler | Build output → public/assets, manifest → public/assets/.vite/manifest.json |
4.5 REST + Inertia route map
A consolidated table; see admin/start/routes.ts:1 for source of truth.
Inertia (HTML) routes
| Path | Controller |
|---|---|
GET / |
HomeController.index (redirects to /home) |
GET /home |
HomeController.home |
GET /about |
inline render |
GET /chat |
ChatsController.inertia |
GET /maps |
MapsController.index |
GET /knowledge-base |
redirect → /chat?knowledge_base=true |
GET /easy-setup |
EasySetupController.index |
GET /easy-setup/complete |
EasySetupController.complete |
GET /docs/:slug |
DocsController.show |
GET /docs |
redirect → /docs/home |
GET /settings/{system,apps,legal,maps,models,update,zim,zim/remote-explorer,benchmark,support} |
SettingsController.<page> |
JSON API routes (104 endpoints across 9 prefixes)
| Prefix | Controller | Method count |
|---|---|---|
/api/easy-setup/* + /api/manifests/* |
EasySetupController |
4 |
/api/content-updates/* |
CollectionUpdatesController |
3 |
/api/maps/* |
MapsController |
14 |
/api/docs/* |
DocsController |
1 |
/api/downloads/* |
DownloadsController |
4 |
/api/health |
inline | 1 |
/api/ollama/* |
OllamaController |
7 |
/api/chat/sessions/* + /api/chat/suggestions |
ChatsController |
8 |
/api/rag/* |
RagController |
8 |
/api/system/* |
SystemController + SettingsController |
17 |
/api/zim/* |
ZimController |
8 |
/api/benchmark/* |
BenchmarkController |
11 |
4.6 Database migrations (in execution order)
| Filename prefix | Purpose |
|---|---|
1751086751801_create_services_table |
Core service catalog |
1763499145832_update_services_table |
Add columns |
1764912210741_create_curated_collections_table |
Legacy curated content (later dropped) |
1764912270123_create_curated_collection_resources_table |
(later dropped) |
1768170944482_update_services_add_installation_statuses_table |
installation_status enum |
1768453747522_update_services_add_icon |
Tabler icon name on service rows |
1769097600001_create_benchmark_results_table |
Benchmark history |
1769097600002_create_benchmark_settings_table |
Benchmark KV |
1769300000001_add_powered_by_and_display_order_to_services |
UI metadata |
1769300000002_update_services_friendly_names |
Backfill display names |
1769324448000_add_builder_tag_to_benchmark_results |
Builder-tag column |
1769400000001_create_installed_tiers_table |
Legacy (dropped later) |
1769400000002_create_kv_store_table |
KV store |
1769500000001_create_wikipedia_selection_table |
Single-row Wikipedia selection |
1769646771604_create_create_chat_sessions_table |
Chat sessions |
1769646798266_create_create_chat_messages_table |
Chat messages |
1769700000001_create_zim_file_metadata_table |
ZIM metadata cache |
1770269324176_add_unique_constraint_to_curated_collection_resources_table |
(legacy, before drop) |
1770273423670_drop_installed_tiers_table |
Cleanup |
1770849108030_create_create_collection_manifests_table |
Spec snapshot store |
1770849119787_create_create_installed_resources_table |
Per-resource installs |
1770850092871_create_drop_legacy_curated_tables_table |
Drop legacy curated tables |
1771000000001_add_update_fields_to_services |
Available-update tracking |
1771000000002_pin_latest_service_images |
Pin image tags |
1771100000001_migrate_kiwix_to_library_mode |
Switch Kiwix command line |
1771200000001_create_map_markers_table |
Map markers |
naturalSort: true is set in database.ts so the millisecond prefix is
respected.
5. Deployment & Scaling
5.1 Reference deployment (single host)
The supplied install/management_compose.yaml is the canonical layout and
starts six containers:
| Container | Image | Restart | Healthcheck |
|---|---|---|---|
nomad_admin |
ghcr.io/crosstalk-solutions/project-nomad:latest |
unless-stopped |
curl -f http://localhost:8080/api/health every 30s |
nomad_dozzle |
amir20/dozzle:v10.0 |
unless-stopped |
none |
nomad_mysql |
mysql:8.0 |
unless-stopped |
mysqladmin ping -h localhost every 30s |
nomad_redis |
redis:7-alpine |
unless-stopped |
redis-cli ping every 30s |
nomad_updater |
ghcr.io/crosstalk-solutions/project-nomad-sidecar-updater:latest |
unless-stopped |
none |
nomad_disk_collector |
ghcr.io/crosstalk-solutions/project-nomad-disk-collector:latest |
unless-stopped |
none |
Volumes:
volumes:
/opt/project-nomad/storage -> /app/storage # admin
/var/run/docker.sock -> /var/run/docker.sock # admin + dozzle + updater
/opt/project-nomad/mysql -> /var/lib/mysql # mysql
/opt/project-nomad/redis -> /data # redis
/opt/project-nomad -> /opt/project-nomad # updater (RW)
/ -> /host (ro,rslave) # disk-collector
nomad-update-shared (named) -> /app/update-shared # admin <-> updater IPC
extra_hosts: ["host.docker.internal:host-gateway"] is set so the admin can
reach services bound to the host (Linux equivalent of the macOS shortcut).
5.2 Scaling considerations
| Bottleneck | Symptom | Fix |
|---|---|---|
| MySQL pool exhaustion | acquireTimeoutMillis errors after ~10s |
Bump pool.max in database.ts, scale MySQL vertically |
| BullMQ throughput | Backlog in nomad_admin worker logs |
Run dedicated worker processes: node ace queue:work --queue=downloads, --queue=model-downloads, --queue=benchmarks |
| Ollama inference | Slow tokens/sec | Add NVIDIA GPU; or move to remote Ollama on a beefier box |
| Disk full | Failed to dispatch download job |
Move /opt/project-nomad/storage to a bigger volume; rerun migrate-disk-collector.sh if needed |
| Network bandwidth | Slow ZIM downloads | Use the resumable downloader's auto-retry; pre-stage ZIMs from a LAN mirror with the Custom URL option |
Tip
Run multiple queue workers per host to parallelize background work without running multiple admin instances. The admin process auto-spawns one
queue:work --allworker; bring up additional workers as separate compose services pointing at the samecompose.yml.
5.3 Updates
Production updates flow through the sidecar pattern:
- UI button (
POST /api/system/update) →SystemUpdateService.requestSystemUpdate - Writes
{target_tag, ...}JSON to/app/update-shared/update-request nomad_updaterpolls/shared/update-requestonce per second- On request:
sed -irewrites the admin image tag incompose.yml,docker compose pull, then sequentialstop/rm/up -d --no-depsforadmin,mysql,redis,dozzle - Status JSON is written to
/shared/update-status; admin reads it for UI progress
If you've manually customized compose.yml, avoid the in-app update
button — sed-based rewrites can leave indentation broken on heavily-edited
files. Use update_nomad.sh instead.
Warning
A botched update can wedge
compose.yml. The updater always backs up tocompose.yml.bak.<timestamp>before editing — keep/opt/project-nomadbacked up so you can roll back.
6. Operations
6.1 Day-2 runbooks
Restart the admin only
docker compose -p project-nomad -f /opt/project-nomad/compose.yml restart admin
Tail logs
docker logs -f --tail 200 nomad_admin
docker compose -p project-nomad -f /opt/project-nomad/compose.yml logs -f
# Or use the embedded Dozzle UI at http://<host>:9999
Force-rerun migrations
The admin runs node ace migration:run --force from install/entrypoint.sh
on every container start. To re-trigger out-of-band:
docker exec -it nomad_admin node ace migration:run --force
Re-seed the service catalog
docker exec -it nomad_admin node ace db:seed
Idempotent: the seeder uses upserts on service_name.
Back up
sudo /opt/project-nomad/stop_nomad.sh
sudo tar czf nomad-$(date +%F).tar.gz -C /opt project-nomad
sudo /opt/project-nomad/start_nomad.sh
For a faster, app-consistent backup of just the metadata (excluding bulky
content), use mysqldump:
docker exec nomad_mysql mysqldump -u nomad_user -p nomad > nomad.sql
Rotate APP_KEY
APP_KEY rotation will invalidate every signed URL in flight (Maps assets,
download tokens). Plan a brief outage:
- Stop the stack.
- Edit
/opt/project-nomad/compose.yml→ set newAPP_KEY=.... - Start the stack.
Env.createwill validate; admin restarts.
Note
Cookies are also encrypted with
APP_KEY(@adonisjs/core/helpers Secret), so users may need to re-load their tabs. The stack does not currently use server sessions, so there's no session blow-up to worry about.
6.2 Monitoring & telemetry
- Health:
GET /api/healthreturns{"status":"ok"}plain — wire to Uptime Kuma / Healthchecks.io. - System info:
GET /api/system/inforeturns disk, RAM, CPU, GPU, and internet-status data structured for the dashboard. Expensive — limit polling to once every 5–15s in custom dashboards. - Internet status:
GET /api/system/internet-statusis the lightweight endpoint the UI polls. Override the test URL viaINTERNET_STATUS_TEST_URL. - Container state: read directly from Dozzle (
:9999) ordocker ps. - Update status:
GET /api/system/update/statusreturns the JSON the updater sidecar wrote to the shared volume —idle | starting | pulling | pulled | recreating | complete | error. - Queue: BullMQ stores state in Redis. Use
bullmq-clior any BullMQ dashboard pointed atredis://nomad_redis:6379.
Tip
The admin's pino logger writes to
/app/storage/logs/admin.login production. Mounted to the host at/opt/project-nomad/storage/logs/. Hook Promtail/Vector here to centralize.
6.3 Background job queues
| Queue | Worker class | Purpose |
|---|---|---|
downloads |
RunDownloadJob |
Resumable ZIM/PMTiles downloads with stall detection |
model-downloads |
DownloadModelJob |
Ollama pull with progress streaming |
benchmarks |
RunBenchmarkJob |
Async benchmark runs (sysbench + Ollama) |
| (no name) | EmbedFileJob |
Knowledge-base file embedding |
| (no name) | CheckUpdateJob |
Periodic GitHub release check |
| (no name) | CheckServiceUpdatesJob |
Container image update polling |
Run dedicated workers via the npm scripts in admin/package.json:
node ace queue:work --queue=downloads
node ace queue:work --queue=model-downloads
node ace queue:work --queue=benchmarks
node ace queue:work --all # one worker handling every queue
6.4 Pre-deploy checklist
- [ ] Snapshot
/opt/project-nomad(or at leastmysql,redis,compose.yml) - [ ] Confirm
docker compose versionreports v2 - [ ] Verify
nvidia-smiworks on the host (if GPU-using) - [ ] Confirm
curl http://localhost:8080/api/healthreturns{"status":"ok"} - [ ] Set
LOG_LEVEL=info(notdebug) for production - [ ] Plan firewall rules for
8080(admin) and9999(Dozzle) - [ ] Update Crosstalk's GHCR PAT in your CI mirror if you build from source
- [ ] Run a Full benchmark and record the score for hardware regression checks
7. Security
7.1 Threat model
N.O.M.A.D. is built for a trusted LAN appliance:
- No authentication on the admin UI or REST API.
- No HTTPS termination inside the container.
- The admin container has full access to the host Docker socket.
- Sidecar containers have specific elevated privileges
(
disk-collectorreads the host root filesystem;updaterwritescompose.yml).
This design is acceptable on a private LAN behind a network firewall but unacceptable on the open internet. Do not expose port 8080 publicly.
Caution
Anyone on the LAN can stop services, install/uninstall containers, browse chat history, and trigger updates. If you need network segmentation, put N.O.M.A.D. on a dedicated VLAN.
7.2 SSRF protection
The platform blocks loopback and link-local URLs at the validator layer:
// admin/app/validators/common.ts
const blockedPatterns = [
/^localhost$/,
/^127\.\d+\.\d+\.\d+$/,
/^0\.0\.0\.0$/,
/^169\.254\.\d+\.\d+$/, // AWS/GCP/Azure metadata
/^\[::1\]$/,
/^\[?fe80:/i, // IPv6 link-local
/^\[::ffff:/i, // IPv4-mapped IPv6 (e.g. [::ffff:7f00:1])
/^\[::\]$/, // IPv6 all-zeros
]
Triggered through assertNotPrivateUrl(url) in every download path
(maps, ZIM, content-update). RFC1918 ranges (10.x, 172.16-31.x,
192.168.x) are intentionally allowed because LAN mirrors of content are
a supported use case.
7.3 Network exposure
| Port | Service | Reachable |
|---|---|---|
| 8080 | admin | LAN |
| 9999 | dozzle | LAN |
| 11434 | ollama (when installed) | container network only by default |
| 6333 | qdrant (when installed) | container network only by default |
| MySQL 3306 | nomad_mysql | container network only |
| Redis 6379 | nomad_redis | container network only |
Warning
Even with default install,
9999(Dozzle) is reachable on the LAN. Logs can leak credentials, internal paths, and PII. If your LAN isn't fully trusted, drop thedozzleservice fromcompose.yml.
7.4 Headers and middleware (admin/config/shield.ts)
| Header / control | State | Note |
|---|---|---|
X-Frame-Options: DENY |
Enabled | Set via shield |
| Content-Type sniffing | Disabled in browsers (X-Content-Type-Options: nosniff) |
Enabled |
| CSP | Disabled | Inertia + Vite asset reloading made strict CSP painful; tracked TODO |
| CSRF | Disabled | TODO comment in source — sessionless API; would need re-architecture |
| HSTS | Disabled | Marked TODO — enable when fronting with TLS |
Important
When you put N.O.M.A.D. behind a reverse proxy (Caddy, Traefik, nginx), the proxy is responsible for HSTS, TLS, and any auth gate. The app trusts
X-Forwarded-Protofor generating the mapsstyles.jsonURL.
7.5 Secrets handling
- Where:
compose.ymlcontainsAPP_KEY,DB_PASSWORD,MYSQL_ROOT_PASSWORD, andMYSQL_PASSWORDin plain text. - Generation: install script uses
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32for each. - Hashing:
admin/config/hash.tsuses scrypt with cost 16384, blockSize 8, parallelization 1, max memory 32 MiB. Currently unused (no end-user passwords) but available for future auth. - Benchmark HMAC:
BENCHMARK_HMAC_SECRETis a hardcoded shared secret inadmin/app/services/benchmark_service.ts. Acceptable — N.O.M.A.D. is open source; the secret only deters casual abuse of the public benchmark endpoint, and a determined attacker can extract it from source.
7.6 Container security posture
| Container | Privilege |
|---|---|
nomad_admin |
Standard, but bind-mounted Docker socket = root-equivalent on host |
nomad_mysql |
Standard |
nomad_redis |
Standard |
nomad_dozzle |
Bind-mounted Docker socket (read-only logs); DOZZLE_ENABLE_ACTIONS=false and DOZZLE_ENABLE_SHELL=false to suppress unauth privilege escalation |
nomad_disk_collector |
Bind-mount /:/host:ro,rslave (read-only host root) — same pattern as Prometheus node-exporter; no privileged, no SYS_ADMIN |
nomad_updater |
Bind-mount Docker socket + write access to /opt/project-nomad/compose.yml |
Caution
Anyone who can drop a malicious image inside
compose.ymland trigger the in-app update can run code as root on the host. Treat write access to/opt/project-nomad/as equivalent to root.
7.7 Input validation
Vine schemas guard every JSON-accepting endpoint. Patterns to be aware of:
- Filenames:
vine.string().trim().minLength(1).maxLength(4096)— the controllers also pass throughsanitizeFilenameinadmin/app/utils/fs.ts. - URLs:
vine.string().url({require_tld: false})to permit LAN hostnames, paired withassertNotPrivateUrlfor SSRF defense. - Builder-tag format:
^[A-Za-z]+-[A-Za-z]+-\d{4}$enforced before save. - Validator schemas for chat, settings, ollama models, ZIM lists, downloads, benchmarks, content updates, and Wikipedia selection. Required keys are documented in §10.
7.8 Dependency hygiene
npm auditshould be clean as of the latest tag — repo carriespackage-lock.jsonfor reproducible installs.- Heavy parsers (
pdf-parse,tesseract.js,cheerio,fast-xml-parser,jszip,tar) are sandboxed only by Node's process isolation. The admin process is the only thing parsing user-supplied uploads. Untrusted file content is a real attack surface — keep the host updated.
8. Developer Internals
8.1 Repository layout
project-nomad/
├── package.json (metadata only)
├── package-lock.json (root lock — corresponds to bare metadata)
├── Dockerfile (admin image, multi-stage, copies into /app)
├── README.md / FAQ.md / CODE_OF_CONDUCT.md / CONTRIBUTING.md
├── .releaserc.json (semantic-release config)
├── admin/ AdonisJS application
│ ├── ace.js (TypeScript-aware ace launcher)
│ ├── adonisrc.ts (providers, preloads, command list)
│ ├── package.json (full dependency set)
│ ├── package-lock.json
│ ├── tsconfig.json / tailwind.config.ts / vite.config.ts / eslint.config.js
│ ├── start/ (env.ts, kernel.ts, routes.ts)
│ ├── config/ (13 config files — see §4.4)
│ ├── constants/ (broadcast, kiwix, kv_store, misc, ollama, service_names, zim_extraction)
│ ├── app/
│ │ ├── controllers/ (13)
│ │ ├── exceptions/ (handler.ts + InternalServerErrorException)
│ │ ├── jobs/ (BullMQ workers)
│ │ ├── middleware/ (compression, container_bindings, force_json_response, maps_static)
│ │ ├── models/ (10 Lucid models)
│ │ ├── services/ (17 services — see §8.2)
│ │ ├── utils/ (downloads, fs, misc, version)
│ │ └── validators/ (10 Vine schemas)
│ ├── database/migrations/ (23 migrations + service_seeder.ts)
│ ├── docs/ (Markdoc files for the in-app docs page)
│ ├── inertia/
│ │ ├── app/app.tsx (React + Inertia bootstrap)
│ │ ├── pages/ (page-level components)
│ │ ├── components/ (shared UI: chat, maps, file-uploader, markdoc renderers, system info, etc.)
│ │ ├── hooks/ (useDebounce, useSystemInfo, useTheme, …)
│ │ ├── layouts/ (AppLayout, DocsLayout, MapsLayout, SettingsLayout)
│ │ ├── lib/ (api.ts, classNames, navigation, util, icons, builderTagWords)
│ │ ├── providers/ (ModalProvider, NotificationProvider, ThemeProvider)
│ │ ├── context/ (ModalContext, NotificationContext)
│ │ └── css/app.css
│ ├── public/ (favicons, branding webp/svg, in-app docs images)
│ ├── resources/views/ (Edge inertia_layout.edge)
│ ├── tests/ (Japa bootstrap; suites: unit, functional)
│ ├── types/ (benchmark, chat, collections, docker, downloads, files, kv_store, maps, ollama, rag, services, system, util, zim)
│ └── views/ (deprecated location of inertia_layout.edge)
├── collections/ (Markdown todo list + JSON specs for kiwix-categories, maps, wikipedia)
└── install/
├── *.sh (install/start/stop/uninstall/update + migrate-disk-collector + run_updater_fixes)
├── management_compose.yaml (canonical compose definition)
├── sidecar-disk-collector/ (Dockerfile + collect-disk-info.sh)
├── sidecar-updater/ (Dockerfile + update-watcher.sh)
└── wikipedia_en_100_mini_*.zim (small test ZIMs shipped for offline first-run)
8.2 Service catalog (admin/app/services/)
| Service | Responsibility |
|---|---|
BenchmarkService |
Hardware detection (systeminformation), sysbench orchestration, Ollama timing, NOMAD-score calculation, repository submission with HMAC |
ChatService |
Sessions/messages CRUD, default-suggestion list, title generation via the active model |
CollectionManifestService |
Fetch + cache zim_categories, maps, wikipedia JSON specs from https://api.projectnomad.us (or NOMAD_API_URL); spec_version-aware |
CollectionUpdateService |
Per-resource update detection (compare installed_version to latest_version) and apply via dispatched download jobs |
ContainerRegistryService |
Token-based GHCR/Docker Hub auth, tag listing, retry-with-backoff |
DockerService |
The Docker socket abstraction: createContainer, inspect, pull, start/stop/restart, image cleanup, network attach; also handles the Kiwix command migration |
DocsService |
Reads admin/docs/*.md, validates with Markdoc, returns parsed AST for the in-app docs viewer |
DownloadService |
Generic resumable downloader (HEAD probe, range requests, stall timer, atomic move via tmp suffix) |
KiwixLibraryService |
Builds and refreshes /data/kiwix-library.xml from installed ZIMs |
MapService |
Protomaps base-asset download, region listing, custom PMTiles ingest, marker CRUD passthrough, generates dynamic styles.json |
OllamaService |
Wraps the official ollama SDK; model catalog, pull progress, stream chat, "thinking" detection, remote-mode probing |
QueueService |
Thin wrapper around BullMQ Queue/Worker constructors |
RagService |
File chunking via @chonkiejs/core, embedding via Ollama nomic-embed-text, Qdrant CRUD, similarity search, query rewriting |
SystemService |
Service catalog operations (install/affect/force-reinstall/check-updates), debug info, NVIDIA SMI passthrough, internet-status check |
SystemUpdateService |
Writes update-request JSON, reads update-status, retrieves admin app version |
ZIMExtractionService |
Streams ZIM articles via @openzim/libzim, applies HTML_SELECTORS_TO_REMOVE and NON_CONTENT_HEADING_PATTERNS filters |
ZimService |
ZIM list/list-remote/curated-categories, Wikipedia selection state, deletion with cleanup |
8.3 Inertia frontend stack
- Bootstrap in
admin/inertia/app/app.tsx:- Patches
crypto.randomUUIDfor non-HTTPS contexts (LAN deploys often don't terminate TLS at the container). - Wraps the app in QueryClientProvider, ThemeProvider, TransmitProvider (real-time SSE), NotificationsProvider, ModalsProvider.
- Shows React Query Devtools only when
environment in ['development', 'staging'].
- Patches
- Page resolution:
resolvePageComponentuses a glob overadmin/inertia/pages/**/*.tsxso adding a new page is just dropping a file. - Shared props:
inertia.tsconfig injectsappVersion,environment, andaiAssistantName(cached one minute on the server, invalidated whenai.assistantCustomNameis updated). - Routing: server-side via Adonis routes; the React side is a fully Inertia-style SPA — no React Router, no client-side routing.
- Real-time channels:
admin/constants/broadcast.tsdefines:
BENCHMARK_PROGRESS: 'benchmark-progress'
OLLAMA_MODEL_DOWNLOAD: 'ollama-model-download'
SERVICE_INSTALLATION: 'service-installation'
SERVICE_UPDATES: 'service-updates'
The frontend subscribes via react-adonis-transmit hooks; the backend
publishes via transmit.broadcast(channel, payload).
8.4 Lucid models (admin/app/models/)
| Model | Table | Notes |
|---|---|---|
BenchmarkResult |
benchmark_results |
All score columns + submitted_to_repository |
BenchmarkSetting |
benchmark_settings |
getValue / setValue / getAllSettings typed accessors |
ChatSession |
chat_sessions |
hasMany messages |
ChatMessage |
chat_messages |
belongsTo session (custom FK mapping session_id) |
CollectionManifest |
collection_manifests |
Stores cached spec JSON; consume/prepare JSON.parse round-tripping |
InstalledResource |
installed_resources |
Type union `'zim' |
KVStore |
kv_store |
Generic KV with type-aware getters via KV_STORE_SCHEMA |
MapMarker |
map_markers |
name, longitude, latitude, color, route_id (future) |
Service |
services |
The service catalog row; dependency and dependencies self-referential relations |
WikipediaSelection |
wikipedia_selection |
Single user choice; status enum |
All use SnakeCaseNamingStrategy and @column.dateTime({ autoCreate: true, autoUpdate: true }) for timestamps.
8.5 Code patterns and conventions
- Path aliases:
admin/package.jsonimportsfield maps#controllers/*→./app/controllers/*.js,#services/*,#middleware/*,#models/*,#validators/*,#jobs/*,#config/*,#start/*,#exceptions/*,#database/*,#tests/*,#providers/*,#policies/*,#abilities/*,#mails/*,#listeners/*,#events/*. Always import via these aliases. - Hot-reload boundaries (development only):
hotHook.boundariesinadmin/package.jsonlists./app/controllers/**/*.tsand./app/middleware/*.ts— these get HMR'd viahot-hook. - DI: services are decorated
@inject()and constructor-injected. Controllers take services in the constructor. - Streaming: never use Adonis's
response.send()for SSE; flush headers manually and write toresponse.response(the underlying Node res). - Validation first: every controller method that takes JSON should call
request.validateUsing(schema)before touching services. - Errors: throw
InternalServerErrorException(admin/app/exceptions/) to surface a 500 with a structured payload; everything else is a plain Error. The custom handler inadmin/app/exceptions/handler.tsformats output as JSON whenAcceptincludesapplication/json. - Transactions: use
Database.transaction()in services; controllers shouldn't open transactions directly. - Logging: use the injected
logger(orimport logger from '@adonisjs/core/services/logger') and tag with the class name in square brackets (logger.info('[BenchmarkService] ...')). Pino formatting follows this convention everywhere.
8.6 Testing strategy
- Test runner: Japa 4 with
@japa/assertand@japa/plugin-adonisjs. - Suites in
admin/adonisrc.ts:unit—tests/unit/**/*.spec(.ts|.js), 2s timeoutfunctional—tests/functional/**/*.spec(.ts|.js), 30s timeout
- Bootstrap (
admin/tests/bootstrap.ts) starts the HTTP server for any suite withname in ['browser', 'functional', 'e2e']. - Run tests:
cd admin
node ace test # all suites
node ace test --suites=unit # single suite
Note
The test suite is sparse at present — community contributions to expand coverage on services and validators are welcome. CI runs whatever exists on every PR.
8.7 Build pipeline
- Image:
Dockerfilein repo root produces the admin GHCR image. Multi- stage: install deps → runnode ace build(compiles TypeScript + Vite bundles the React app) → copy minimal runtime layer. - Local dev:
cd admin && npm run devrunsnode ace serve --hmr. Vite serves the SPA in middleware mode through@adonisjs/vite/vite_middleware. - Type-check:
npm run typecheck(tsc --noEmit). - Lint:
npm run lint(ESLint flat config plus@tanstack/eslint-plugin-queryrecommended). - Format:
npm run format(Prettier with@adonisjs/prettier-config). - Release: semantic-release on
main(stable) andrc(prerelease). Each merged commit's conventional-commit type drives version bumps; CI publishes the GHCR image plus a GitHub Release.
8.8 Adding a new managed service
- Add the entry to
admin/database/seeders/service_seeder.ts(or a new migration if you want it added without a re-seed). - Add the constant to
admin/constants/service_names.ts. - If it needs a special install action (volumes, pre-pull steps), wire it
in
DockerService.createContainerPreflight. - Add a Tabler icon name to the
iconcolumn so the UI renders it. - If it's a UI-driven service (not a dependency), set
is_dependency_service = falseand provideui_location. - Restart the admin container and verify it appears under Settings → Apps.
8.9 Potential improvements (open opportunities)
- Re-enable CSP (currently
csp.enabled: false) once the Vite asset URL pattern is settled. - Re-enable CSRF protection — would need a token-vending route for the SPA.
- Add structured tracing (OpenTelemetry) — pino is great for log correlation but tracing across BullMQ workers is currently log-only.
- Pluggable embedding model (today the
nomic-embed-textchoice is hardcoded in RAG service). - ROCm GPU support (NVIDIA-only today, see
setup_nvidia_container_toolkitininstall_nomad.sh). - True multi-tenant mode with auth — would touch routes, controllers, and the (currently disabled) session/auth providers.
- Service install rollback semantics (currently best-effort; failed installs leave a partial container behind).
8.10 Contributing
- File issues at the GitHub tracker; PRs against
mainfor stable,rcfor upcoming features. - Keep PRs scoped — one logical change per PR.
- Run
npm run lint && npm run typecheck && node ace testbefore opening. - Commit messages follow Conventional Commits (
feat:,fix:,chore:,docs:,refactor:,test:) — semantic-release reads them.
9. Troubleshooting
Symptom: Admin container exits at boot with Cannot validate environment variable APP_KEY
Cause: APP_KEY is unset, empty, or shorter than 16 characters.
Solution: Edit compose.yml and ensure the APP_KEY= line under the
admin service has a 16+ character value. Restart the stack.
Prevention: Don't hand-edit compose.yml without running through
Env.create validation — start the admin and watch logs.
Symptom: Failed to detect hardware: command not found
Cause: systeminformation falls back to OS commands; on a slim base
image something like lspci may be missing.
Solution: The Crosstalk-published image already bundles the deps — but
if you've built your own image, add apt-get install -y pciutils to the
Dockerfile.
Symptom: AI benchmark returns Ollama is not running or not accessible
Cause: Reaching the nomad_ollama service is blocked, the container is
down, or the model isn't pulled.
Solution:
docker logs nomad_ollama --tail 50
docker exec nomad_admin curl -s http://nomad_ollama:11434/api/tags
If the second command 404s on /api/tags, Ollama itself is unhealthy.
Restart the container; a fresh nomad_ollama will replay the pulled-models
volume.
Symptom: Markdoc validation failed
Cause: A .md doc in admin/docs/ uses Markdoc tag syntax that doesn't
parse, or the user uploaded a Markdown file with non-Markdoc tags.
Solution: Run node ace doc:validate if you're shipping new in-app
docs. For user uploads, the system catches the error and surfaces it through
InternalServerErrorException — the Knowledge Base UI shows the failure on
the Files tab.
Symptom: Container is unhealthy after pull
Cause: Healthcheck failed within the configured interval × retries
window. For nomad_admin that's 30s × 3 = 90s for /api/health to come
back 200.
Solution: Tail logs (docker logs --tail 200 nomad_admin); look for env
validation errors, MySQL connection refusals, or migration failures. The
admin's entrypoint runs migration:run --force then db:seed then
queue:work --all & then bin/server.js; failure in any step will show in
the logs.
Symptom: Update sidecar reports Failed to update compose.yml image tag
Cause: compose.yml is structurally different from what the
sed -i 's|\(image: ghcr\.io/crosstalk-solutions/project-nomad\):.*|\1:${target_tag}|'
pattern expects (e.g., you reformatted YAML or pinned a private registry).
Solution: Run update_nomad.sh from the host instead, which uses
docker compose pull + up -d --force-recreate and doesn't edit YAML.
Restore from a compose.yml.bak.<timestamp> if the sidecar partially edited
the file.
Symptom: extractStructuredContent parses only direct body children
Cause: Modern HTML ZIMs nest article content inside multiple wrapper divs; the v1.x extractor only walked direct children. Issue #720.
Solution: Update to a release where this is fixed (track the issue); otherwise use older-format ZIMs (Wikipedia mwoffliner pre-2024).
Symptom: ZIM batch processing stops at 50 articles
Cause: ZIM_BATCH_SIZE = 50 is the per-batch ceiling and hasMoreBatches
used the wrong counter, ending iteration early. Issue
#719.
Solution: Update; this is fixed in current main. Check
admin/constants/zim_extraction.ts for the constant.
Symptom: scanAndSyncStorage queues non-embeddable files
Cause: The sync routine queued every file in kb_uploads regardless of
extension, including kiwix-library.xml. Issue
#718.
Solution: Update; the embedder now filters by extension list in
RagService. Quick fix: move the offending file out of kb_uploads.
Symptom: SSE stream hangs / chat endpoint never sends data
Cause: A reverse proxy is buffering. nginx without proxy_buffering off
will hold SSE chunks until they flush.
Solution: Configure your reverse proxy:
location /api/ollama/chat {
proxy_pass http://nomad_admin:8080;
proxy_buffering off;
proxy_http_version 1.1;
chunked_transfer_encoding off;
}
Caddy and Traefik handle this correctly out of the box.
Warning
Don't put a CDN in front of N.O.M.A.D. — most CDNs buffer SSE and the chat experience will look broken.
10. Quick Reference
10.1 Important file paths
HOST CONTAINER
/opt/project-nomad/compose.yml ─► (n/a — host file)
/opt/project-nomad/storage/ ─► /app/storage
/opt/project-nomad/storage/logs/admin.log /app/storage/logs/admin.log
/opt/project-nomad/storage/maps/ /app/storage/maps
/opt/project-nomad/storage/zim/ /app/storage/zim
/opt/project-nomad/storage/kb_uploads/ /app/storage/kb_uploads
/opt/project-nomad/storage/nomad-disk-info.json /app/storage/nomad-disk-info.json (read by admin)
/opt/project-nomad/mysql/ /var/lib/mysql
/opt/project-nomad/redis/ /data
named volume: nomad-update-shared /app/update-shared (admin) + /shared (updater)
10.2 Service names (admin/constants/service_names.ts)
KIWIX: nomad_kiwix_server
OLLAMA: nomad_ollama
QDRANT: nomad_qdrant
CYBERCHEF: nomad_cyberchef
FLATNOTES: nomad_flatnotes
KOLIBRI: nomad_kolibri
10.3 Key constants (admin/constants/)
| Constant | Value | File |
|---|---|---|
KIWIX_LIBRARY_CMD |
--library /data/kiwix-library.xml --monitorLibrary --address=all |
kiwix.ts |
NOMAD_API_DEFAULT_BASE_URL |
https://api.projectnomad.us |
misc.ts |
DEFAULT_QUERY_REWRITE_MODEL |
qwen2.5:3b |
ollama.ts |
RAG_CONTEXT_LIMITS |
[{≤3B: 2/1k}, {≤8B: 4/2.5k}, {>8B: 5/no-cap}] |
ollama.ts |
ZIM_BATCH_SIZE |
50 |
zim_extraction.ts |
BROADCAST_CHANNELS.BENCHMARK_PROGRESS |
benchmark-progress |
broadcast.ts |
BROADCAST_CHANNELS.OLLAMA_MODEL_DOWNLOAD |
ollama-model-download |
broadcast.ts |
BROADCAST_CHANNELS.SERVICE_INSTALLATION |
service-installation |
broadcast.ts |
BROADCAST_CHANNELS.SERVICE_UPDATES |
service-updates |
broadcast.ts |
SETTINGS_KEYS |
8 valid kv-store keys | kv_store.ts |
10.4 Useful one-liners
| Task | Command |
|---|---|
| Tail admin logs | docker logs -f --tail 200 nomad_admin |
| Open MySQL CLI | docker exec -it nomad_mysql mysql -u nomad_user -p nomad |
| Open Redis CLI | docker exec -it nomad_redis redis-cli |
| List installed Ollama models | docker exec nomad_ollama ollama list |
| Pull model manually | docker exec nomad_ollama ollama pull <model> |
| Force re-pull all images | docker compose -p project-nomad -f /opt/project-nomad/compose.yml pull --no-cache |
| Force-recreate one service | docker compose -p project-nomad -f /opt/project-nomad/compose.yml up -d --force-recreate <service> |
| Inspect Docker network | docker network inspect project-nomad_default |
| Watch update progress | cat /var/lib/docker/volumes/project-nomad_nomad-update-shared/_data/update-status |
| Run all queue workers | docker exec -d nomad_admin node ace queue:work --all |
| Generate Adonis route table | docker exec nomad_admin node ace list:routes |
| Generate debug bundle | curl -s http://localhost:8080/api/system/debug-info > debug.json |
10.5 Versioning
- App version:
package.json(version) — bumped by semantic-release. - Image tags on GHCR:
latest(stable),rc(release candidate), exactvX.Y.Zfrom each tagged release. - Migrations: monotonically-numbered; never edit a migration after it's shipped — write a new one.
- DB schema version: implicit (highest applied migration). No
schema_versiontable; AdonisJS Lucid manages this internally.
10.6 Default ports
| Port | Service | Default-installed? |
|---|---|---|
| 8080 | admin | yes |
| 9999 | dozzle | yes |
| 11434 | nomad_ollama | when AI Assistant installed |
| 6333 | nomad_qdrant | when Knowledge Base used |
| 8086 | nomad_kiwix_server | when any ZIM installed |
| 8000 | nomad_cyberchef | optional |
| 8200 | nomad_flatnotes | optional |
| 8080 (mapped) | nomad_kolibri | optional |
| 3306 | nomad_mysql | yes (internal only) |
| 6379 | nomad_redis | yes (internal only) |