LM-Kit OneDocs2026.9.7lm-kit.comEULA
Deployment

Scaling Out

How one server becomes a farm: identical nodes behind an ordinary load balancer, sharing an admin domain, an upload volume, and a model volume. Any node serves the stateless surfaces; a short list of surfaces is bound to the node that created their state, and every response names its node so a request that reaches the wrong one is told so instead of failing quietly; nodes join warm, leave draining, and the fleet grows or shrinks on a first-party signal. For the architect sizing past one box and the operator running the result, on Kubernetes or on plain VMs. This page owns the farm story; the container manifests are Running in Containers and the balancer directives are Behind a Reverse Proxy.


1Single node or farm: the decision#

One box keeps every convenience, and a farm trades three of them for capacity; this section is the price list, Reference Architectures places the fleet on the ladder that decides whether you need it, and Fleet Recipes writes out what each platform (Azure App Service, Azure Container Apps, Kubernetes, VM and Windows service farms, Compose and Swarm, Amazon ECS) needs from the contract below.

A single node keeps the embedded SQLite identity store under its state directory, writes every panel and wizard edit to its own appsettings.json, and provisions the embedded search cluster on first use. Nothing else is needed, and nothing below applies.

A farm is declared with one switch and needs three things decided before the second node starts:

Decision What a farm needs Why
The mode Deployment:Mode=Farm (or LMKIT_DEPLOYMENT_MODE=Farm) on every node, with Deployment:DomainKey (LMKIT_DOMAIN_KEY, at least 32 characters, the same value everywhere); the default is SingleNode, and Deployment:SyncSeconds (5 on a farm) is how often a node reads the shared domain and writes its heartbeat The declared switch. A farm node starts only with the shape below in place and refuses otherwise, naming the setting to fix: a shared identity store, uploads and models on volumes every node mounts, no embedded search cluster, TLS at the balancer (no ACME), agent memory off, a network posture the balancer can reach, a domain key, and a mode agreeing with the live nodes of its store. Node settings are read-only on a farm node whatever LMKIT_MANAGED_CONFIG says. A fleet running on LMKIT_MANAGED_CONFIG=1 alone keeps running as a single node with managed configuration; nothing promotes it, and the startup log names the key. A single node joins a farm from the identity domain card of the Access section or with lmkit farm join; the procedures are Farm Runbooks
One identity database Exactly one dedicated admin identity database on PostgreSQL, MySQL or SQL Server: Admin:Identity:Store accepts postgres, mysql or sqlserver (the default embedded is the single-node file), with Admin:Identity:ConnectionString pointing at it It is what makes operators, sessions, API keys and stored responses one domain instead of one per node (section 3)
External search engines One external engine per search cluster, declared in configuration, or Search switched off explicitly A node carrying a fleet signal (managed configuration, or a shared identity store) never provisions the embedded engine: it stays disabled and names the two remedies in its log and in the Search panel (Storage Engines and Deployment)
Who owns configuration LMKIT_MANAGED_CONFIG=1 on every node, or deliberately not With the flag the manifest owns every setting and every configuration edit refuses (section 4). Without it the farm is a supported but drift-prone shape: every panel edit lands in one node's appsettings.json only, nothing propagates it, and nothing compares nodes, so two nodes can run different agents, tool policies or default models with no surface saying so

Everything the identity database does not hold and no shared volume carries stays on the node that wrote it; section 5 is the complete list, and section 6 says which of those surfaces the balancer must pin.

2The model: scale by replication#

Every node is a complete engine with its own resident models, inference pools and device memory, and the fleet scales by adding nodes behind a load balancer, so size ONE node honestly with Inference Capacity and multiply.

Two things are deliberately NOT in the design, so nobody waits for them: cross-node tensor parallelism (one request never spans machines; a model too large for one node needs a bigger node, not more of them) and scale-to-zero (model loads are measured in minutes, so a zero-node fleet answers its first request unacceptably late; the floor is one warm node).

One thing IS in the design and has to be named: several surfaces depend on reaching the node that holds their state (a chat-with-document session, an MCP event stream, a running exchange, a job result too large for its record). Section 6 lists them with the contract the server emits for them and the pinning that keeps them on one node; nothing else on the data plane cares which node answers.

3What the fleet shares#

Three shared things turn independent servers into one domain, each explicit configuration a reviewer can read off the manifest, and the identity database among them is the availability dependency this section spells out (Operator Accounts covers the accounts inside it).

Shared How What it buys
The admin domain A dedicated identity database (Admin:Identity: PostgreSQL, MySQL, or SQL Server) Operators, sessions, second factors, per-account lockout, SSO sign-ins, the security trail, API keys with their usage counters, and stored responses are domain-wide: a key minted on any node authorizes on every node, a revocation lands everywhere within seconds, and a Responses-API conversation continues on whichever node the next request reaches. The same database carries the node roster (one heartbeat row per node: build, readiness, resident models, load, written every sync period), the shared domain documents with their per-kind revisions, and the leases that give single-runner work exactly one runner
The upload volume FileManagement:UploadDirectory on a shared read-write mount at the same path on every node A file uploaded through one node serves requests on every node: storage is filesystem-authoritative (GUID-named files plus manifests, no in-memory index), so a file id resolves anywhere
The model volume LMKIT_MODELS_DIR on a shared mount, at the same path on every node by preference (paths in the shared records and journals travel relative to it, so a different mount point still resolves); ModelWorkDirectory, when set, is a shared mount too, since it holds the training journals and the imported-model blobs Models download once for the whole fleet: the first node to need a file pulls it under a cross-process lock while the others wait, then load what it fetched; each machine downloads into a sidecar of its own before moving a whole file into place, so a mount that does not honour the lock costs a duplicate transfer, never a torn file. Custom-model blobs share the volume too, each node marking what it references under blobs/refs so a removal on one node never reclaims a blob another node's model is built from

Search content is shared through the cluster's own external engine, never through the identity database, and every node runs the search maintenance worker against it: on PostgreSQL the passes coordinate through advisory locks, so one node runs a given pass while the others skip it, and a node's start reclaims only the locks its own earlier incarnation left behind (its backends are named after it) and never a sibling's live pass (Storage Engines and Deployment section 7).

Setting up the identity domain

