Jesse A. Green / notebook

Project N.O.M.A.D. — Administrator & Developer Guide

Generated April 2026 from Crosstalk-Solutions/project-nomad at 9cbf8c2

AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it.

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, edits compose.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 to compose.yml for updater). 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.json in the repo root carries only metadata (name, version, author). All runtime deps live in admin/package.json. The root version field is what semantic-release bumps; the admin one is pinned at 0.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 (not sh) and requires sudo — check_is_bash and check_has_sudo are mandatory pre-flight gates. The Debian-family check is also enforced (/etc/debian_version must 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_KEY must be ≥ 16 characters or AdonisJS refuses to boot — validation happens during Env.create at startup. The installer generates 32 random alphanumeric chars via tr -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 --all worker; bring up additional workers as separate compose services pointing at the same compose.yml.

5.3 Updates

Production updates flow through the sidecar pattern:

  1. UI button (POST /api/system/update) → SystemUpdateService.requestSystemUpdate
  2. Writes {target_tag, ...} JSON to /app/update-shared/update-request
  3. nomad_updater polls /shared/update-request once per second
  4. On request: sed -i rewrites the admin image tag in compose.yml, docker compose pull, then sequential stop/rm/up -d --no-deps for admin, mysql, redis, dozzle
  5. 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 to compose.yml.bak.<timestamp> before editing — keep /opt/project-nomad backed 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:

  1. Stop the stack.
  2. Edit /opt/project-nomad/compose.yml → set new APP_KEY=....
  3. Start the stack. Env.create will 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/health returns {"status":"ok"} plain — wire to Uptime Kuma / Healthchecks.io.
  • System info: GET /api/system/info returns 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-status is the lightweight endpoint the UI polls. Override the test URL via INTERNET_STATUS_TEST_URL.
  • Container state: read directly from Dozzle (:9999) or docker ps.
  • Update status: GET /api/system/update/status returns 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-cli or any BullMQ dashboard pointed at redis://nomad_redis:6379.

Tip

The admin's pino logger writes to /app/storage/logs/admin.log in 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 least mysql, redis, compose.yml)
  • [ ] Confirm docker compose version reports v2
  • [ ] Verify nvidia-smi works on the host (if GPU-using)
  • [ ] Confirm curl http://localhost:8080/api/health returns {"status":"ok"}
  • [ ] Set LOG_LEVEL=info (not debug) for production
  • [ ] Plan firewall rules for 8080 (admin) and 9999 (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-collector reads the host root filesystem; updater writes compose.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 the dozzle service from compose.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-Proto for generating the maps styles.json URL.

7.5 Secrets handling

  • Where: compose.yml contains APP_KEY, DB_PASSWORD, MYSQL_ROOT_PASSWORD, and MYSQL_PASSWORD in plain text.
  • Generation: install script uses tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32 for each.
  • Hashing: admin/config/hash.ts uses 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_SECRET is a hardcoded shared secret in admin/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.yml and 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 through sanitizeFilename in admin/app/utils/fs.ts.
  • URLs: vine.string().url({require_tld: false}) to permit LAN hostnames, paired with assertNotPrivateUrl for 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 audit should be clean as of the latest tag — repo carries package-lock.json for 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.randomUUID for 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'].
  • Page resolution: resolvePageComponent uses a glob over admin/inertia/pages/**/*.tsx so adding a new page is just dropping a file.
  • Shared props: inertia.ts config injects appVersion, environment, and aiAssistantName (cached one minute on the server, invalidated when ai.assistantCustomName is 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.ts defines:
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.json imports field 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.boundaries in admin/package.json lists ./app/controllers/**/*.ts and ./app/middleware/*.ts — these get HMR'd via hot-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 to response.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 in admin/app/exceptions/handler.ts formats output as JSON when Accept includes application/json.
  • Transactions: use Database.transaction() in services; controllers shouldn't open transactions directly.
  • Logging: use the injected logger (or import 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/assert and @japa/plugin-adonisjs.
  • Suites in admin/adonisrc.ts:
    • unit — tests/unit/**/*.spec(.ts|.js), 2s timeout
    • functional — tests/functional/**/*.spec(.ts|.js), 30s timeout
  • Bootstrap (admin/tests/bootstrap.ts) starts the HTTP server for any suite with name 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: Dockerfile in repo root produces the admin GHCR image. Multi- stage: install deps → run node ace build (compiles TypeScript + Vite bundles the React app) → copy minimal runtime layer.
  • Local dev: cd admin && npm run dev runs node 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-query recommended).
  • Format: npm run format (Prettier with @adonisjs/prettier-config).
  • Release: semantic-release on main (stable) and rc (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

  1. Add the entry to admin/database/seeders/service_seeder.ts (or a new migration if you want it added without a re-seed).
  2. Add the constant to admin/constants/service_names.ts.
  3. If it needs a special install action (volumes, pre-pull steps), wire it in DockerService.createContainerPreflight.
  4. Add a Tabler icon name to the icon column so the UI renders it.
  5. If it's a UI-driven service (not a dependency), set is_dependency_service = false and provide ui_location.
  6. 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-text choice is hardcoded in RAG service).
  • ROCm GPU support (NVIDIA-only today, see setup_nvidia_container_toolkit in install_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 main for stable, rc for upcoming features.
  • Keep PRs scoped — one logical change per PR.
  • Run npm run lint && npm run typecheck && node ace test before 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), exact vX.Y.Z from 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_version table; 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)