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__InitialPasswordbecomes the password of theadminaccount 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 throughlmkit admin create-ownerrun on a node with that node's environment (The Command Line explains why the shell's environment matters).Admin__Passwordis not a third way in: it is the emergency reset, re-applied to theadminaccount 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.jsonis 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 fromX-Forwarded-Forinstead 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-Nodeon 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 /healthon 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,HOSTNAMEand 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; setLMKIT_NODE_NAMEper node (or a stablehostname:) 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 chatsession_id, anMcp-Session-Id, ajob_idand 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 Requeston a wrong-node request. A request that names a node-bound id whose tag is not the answering node's answers421with the body{ "error": "not_on_this_node", "node": "<tag>", "hint": ... }, wherenodeis the owner's tag (never a hostname) andhinttells 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 answering404, the same "unknown or expired" as on a single node, and so does a malformed id.Locationon every job202. Every asynchronous acceptance minted by a task endpoint carriesLocation: /lmkit/v1/jobs/<job_id>beside the body'sjob_id, andX-LMKit-Nodelike every response.- The
lmk-nodecookie 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/adminand/playgroundpage loads setlmk-node=<tag>:HttpOnly,SameSite=Lax,Securewhen 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/readyanswers 200 only when the node can serve and 503 otherwise, with the status word alone:not_readywhile it initializes,warmingwhile its lineup loads,drainingthe 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/healthfor 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/readycarries the detailed body behind a bearer key for probers that want it (KuberneteshttpHeaders, HAProxyhttp-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:TrustedProxiesso 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 unlessSecurity:ProxyFrontOnly=truestates that the nodes are unreachable except through the front. When TLS ends at the front, setSecurity:PublicBaseUrlto the public address, so the SSO callback and theSecureflag 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=1on 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-Nodeon every response, the owner's tag inside every node-bound id,421 Misdirected Requestwithnot_on_this_nodeand the owner's tag on a wrong-node request,Locationon every job202, and anlmk-nodecookie 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 theAuthorizationhash is how a client keeps its follow-ups on the right node: hash every request outside the console and Playground paths byAuthorization(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;/healthis liveness and never membership. SetDrainAdvertiseSeconds=5where 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/readyon each node answers 200 and namesfarmas the mode; every response carries a differentX-LMKit-Nodeper 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 Requestwithnot_on_this_nodeand the owner's tag, and thelmk-nodecookie 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.