Configure the store in the settings file or the environment, deliberately not in the admin form: the surface that edits configuration authenticates through this store, so its selection is bootstrap-level.

{
  "Admin": {
    "Identity": {
      "Store": "postgres",
      "ConnectionString": "Host=db;Database=lmkit_identity;Username=lmkit;Password=..."
    }
  }
}
  • Identity gets a strictly dedicated database; the database server can be shared. Knowledge-base clusters store document content; operator credentials, sessions and lockout state never share a DATABASE with them, while one PostgreSQL, MySQL or SQL Server instance may host the identity database beside the cluster databases. Both directions are enforced: an identity store pointed at a content database refuses to start, and a knowledge-base cluster pointed at the identity database refuses to initialize, each naming the remedy. Create a small dedicated database (for example lmkit_identity) and grant the server's login rights on it alone.
  • Seed the first operator before exposing the nodes. Admin__InitialPassword becomes the password of the admin account and is consumed only while no account exists: whichever node runs first creates the account, every other node finds it and ignores its copy of the seed, two nodes seeding at the same moment tolerate the duplicate, and a restart never resets the password. A shared identity store never opens the admin surface, with or without accounts, so the first operator arrives through this seed or through lmkit admin create-owner run on a node with that node's environment (The Command Line explains why the shell's environment matters). Admin__Password is not a third way in: it is the emergency reset, re-applied to the admin account on EVERY start it is present (the password overwritten, a disabled account re-enabled), and a managed node cannot remove it from the manifest, so it is set for one restart and removed afterwards; the log names the source still supplying it (Operator Accounts section 3).
  • Caches are 30 seconds wide. Session validations and the API-key snapshot are cached per node for 30 seconds, so a revocation issued on one node is effective everywhere within that window, and immediately for a token another node has never seen: an unknown key is looked up in the store before it is refused, which is what makes a key minted seconds ago valid on every node at once. Remembered devices, web-area tickets and in-flight SSO sign-ins are domain-wide as well, because the signing secret and the PKCE challenges live in the store, so a sign-in that starts on one node completes on another.
  • A node's pre-existing apikeys.json is adopted into the store on its first start and removed, so a single node promoted into a farm keeps the keys it minted alone.
  • Name the balancer in Security:TrustedProxies (IP addresses or CIDR networks) so sign-in throttling, session records and the audit trail see the real client address from X-Forwarded-For instead of the balancer's. With the list empty, forwarded headers are ignored, which is the safe stance when nothing sits in front.
  • Security events (sign-ins and failures, account lifecycle, credential and second-factor changes) are written to the store's central trail beside each node's own journal; the audit history endpoint serves that domain-wide view for the security source.

While the identity database is unreachable

Inference continues. API keys already in a node's 30-second snapshot keep authorizing for the whole outage, which includes a key revoked on another node after the snapshot was taken; a key not in the snapshot is refused with 401 until the database answers. The admin surface never opens: a node keeps the last answer it had about existing accounts, and on a shared store the surface is closed unconditionally, so an outage cannot turn a farm into an unprovisioned install. Admin sign-in, operator management and key management refuse until the store returns. The outage and its recovery are each recorded once in the security trail and the node log, not once per request. Readiness does not depend on the database, so the balancer keeps routing to a node whose store is down; that is deliberate, because the data plane still works.

Work done once per fleet

A shared subject is maintained by one node at a time. Each such job takes a lease in the identity store (its own name, three of its periods long) and runs only while it holds it; a node that stops renewing loses the lease and any sibling takes over on its next pass. The Farm panel lists every lease with its holder, and the Jobs page names the holder beside each of these jobs, whichever node you are pinned to; a manual run from another node names the holder instead of doubling its pass.

Lease What runs once per fleet
security-trail-retention Removes security trail entries older than the retention the Access section declares
job-records Removes the records of async jobs an hour past their end
stale-file-remover Sweeps the shared upload volume of files past MaxFileRetention
stale-node-sweep Removes roster rows a day stale, taken by whichever heartbeat sweeps first
skills-sources Reconciles Skills:Sources and installs what they declare into the shared skills
search-migration:<cluster> A cluster's schema migrations at start; the nodes of one boot wave take turns
search-database-advisor:<cluster> The raise of a PostgreSQL cluster's reload-scope settings; every node records the findings
search-bloat-reconcile:<cluster> The sweep for dead vector-partition bloat of a PostgreSQL cluster, every six hours

The database health poll of the Search telemetry needs no lease: each node polls every three seconds multiplied by the live node count, so the database sees one poll per three seconds however many nodes watch it, and every node's panel keeps a live headline.

The Search control plane's caches keep coherent the same way the shared objects do: a write on one node (a tenant setting, a collection created, configured or deleted) advances the cluster's generation in the identity database, every other node drops its cached entries for that cluster when its sync brings the new generation, and every entry expires within thirty seconds on its own (Storage Engines and Deployment section 7).

Every shared object the domain carries, by kind (generated from the domain's kind registry, so a kind added without its row fails the build): a configuration object is farm-wide through the manifest or the panel, a record kind is applied as it changes, a registry kind is replaced as a whole on every node.

Kind Nature What it holds
agents Configuration object: farm-wide through the manifest or the panel The agents: one document per agent, keyed by name
connectors Configuration object: farm-wide through the manifest or the panel The MCP connectors: one document per connector, keyed by name
memory-stores Configuration object: farm-wide through the manifest or the panel The memory store policies: name, retention, recall shape, extraction
tools-policy Configuration object: farm-wide through the manifest or the panel The tools policy: whether tools run, which are allowed and their options, the web search key sealed
skills-policy Configuration object: farm-wide through the manifest or the panel The skills policy: which skills are allowed and which sources install them
default-models Configuration object: farm-wide through the manifest or the panel The default model picks: what a request that names no model gets
model-overrides Configuration object: farm-wide through the manifest or the panel The per-model slot pool overrides
mcp-ingest-roots Configuration object: farm-wide through the manifest or the panel The MCP file ingest roots: the directories file_ingest may read, the same list on every node
claude-slots Configuration object: farm-wide through the manifest or the panel The Claude Desktop lineup pins: each identity and the model that serves it
claude-shapes Configuration object: farm-wide through the manifest or the panel The Claude Desktop serving shapes per identity
search-clusters Configuration object: farm-wide through the manifest or the panel The Search clusters: one document per cluster, connection strings and Qdrant key sealed, and the Search on/off flag
custom-models Shared registry: replaced as a whole The custom models: one document per imported model; the blobs live on the shared model volume
memory-facts Shared registry: replaced as a whole The agent memories: one document per store and memory id
skills-content Shared registry: replaced as a whole The skills: one document per skill holding every file of its folder
jobs Records: applied as they change The async jobs: one record per job, written by the node that runs it
mcp-sessions Records: applied as they change The MCP sessions: one record per session, written by the node that completed the handshake
search-chat-sessions Records: applied as they change The grounded chat sessions: one record per session with its scope, defaults and conversation history
search-control-plane Records: applied as they change The search control plane's generation: one document per cluster, advanced by every control-plane write
search-deletions Records: applied as they change The deletions a search cluster's orphan sweep still has to reconcile: one document per cluster and tenant
search-partition-resets Records: applied as they change A tenant's vector partition rebuild in flight and its wipe generation: one document per cluster and tenant
search-preemptions Records: applied as they change The deletions in progress that preempt a tenant's re-embed pass: one document per cluster and tenant

4Configuration in each mode#

Every setting the panel or the API can change has one of three classes, declared once in the server and carried by the configuration payload as classes, so the panel, the writer and the fingerprint of section 3 agree on it:

Class What it covers Where it lives Who may write it
Node The node's own settings: ports, TLS, the inference shape, security, telemetry, the MCP endpoint, the skills and model directories, SSO, memory on or off appsettings.json under the state directory, or the deployment's environment keys and mounted files The panel and the API on an unmanaged single node; nobody on a Farm or managed node, where every such write answers 409
NodeLocal Paths that legitimately differ per machine: General:FfmpegPath, Inference:ContextHibernationDirectory, Memory:Directory The same file, excluded from the fingerprint The same rule as Node
Domain Shared objects: agents, connectors, memory store policies, the tools policy and per-tool options, the skills allowlist and sources, the default model picks, per-model overrides, MCP ingest roots, the Claude Desktop lineup pins and per-identity serving shapes, the Search clusters and the Search on/off flag The identity store, one document per object, applied by the saving node at once and by every other node within Deployment:SyncSeconds Any node, in both modes, through the same panel sections and PUT /lmkit/v1/admin/configuration

A save carrying Node leaves on a node that does not own its file is refused before anything changes, always in one shape: HTTP 409 { "error": ..., "managed": true }, which the panel keys on. A save carrying Domain leaves alone lands in the store from any node, and one that mixes the two is refused as a whole, so nothing half-lands. The panel echoes the revision of each domain kind it loaded, and the store refuses a kind whose revision moved since (another node wrote it) with the reason, so a stale page never overwrites another operator's change.

Who owns the file. A Farm node never owns it: Farm mode makes Node settings read-only whatever LMKIT_MANAGED_CONFIG says. A single node owns it unless LMKIT_MANAGED_CONFIG=1 is set in the environment or a mounted settings file carries top-level ManagedConfiguration: true. Where the node does not own the file, settings come from double-underscore environment keys (Inference__SlotContextSize, Admin__Identity__Store, ...) or the mounted file, the environment winning where both state a value; nothing is seeded or migrated on disk, so a read-only ConfigMap and a read-only root filesystem both work, and secrets are read as written, never encrypted at rest by the node. An owned file is written by the panel and the setup wizard, round-trips through export and import, and seals its secrets with that node's keyring (enc:v1 values, readable only where they were written).

Who owns the domain. The identity store, unless the deployment declares Deployment__DomainSource=Manifest: then the shared objects are the manifest's too. At every start the first node of a boot wave takes a short lease and writes what its environment and mounted files declare over the store (unchanged documents are skipped, a sealed secret is compared by what it opens to), every other node applies that within its sync period, the domain editors render read-only with the manifest as the remedy, and a domain write answers the same 409 as a node setting. Without it, the manifest seeds an empty store once and the panel or the API changes the objects from then on.

What the Node rule refuses on a Farm or managed node: every plain panel setting, config import, the setup wizard's apply, the Search section's node settings (the dumps, the limits, the quality gate, reindex parallelism), the TLS editors and certificate upload and removal, the managed ffmpeg install, and the lmkit cert-acme verb. The panel renders those settings read-only, with a notice naming the mode, so a pod's resolved configuration can still be inspected.

What stays editable on every node: the Domain objects above, and everything that is state rather than configuration: model files (pull, load, unload, delete, with the fleet rules of section 5), custom-model import (which lands in the shared store), skills (authored on any node, materialized on every node), operators, sessions, API keys, uploads, knowledge-base content, memory facts, and browsing the skills library.

How a fleet gets its Domain objects. Author them once, on any node's panel or through the API, and every node sharing the identity store applies them. A manifest can seed them too, since every setting still binds from the environment or a mounted file (Agents__0__Name-style keys, the Agents, Connectors, Tools, Memory:Stores and Search:Clusters sections): the first node to start against an empty store writes the values it bound into it, once per kind, and from then on the store is the authority, so a later edit of those manifest keys is not re-read; change the objects through the panel or the API instead. Skills:Sources is the one Domain object with a per-node effect: each node installs at startup, and again whenever the sources change, the skill archives the list names, installing what is absent and leaving what is present untouched, so an edited skill ships as a new source (Agent Skills).

Secrets in Domain objects. The web-search key of the tools policy travels sealed inside its document: under the farm's LMKIT_DOMAIN_KEY every node opens it; on a single node without a key it is sealed under that node's keyring. Joining a farm re-seals the copied key for the domain and leaving seals it back for the node. A value sealed under a key this node does not hold reads as unset, with one log line, never as garbage.

Export honesty, one sentence each. The export carries the shared objects from the store in the file's own shape and masks every secret as ****, so a configuration exported from one node is a seed manifest whose secrets you supply, and an import routes those sections back to the store while the file keeps the node settings. An enc:v1 value decrypts only on the node whose keyring wrote it, so a copied settings file reads those values as empty elsewhere. A mounted managed file is read as written and never encrypted at rest, so a fleet's secrets travel by environment variable or secret mount (the secretKeyRef the container manifest uses), never inside the file.

5What stays per node, honestly#

The Farm page under Operate is the one farm-tier surface: every node's heartbeat row, drift against the node you browse, propagation lag (Farm Operations). Everything below is per node and says so.

Everything below lives on one node in this release; the table names where, what a fleet observes, whether a client can see the difference, and whether a setting relocates it (Where Data Lives has the full disk inventory).

Item Where it lives Fleet behaviour API-visible Relocatable
Configuration objects: agents, connectors, memory stores, the tools policy, the skills allowlist, Claude Desktop shapes, search cluster definitions, model overrides and default models appsettings.json on each node, or the manifest under LMKIT_MANAGED_CONFIG Farm-wide only through the manifest; a panel edit on an unmanaged node is node-local; GET /lmkit/v1/agents answers with the node's own set Yes: the agents list, the default models Only by the manifest
Memory facts Documents of the identity database (the memory-facts kind): one per store and memory id, written at the end of each exchange at the revision the exchange read, merged onto a concurrent writer's document once; the memories/ files earlier builds wrote seed the domain once Fleet-safe: a fact remembered through one node is recalled through every node from the next exchange on, and a forget removes it for the whole farm; every node must use the same Models:DefaultTextEmbeddingsModel, since a memory written under another model is refused rather than misread No Memory:Enabled may stay on; align the embedding model across the farm (Agent Memory)
Skills Documents of the identity database (the skills-content kind, one per skill with its files); skills/ per node is the materialized copy, the revision each folder holds recorded beside it Fleet-safe: authoring on any node, from the console or on disk, writes the document and every node materializes it within the sync period; Skills:Sources is synced by one node under a lease Yes, through the agents that use them Skills:Directory places the cache; Skills:Sources is the declarative lever (Agent Skills)
Custom models Records in the identity database (the custom-models kind, farm-wide); blobs shared under the work directory (LMKIT_MODELS_DIR, or ModelWorkDirectory when set) with blobs/refs markers; models-registry.json is only the seed of an empty domain, read once by the first node that has one, and models-registry.id stays per node as the identity its blob markers carry An import on any node resolves on every node within the sync period, with every artifact path under the model directory stored relative to it and re-rooted on each node; on a farm a local file outside the model directory is refused (copy it onto the volume first); a removal reclaims only what nothing references; the directory-wide reclaim (models/blobs/unreferenced, models/blobs/reclaim) is an explicit Owner action that shows foreign holders No LMKIT_MODELS_DIR at an identical path is the recommendation, not a requirement, since paths travel relative to it
Training jobs A per-node in-memory registry over a journal under the work directory (training/; the model directory unless ModelWorkDirectory names another), each journal stamped with the node that runs the job, its boot id and a heartbeat on every write, its paths kept relative to the model directory Execution stays on the node that accepted the run; every node lists every journal on the shared volume, a sibling's job read-only with its node tag (node, foreign: true); a cancel from any node is accepted (202) as a request beside the journal that the owner honours within a couple of seconds, while GET, resume and delete of a sibling's live job answer 421 Misdirected Request naming that node; a node's start marks Failed only the Running journals it owned itself and never rewrites a sibling's Yes: job status, the node and foreign fields No (Fine-tuning)
Chat-with-document sessions, including the Playground document chat In memory on the node that created them, under a session_id whose first six characters are that node's tag; idle sessions expire after thirty minutes A turn, clear or delete that reaches another node answers 421 Misdirected Request with { "error": "not_on_this_node", "node": "<tag>", "hint": ... } naming the owner; an unknown id of THIS node keeps the 404 a client reads as expired (Chat with Documents) Yes No: pin (section 6)
Grounded chat sessions (POST /lmkit/v1/search/chat) Documents of the identity database (the search-chat-sessions kind: scope, defaults, activity and the serialized conversation history), written by the node that answered the last turn; the server-minted 32-character session_id opens with the creating node's tag Fleet-safe: a turn, clear or delete on any node serves the session, a node without the live conversation rebuilding it from the recorded history (one re-prefill); a session that expired or was deleted answers 404 anywhere; a record that cannot be read while the id names another node answers 421 naming it; skip-thinking acts on the node running the turn (Grounded Answers) Yes No: the record travels
The Search control plane's caches: a tenant's settings, a collection's configuration and internal id, the Qdrant collections ensured In memory on every node, over the search-control-plane documents of the identity database: one generation per search cluster, advanced by every control-plane write Fleet-safe: a write on one node drops the other nodes' entries for that cluster when their sync brings the new generation, and every entry expires within thirty seconds regardless (Storage Engines and Deployment section 7) No No: memory only
The Search maintenance signals: the deletions still to reconcile, a tenant's partition reset in flight, a deletion preempting the re-embed worker Documents of the identity database (the search-deletions, search-partition-resets and search-preemptions kinds, one per cluster and tenant), written by the node that deletes, wipes or resets Fleet-safe: whichever node runs a cluster's orphan sweep drains what every node deleted, and a node that stops after deleting loses no signal; a deletion issued on any node cancels the tenant's re-embed pass on the node running it within the sync period; a partition rebuild in flight parks the tenant's re-embed on every node; a signal whose node is no longer alive in the roster is not honoured No No: the records travel
MCP file ingest roots (Mcp:FileIngestRoots) Directories of each node, wherever the paths point Node-local unless every node mounts one shared volume at the same path; a root under a node's state directory, or a relative one, is that node's alone, and the security audit flags it No Mount a shared volume and open that path
MCP sessions Documents of the identity database (the mcp-sessions kind: owner, client, protocol version, activity), written by the node that completed the handshake and read by every node; the Mcp-Session-Id it issues opens with that node's tag Fleet-safe for messages: any node serves a session another node initialized, the concurrent-session cap counts the farm and the idle timeout runs from the last message on any node; the event stream (GET /mcp) is per node, so progress reaches the stream open on the node running the call; a session whose record cannot be read answers 421 naming the node that issued it (MCP Server) Yes No: the record travels
The PII detect-then-redact stash Beside the file on the shared upload volume: document_detect_pii records the positioned areas next to the file's manifest (<file id>_<stamp>.pii-areas in the owner's bucket), kept thirty minutes and removed with the file; each node caches its latest detections in memory Fleet-safe: document_redact with a file id and no explicit areas finds the areas on any node, since the upload volume is shared (MCP Server) Yes, through the MCP tools Rides the upload volume
Async job records Documents of the identity database (the jobs kind: one record per job with its owner, operation, status, progress, outcome and the result up to 4 MiB), removed an hour after the job ended; the id opens with the tag of the node running the job, and the 202 carries job_id, Location: /lmkit/v1/jobs/<job_id> and X-LMKit-Node Fleet-safe: a poll or a cancel on any node reads the record, and a cancel on another node is honoured by the running node within its sync period (202 with cancel_requested); a result above the inline cap stays in the memory of the node that minted it, so a poll for it elsewhere answers 421 naming that node, as does a foreign id whose record is gone (Errors, Retries, and Jobs) Yes No: the record travels
Skip-thinking and live cancel The serving node's running exchange Both act on the serving node only. The request-id form (POST /lmkit/v1/chat/skip-thinking) answers 404 when no exchange with that request id is streaming on the answering node, never a 202 that does nothing; the session forms of chat-with-document and search/chat answer 421 for a sibling's session; the admin console's cancel acts on the node that answered it Yes No: pin
TLS material, the settings keyring, setup-state.json The state directory (certificates/, keyring/) Per node: an enc:v1 secret decrypts only where its keyring lives, the wizard's state is the node's own, and TLS terminates at the balancer or one PFX is mounted on every node No Https:CertificatePath for the PFX
The operator plane audit.db, metrics.db, telemetry-history.db, forensics/ and logs/ under the state directory, every request record and activity entry stamped with the node name (a node column in the CSV and JSONL exports) Each node's own story: every Operate section (dashboard, requests, logs, alerts, metrics, scheduler, memory, forensics, search telemetry) carries a Node <name> label, and when successive polls come from different nodes the page shows one notice saying the balancer moved the console, so an unpinned console fails loudly instead of blending nodes; fleet-wide observability is the export path (Observability) Admin only No
Managed tools tools/ under the state directory: the ffmpeg the admin panel downloads, one folder per tool Installed on the node that ran the install alone; media transcription on a sibling fails until it has its own, so install on every node or bake the tool into the image (Transcription) Yes, indirectly: media endpoints fail on a node without the tool FfmpegPath names a tool the image carries
The per-IP sign-in throttle and the per-owner job ceiling In memory per node The sign-in throttle counts per node (a fleet of N multiplies it by N; per-account lockout is domain-wide on a shared store); the per-owner job ceiling (MaxJobsPerOwner) is fleet-wide on a farm: each node admits its share, the ceiling divided by the live node count (Errors, Retries, and Jobs section 3) No Size the throttle per node; state the ceiling for the fleet
Embedded search search-embedded/ under the state directory Never provisioned on a fleet-signal node; a declared embedded cluster on such a node is flagged; a farm declares external engines (Storage Engines and Deployment) Yes, when it exists Declare external clusters

6Affinity, honestly#

Affinity is never what makes a request correct: a request that reaches the wrong node is told so and told where to go. It is what keeps the node-bound surfaces of section 5 on one node so that never happens, and it is what keeps the operator's console reading one node's story. The server emits a contract a balancer or a client can act on, and the directives per proxy live in Behind a Reverse Proxy.

What the server emits.

  • X-LMKit-Node on every response: the six-character node tag (lowercase letters and digits, a stable hash of the node's name) on every response, the full node name only on responses to an authenticated admin session. GET /health on a node's own address is the quickest way to read its tag.
  • The node's name, which everything above derives from. A node is named by the first of LMKIT_NODE_NAME, WEBSITE_INSTANCE_ID, HOSTNAME and the machine name that is set, reduced to printable ASCII (any other character becomes a dash). The tag, the header, the cookie, the ownership of training journals and the names of the search connections all follow from it, so the name must be unique in the fleet and stable across restarts: two live nodes with one name share one tag and every protection on this page reads them as one node, and nothing detects that. In a container the default is the container or pod id, which changes on every recreation; set LMKIT_NODE_NAME per node (or a stable hostname:) wherever the platform does not keep the name for you (Running in Containers shows where).
  • The owner's tag inside every node-bound id. A chat-with-document session_id, a grounded chat session_id, an Mcp-Session-Id, a job_id and a training job id open with the tag of the node that minted them, in an opaque string of the same length as before. Ids that are domain-wide (API keys, response ids, file ids, operator ids) carry no tag, because any node serves them.
  • 421 Misdirected Request on a wrong-node request. A request that names a node-bound id whose tag is not the answering node's answers 421 with the body { "error": "not_on_this_node", "node": "<tag>", "hint": ... }, where node is the owner's tag (never a hostname) and hint tells the caller to reach that node. The node never proxies to the owner. An id the answering node minted itself and no longer holds keeps answering 404, the same "unknown or expired" as on a single node, and so does a malformed id.
  • Location on every job 202. Every asynchronous acceptance minted by a task endpoint carries Location: /lmkit/v1/jobs/<job_id> beside the body's job_id, and X-LMKit-Node like every response.
  • The lmk-node cookie on a fleet member. When the node carries a fleet signal (managed configuration, or a shared identity store), the responses that mint a node-bound id and the /admin and /playground page loads set lmk-node=<tag>: HttpOnly, SameSite=Lax, Secure when the request arrived over HTTPS, valid one day. A single node without a fleet signal never sets it, so nothing changes for the one-box install.

The tiers.

Tier Surfaces Why
None needed OpenAI, Anthropic and Ollama chat, embeddings, rerank, the Responses API, files, search indexing and queries, one-shot document tasks answered synchronously, job polling and cancellation (/lmkit/v1/jobs, answered from the shared job record), MCP messages (POST /mcp, answered from the shared session record), grounded chat turns (/lmkit/v1/search/chat, rebuilt from the recorded history), health, readiness, admin authentication, and every panel edit under managed configuration Each request carries its own history or reads the shared stores; any node serves them identically
Required today The MCP event stream (GET /mcp, for live progress), chat-with-document (/lmkit/v1/chat-with-document) including the Playground document chat, live cancel and skip-thinking, training job control, and the poll for a job result too large for its record Their state is in one node's memory: a session's document index, a stream, a result above the inline cap that lives in the memory of the node that minted it. The Location header and the lmk-node cookie are how a client keeps its follow-ups on the right node, a bearer client that keeps no cookies hashes on Authorization, and a request that lands elsewhere answers 421 naming the owner
Recommended The operator's console (/admin, /lmkit/v1/admin) and the Playground (/playground) The operator plane is per node: every Operate section is labelled Node <name> and the page notices when successive polls come from different nodes, but a pinned console reads one story without the notice. On an unmanaged farm a configuration edit persists on the node that answered it, a second reason to pin

How a client keeps its pin. A browser keeps lmk-node and a balancer that reads application cookies (HAProxy with per-server cookie values, nginx Plus sticky route, AWS ALB application-cookie stickiness) routes on it directly; a balancer that mints its own affinity cookie (ingress-nginx, Traefik, IIS ARR, App Service, nginx Plus sticky cookie, Caddy) pins the same browser with its own cookie, and lmk-node then tells the operator which node a browser is on. A bearer API client keeps no cookies, so it is pinned by a consistent hash on the Authorization header: every request with the same key reaches the same node while the pool is stable, which is also the KV prefix-cache optimization (a client that resends long system prompts keeps its prefixes warm on one node). The rule the hash must satisfy is one sentence: the request that opens a session or starts a stream and the request that follows it must hash identically, which only a whole-surface hash guarantees. A session is opened by chat-with-document on whichever node answered, an MCP event stream on whichever node received the GET, and skip-thinking targets the exchange streaming on /lmkit/v1/chat, so hash EVERY request outside the browser set (/admin, /lmkit/v1/admin, /playground) by Authorization and pin the browser set by cookie or source address. A header hash falls back to round-robin on a request without the header, so a bearer client that omits its key loses its pin with its authorization. Where a balancer has no header hash (Traefik OSS, IIS ARR, an AWS ALB) a bearer client is unpinned; it can still follow the contract itself, by sending its follow-ups to the node X-LMKit-Node named, when it can reach nodes by address.

Reading a lost pin. A 421 is the symptom: the request reached a node that does not hold the id, and the body names the one that does. Compare X-LMKit-Node on the minting response with the tag inside the id and the value of lmk-node; when they agree and a later request still answers 421, the balancer is not honouring the pin, and the balancer guide says which directive to check.

Every identifier a client can present later, by kind (generated from the id registry, so a kind added without its row fails the build): a node-bound id opens with the owner's tag and a wrong-node request is told where to go; a domain-wide id carries no tag, because any node serves it.

Id kind Scope What it names
job Node: opens with the owner's tag Asynchronous job: run by the node that accepted it; its record travels the shared store, a result too large for the record stays in that node's memory.
document-chat-session Node: opens with the owner's tag Chat-with-document session: the loaded document index and the conversation state live on the node that created it.
search-chat-session Node: opens with the owner's tag Grounded search chat session: created on one node, recorded in the shared store, and served by any node that rebuilds its conversation from the record.
mcp-session Node: opens with the owner's tag MCP session: recorded in the shared store by the node that completed the handshake and served by any node; its event stream stays on the node that opened it.
training-job (prefix train-) Node: opens with the owner's tag Training job: executed and controlled by the node that started it; its journal rides the model volume.
turn-transcript Node: opens with the owner's tag Turn transcript: a diagnostic stash held in the memory of the node that served the turn.
restart-report Node: opens with the owner's tag Restart report: the forensic verdict about this node's previous process, acknowledged from the panel.
api-key Domain: any node serves it API key record: lives in the operator identity store every node shares.
admin-session Domain: any node serves it Operator session row: lives in the identity store; the token itself never leaves the store.
response (prefix resp_) Domain: any node serves it Stored response of the Responses API: persisted in the identity store for chaining from any node.
response-item Domain: any node serves it Item inside a stored response: travels with its response, so it is meaningful wherever the response is.
file Domain: any node serves it Uploaded file: content and manifest live on the upload volume every node mounts.
training-dataset Domain: any node serves it Training dataset token: the file lives under the training tree of the shared model volume.
search-tenant Domain: any node serves it Search tenant public id: a row of the cluster's control plane, served by every node of that cluster.
search-collection Domain: any node serves it Search collection public id: a row of the cluster's control plane, served by every node of that cluster.
search-evaluation-batch Domain: any node serves it Search evaluation batch: groups the runs of one evaluation in the cluster's evaluation tables.

7The load balancer#

Any TCP or HTTP balancer works once four requirements are met, and only the first one hides a trap (Behind a Reverse Proxy has the exact settings per proxy).

  • Membership by readiness. GET /lmkit/v1/health/ready answers 200 only when the node can serve and 503 otherwise, with the status word alone: not_ready while it initializes, warming while its lineup loads, draining the moment a graceful stop begins. It is anonymous and exposes nothing else, so every prober gates on it, including the ones that cannot send a header (the IIS ARR health test, Azure Load Balancer, an AWS ALB target group, the App Service health check). Never probe /health for membership: it is liveness, 200 whenever the process is alive, warming and draining included, so a balancer on it routes to a node before its models are resident and keeps routing to a node that is stopping. GET /lmkit/v1/ready carries the detailed body behind a bearer key for probers that want it (Kubernetes httpHeaders, HAProxy http-check send hdr). Windows NLB has no application probe at all and cannot gate on anything.
  • Streaming-safe forwarding. Disable response buffering and raise idle timeouts so streamed completions flow token by token.
  • Real client addresses. List the balancer in Security:TrustedProxies so sign-in throttling and the audit trail record the caller, not the proxy. A platform front whose addresses cannot be listed (App Service, Container Apps) takes the * sentinel, which the server accepts on those two platforms and refuses everywhere else unless Security:ProxyFrontOnly=true states that the nodes are unreachable except through the front. When TLS ends at the front, set Security:PublicBaseUrl to the public address, so the SSO callback and the Secure flag on the server's cookies follow the front instead of the node's own plain listener.
  • Affinity for the node-bound surfaces, per section 6.

8Node lifecycle: join warm, leave draining#

A node joins reporting warming until its configured lineup is resident and leaves reporting draining while it finishes its work, and the only setting that changes off Kubernetes is the advertise window (Running in Containers wires the same behaviour into the manifests).

Joining. Opt in with Inference:WarmupModels (model ids, or default for the default chat model): readiness stays 503 warming until the lineup is loaded, so the balancer holds traffic until the node can actually serve it. A model that cannot load parks the node visibly in warmup_failed instead of letting it serve refusals.

Leaving. The moment a graceful stop begins, readiness flips to 503 draining and the listener stays open for a short advertise window so requests routed before the balancer noticed still get served. DrainAdvertiseSeconds defaults to 5 when the server detects a platform that routes by readiness (Kubernetes, Azure App Service, Azure Container Apps, Amazon ECS) and to 0 everywhere else (clamped to 0..30), so a VM, Windows service or Compose fleet sets DrainAdvertiseSeconds=5 explicitly or its last requests are refused. Then the listener closes and in-flight work, streamed completions included, finishes within ShutdownDrainSeconds (default 100, clamped to 5..570). The supervisor's stop allowance must exceed advertise plus drain plus 15 seconds of teardown, 120 seconds with the defaults, and every supervisor names it differently: terminationGracePeriodSeconds in Kubernetes (30 by default), TimeoutStopSec under systemd (90 by default, BELOW the default drain, so the unit raises it to 130), stop_grace_period in Compose (10 by default), WEBSITES_CONTAINER_STOP_TIME_LIMIT on App Service (5 by default, 120 at most) and stopTimeout on ECS (30 by default, 120 at most). The server knows these defaults: at startup it logs one warning naming the setting to raise when the detected platform's allowance is below what the drain needs, and where a platform caps the allowance at 120 you lower ShutdownDrainSeconds to 90 instead. The Windows service asks the service control manager for the time it needs when the stop begins, so no setting exists there. Fleet Recipes has the line for each platform.

Because joins are warm and leaves are lossless, a rolling upgrade is the two combined: one node drains and finishes its streams while its replacement warms, and the fleet never serves a refusal it did not choose.

9The scale signal#

The gauge to act on is lmkit_inference_slots_saturation on /metrics, the busy share of the node's completion budget from 0 to 1, exported per node and averaged by the autoscaler (Observability covers the export path).

Pressure shows there BEFORE a queue forms, so a scaler acting on it adds capacity while the fleet still absorbs the load; lmkit_inference_queue_depth confirms when it did not. On Kubernetes, KEDA drives the replica count from avg(lmkit_inference_slots_saturation) (the ScaledObject, both trigger styles, and the fleet manifests are Running in Containers sections 7 and 9). On a VM fleet the same per-node gauge feeds whatever starts machines for you, or a person watching a graph; the contract is identical because the signal is.

10Without Kubernetes#

Nothing above requires an orchestrator: two VMs running the Linux package under systemd (or two Windows services, Windows Server Deployments), HAProxy or nginx in front, one PostgreSQL for the identity database, and an NFS or SMB share for uploads and models is a complete fleet, and this section writes out the exact environment it takes.

Choose the configuration owner first; the two variants differ in one key and in who may edit what:

With LMKIT_MANAGED_CONFIG=1 Without it
Panel and wizard Read-only for configuration; every configuration edit answers 409 Editable, and every edit lands in that node's appsettings.json alone
Agents, connectors, memory stores, tools policy, skills sources, search clusters, Claude shapes, default models From the manifest, identical on every node by construction Whatever each node's file says; nothing propagates, nothing compares
Skills Reproduced from Skills:Sources on every node Same, plus node-local panel authoring that diverges the nodes
Drift Impossible for configuration Silent: the only symptom is two nodes behaving differently
Operators, API keys, sessions, model files, uploads Editable on every node (shared store and volumes) Editable on every node (shared store and volumes)

In Farm mode the panel is read-only for configuration in both variants: the mode implies the managed rule, so the second column then describes a farm exactly.

Then set the same block on every box. This is 21 environment keys as written, and a real fleet sets fewer: the Search cluster row is needed only when Search is on and stands for one cluster's keys, and the two HTTPS rows are alternatives, never both.

Key Value Why
LMKIT_DEPLOYMENT_MODE Farm Declares the node a farm member (section 1): node settings read-only, the farm prerequisites checked at every start, the heartbeat written and the shared domain read every Deployment__SyncSeconds (5)
LMKIT_DOMAIN_KEY At least 32 characters of your own entropy, the same value on every node Seals the secrets the shared domain carries; a node without it, with a shorter one, or with a key its siblings did not use (the identity database holds a probe sealed under the farm's key) refuses to start. Never logged
LMKIT_MANAGED_CONFIG 1 for the managed variant; absent for the unmanaged one Decides who owns configuration (above)
LMKIT_NODE_NAME A name unique in the fleet and stable across restarts, one per box (the hostname when hostnames are unique and permanent) The tag in X-LMKit-Node, the lmk-node cookie and the ownership of training journals derive from it (section 6); two nodes with one name read as one node
LMKIT_STATE_DIR A directory on LOCAL disk on every node, never a shared or cloned directory It holds the keyring, setup-state.json, the operator history and, on a single node, the identity file; two nodes over one state directory multi-write all of them
LMKIT_MODELS_DIR The NFS or SMB model share, mounted on every node (at the identical path by preference) Models pull once under a cross-process lock; a share that does not honour file locks still yields whole files, since each machine downloads into its own sidecar, at the price of a duplicate transfer when two nodes race
FileManagement__UploadDirectory The upload share, mounted at the identical path on every node A file id resolves on every node
Admin__Identity__Store postgres, mysql or sqlserver The one shared domain
Admin__Identity__ConnectionString The dedicated identity database Never a knowledge-base database
Admin__InitialPassword The first operator's password, or omit it and run lmkit admin create-owner on one node with that node's environment (The Command Line) Consumed only while no account exists; a shared store never opens the admin surface
Search__Enabled false, or true (the clusters then live in the identity store, created from any node's panel or seeded once from the row below) The startup gate reads this before the store opens: an explicit false is authoritative by presence; without any declaration a fleet-signal node leaves Search disabled and says so
Search__Clusters__0__Id, Search__Clusters__0__IsDefault, Search__Clusters__0__FullTextStore, Search__Clusters__0__ConnectionString The seed of an empty store, once: PostgreSql holds text and vectors alone; MySql or SqlServer also need Search__Clusters__0__VectorStore=Qdrant and Search__Clusters__0__QdrantUrl The embedded engine is never provisioned on a fleet member, and a farm node refuses to create one
Search__AutoProvisionLocalCluster false Belt and braces beside the explicit Search__Enabled: no node ever provisions a private cluster
Security__TrustedProxies__0 The balancer's address, or the narrowest CIDR that holds only the balancer Real client addresses in the throttle and the trail; X-Forwarded-Proto honoured for the admin HTTPS rule. Every address in this list may set X-Forwarded-For and so impersonate any client to the sign-in throttle and the admin IP allowlist, so name the balancer, never the network it shares with other machines
Security__NetworkAccess Network when the node binds beyond loopback Every API call then presents a key, the right posture for a node the balancer reaches over the network
DrainAdvertiseSeconds 5 The default is 0 where no platform that routes by readiness is detected (section 8)
Inference__WarmupModels__0 default, or a model id Readiness waits for the lineup
Https__Enabled false, with TLS terminated at the balancer The node then serves HTTP to the balancer only
Https__CertificatePath The alternative: one PFX present on every node, with Https__CertificatePassword beside it The node terminates TLS itself; the configuration also reads Https__Port and the legacy top-level EnableHttps and HttpsPort
HttpPort Only when the node must not listen on 5189 The balancer's upstream port
Memory__Enabled true (the default) Memory facts are shared objects of the identity database (section 5): a fact remembered through one node is recalled through every node

Machine-wide delivery. Under systemd, Environment= lines in the unit (or an EnvironmentFile=), so the service sees them and an operator's interactive shell does not by default; on Windows, setx /M or Group Policy, because a service running as LocalSystem reads the SYSTEM environment, not any user's. A CLI verb run from a shell without these keys resolves a different identity store than the service does (The Command Line).

11Availability, restated#

A fleet is the live availability answer for the stateless surfaces and for nothing else, and Backup and Upgrades says what each copy set contains.

Losing a node loses its in-flight work AND everything section 5 lists as per node: its live chat-with-document sessions, the MCP event streams open on it, the jobs it was running (their records stay and close as lost), a training run in progress, and its operator history (audit, metrics, telemetry, forensics, logs). Keys, sessions, stored responses, uploads, memory facts, skills, custom-model records, job, MCP session and grounded chat session records and external search data live in the shared stores and volumes, which ride your database and storage operations. The single-box cold-standby story remains valid for single-box deployments; a fleet does not need it for the stateless data plane.

Where the requirement is regulatory separation (independent failure AND audit domains), separate fleets remain the answer: the same boundary One Server, Many Teams draws for tenancy, applied to infrastructure.

12Stated plainly#

  • Scale by replication: identical nodes, a load balancer, one dedicated identity database, shared upload and model volumes, external search engines. Any node serves the stateless surfaces.
  • A farm needs one identity database on PostgreSQL, MySQL or SQL Server, and one external engine per search cluster; the embedded engines are single-node only.
  • Node settings are the node's file: Farm mode (and LMKIT_MANAGED_CONFIG=1 on a single node) makes them read-only and every such edit refuse with 409; without either, panel edits are node-local and drift silently. Domain objects (agents, connectors, memory store policies, the tools policy, the skills allowlist and sources, default models, per-model overrides, MCP ingest roots, the Claude Desktop lineup pins and serving shapes, the Search clusters and the Search on/off flag) live in the identity store and save from any node in both modes.
  • Per node, honestly: training execution, chat-with-document sessions, the MCP event stream, the results of jobs too large for their record, skip-thinking and cancel, TLS and keyring, the operator plane, the throttle and budgets, embedded search. Every Operate section is labelled with its node and every record and export row carries the node name.
  • The contract: X-LMKit-Node on every response, the owner's tag inside every node-bound id, 421 Misdirected Request with not_on_this_node and the owner's tag on a wrong-node request, Location on every job 202, and an lmk-node cookie on fleet members. A job's record lives in the shared store, so any node polls and cancels it; a session or a stream lives in the memory of the node that opened it, so the cookie or the Authorization hash is how a client keeps its follow-ups on the right node: hash every request outside the console and Playground paths by Authorization (the request that opens a session or starts a stream and the one that follows it must reach the same node) and pin the console with a cookie or the source address.
  • Every prober gates on /lmkit/v1/health/ready, anonymous and status-only; /health is liveness and never membership. Set DrainAdvertiseSeconds=5 where no platform is detected and keep the supervisor's stop allowance above advertise plus drain plus 15 seconds.
  • Kubernetes gets manifests and KEDA; plain VMs get the identical contract with systemd and any balancer. One request never spans machines, and the floor is one warm node.

The twelve questions the design answers, and where. Each has one owner among the fleet guides; the other guides link to it.

Question Owner
Q1. Simple configuration, through the panel or the files Scaling Out section 4
Q2. Everything documented, per approach Fleet Recipes section 7
Q3. Azure App Service Fleet Recipes section 1
Q4. Kubernetes and the other platforms Fleet Recipes section 3
Q5. Statistics per node and per farm Farm Operations section 1
Q6. What is stored, and where Where Data Lives section 2
Q7. Whether a database is needed, and which Scaling Out section 3
Q8. An agent added in the panel is farm-wide Farm Operations section 8
Q9. Single node or farm, one declared mode Scaling Out section 1
Q10. A dedicated panel for the farm Farm Operations section 2
Q11. Session affinity Scaling Out section 6
Q12. The same configuration on every node, enforced Farm Operations section 3

Validate your fleet. The probes the two-node harness runs on every build, to run once against yours:

  • GET /lmkit/v1/health/ready on each node answers 200 and names farm as the mode; every response carries a different X-LMKit-Node per node, so two nodes that answer with one tag share a name and must be renamed.
  • A request for a node-bound id sent to the other node answers 421 Misdirected Request with not_on_this_node and the owner's tag, and the lmk-node cookie is set on every fleet member's response.
  • An agent saved through one node is listed by the other within the sync period (Deployment:SyncSeconds), and a node setting edited on either node refuses with 409.
  • The Farm page's roster shows every node with zero domain lag and no unaccepted difference, and the security audit's Fleet checks all answer ok.
  • The Farm page's work table shows each single-runner lease held by exactly one node, and a manual run of that job from another node answers 409 naming the holder.
  • Stopping one node drops its readiness for the drain window while the other keeps serving, and the node rejoins under the same tag when it returns.