Running in Containers
How to run this server from the official image: one docker run on Linux, Windows or macOS,
volumes for state and models, GPU passthrough, health-probe wiring, Compose, and Kubernetes in
both honest shapes: one pod with node-local state, or an autoscaled fleet that shares its admin
domain, uploads, models, and manifest-owned configuration. For self-hosters whose stack is
Docker, and platform engineers whose infrastructure is a cluster.
1The official image#
The image is lmkitone/lm-kit-one on Docker Hub, mirrored digest for digest as
ghcr.io/lm-kit/lm-kit-one on GitHub Container Registry. One tag serves both linux/amd64 and
linux/arm64; Docker picks the variant for the machine it runs on. Tags are the product's own
calendar versions (2026.9.0) plus latest, which moves only once a release has shipped every
target, so latest never names a build that is not on the download page.
docker run -d --name lmkit -p 5189:5189 -p 7221:7221 \
-v lmkit-state:/data/state -v lmkit-models:/data/models \
lmkitone/lm-kit-one
With an NVIDIA GPU, one flag more (section 4 has the host prerequisite):
docker run -d --name lmkit --gpus all -p 5189:5189 -p 7221:7221 \
-v lmkit-state:/data/state -v lmkit-models:/data/models \
lmkitone/lm-kit-one
The API is at http://localhost:5189 and the admin panel at https://localhost:7221/admin. The
panel travels over TLS only: the server accepts a plain-HTTP admin request from its own loopback
alone, and through a published port every host client is a network peer, so a plain-HTTP page
request is redirected to the HTTPS port, which serves the server's self-signed certificate and
the browser asks once to trust it. On a trusted private network
-e Security__RequireHttpsForAdmin=false lifts the rule (Going Live).
The first sign-in is the admin account with a one-time password: a container is reachable from
the network, so the server never leaves its panel open and instead creates that account at its
first start and prints the password once, in the startup log (docker logs lmkit); the sign-in
replaces it with a password of your own. To choose the first password yourself, seed it from the
environment, -e Admin__InitialPassword=... (section 2). Every API call needs a key, minted in
Access (Keys and Authentication); the health routes stay
anonymous.
Windows and macOS hosts run the same image through Docker Desktop, which hosts Linux
containers in its own virtual machine. On Windows the NVIDIA driver reaches the container through
WSL2, so --gpus all works unchanged with nothing else installed. On macOS the container is
CPU-only, because Metal never crosses into a container; a Mac that wants its GPU runs the native
package instead. There is no Windows-native container image: those cannot use CUDA, need a host
kernel matching the base image, and nothing in this space ships one. A Windows server that wants
native performance takes the MSI and its service mode (Windows Server).
Upgrading is docker pull lmkitone/lm-kit-one, then remove and recreate the container with the
same volumes: everything the server has written rides the state volume across it (Backup and
Upgrades).
Stopping. docker stop gives a container 10 seconds by default, and a busy server drains
in-flight completions for up to 100 (ShutdownDrainSeconds) plus a short teardown; the startup
log says so on every start under Docker. docker stop -t 120 lmkit, or --stop-timeout 120 on
the run line, gives a graceful stop the room it needs; Compose spells it stop_grace_period
(section 6).
Verify what the container sees with docker exec lmkit lmkit devices, and the whole
environment with docker exec lmkit lmkit doctor (The Command Line).
A container remains optional, not required. Where a cluster is not mandated, the service install in Going Live is a first-class path; containers buy orchestration, not portability the binary lacks.
2What the image decides#
Five decisions shape the image, each worth understanding before building on it:
- Base image. Ubuntu 24.04 LTS, the release the payload is built and packaged on, so the base
is known compatible with the native stack. The binary targets glibc (the
linux-x64andlinux-arm64runtimes), so musl bases such as Alpine are out. What the image adds to the payload is small and deliberate:libicu74, without which the .NET runtime refuses to start;ca-certificates, so the server can pull catalog models over HTTPS;curl, for the health check; the CUDA 13 and CUDA 12 runtimes and the Vulkan loader with Mesa's drivers, so a GPU needs nothing but its host driver (section 4), withvulkaninfobeside them to read what the loader sees from inside the container; and ffmpeg, next. - ffmpeg inside. Audio and video transcription and video frame extraction run ffmpeg as a
separate program; a host install brings its own, or downloads the server-managed build from
the admin panel. The image carries it already: the static build the server would download on
a Linux host, pinned by release and checksum, on the
PATH, so a recording in any container or codec (a video's audio track included) transcribes on the first request with nothing installed and nothing fetched, and the capabilities route advertises the full set (Transcription). An operator's own build still wins, throughLMKIT_FFMPEG_PATHor the panel's FFmpeg Path. ffmpeg is GPL software, included unmodified; its license, version and build configuration are recorded in the image under/usr/share/doc/ffmpeg. - Network posture. By default the server listens on loopback only
(Going Live). Inside a container that makes it unreachable through
published ports, which connect to the container's own interface, not its loopback. The image
therefore sets
Security__NetworkAccess=Network(environment variables override the settings file). The consequence is deliberate: in Network posture every API call must present a key, which is the right default for a server that is, by definition, on a network. - Pinned paths.
LMKIT_STATE_DIR=/data/stateandLMKIT_MODELS_DIR=/data/models, both declared as volumes, so state never lands in the container's writable layer, wheredocker rmwould delete it (section 3). - Health check. The liveness route is
GET /health, always anonymous (section 5); the image'sHEALTHCHECKprobes it at half-minute intervals after a one-minute start period.
The process runs as root, as most inference-server images do, so bind mounts work without a
chown step. --user <uid> works when both mounted volumes are writable by that uid;
lmkit doctor inside the container verifies paths, writability and devices in one pass.
The image exposes both ports: 5189 for the API over plain HTTP and 7221 for HTTPS with the
self-managed certificate, which is where the admin panel lives from anywhere but the container's
own loopback (section 1). In a cluster TLS normally terminates at the ingress or proxy in front
of the container instead; list it in Security__TrustedProxies so its forwarded scheme counts
as HTTPS for the admin rule, and take the buffering and forwarded-header specifics that keep
streaming working from Behind a Reverse Proxy. A kubectl port-forward
reaches the pod over its loopback and needs neither. The first operator account is created at
the first start, one way or the other: seeded from the environment (Admin__InitialPassword
becomes the password of the local admin account, applies only while no operator account exists
yet, and is never stored in plain text), or, with nothing seeded, generated by the server, which
prints a one-time admin password once in its startup log and makes the first sign-in replace
it. Once any account exists the seed is ignored, restart after restart; Admin__Password
is the emergency reset, re-applied on every start it is present, so it never belongs in a
manifest for longer than one restart (Operator Accounts section 3).
Then mint per-caller API keys in Access
(Keys and Authentication).
Building on the image. A site that needs more than the environment can express (a corporate certificate authority, a model baked in, a binary an agent tool calls) extends the official image rather than starting from the archive, and rebuilds when a new version tag appears:
FROM lmkitone/lm-kit-one:2026.9.0
# A private certificate authority, for model mirrors and tools behind it.
COPY corp-root-ca.crt /usr/local/share/ca-certificates/
RUN update-ca-certificates
# A model baked into the image: `lmkit pull` needs no running server and
# writes to LMKIT_MODELS_DIR, which the image has already pinned.
RUN lmkit pull gemma4:12b
Everything the base image set (ports, volumes, health check, entrypoint) is inherited. A stage
that changes LMKIT_MODELS_DIR must pull after the change, so the file lands where the runtime
reads.
3State and models on volumes#
Two mounts, two lifetimes:
| Mount | Holds | Lifetime |
|---|---|---|
/data/state (LMKIT_STATE_DIR) |
Settings and the keyring that seals their secrets, the embedded identity store with operators and API keys, certificates, uploads (by default), memories, skills, the custom-model registry, setup state, embedded search databases, and the operator history (request trail, metrics, telemetry, forensics, logs) | This volume IS the installation. Back it up; losing it loses the server's identity (the inventory). |
/data/models (LMKIT_MODELS_DIR) |
Downloaded model files | Replaceable: models re-pull from the catalog. Persist it to avoid re-downloading tens of gigabytes on every container replacement. |
Pinning both explicitly matters most in a container: without LMKIT_STATE_DIR, the
writability rule would resolve state next to the executable, inside the container's ephemeral
layer, and the installation would silently die with the container.
Both mounts are read-write by default, and the model volume can be read-only when the
deployment wants immutable models. Models download into the model volume on first use, and not
only the chat models: the transcription, embedding, reranking and image segmentation models
arrive the same way, on the first request that needs them, so a read-only volume must hold
every model the deployment needs, pulled beforehand (lmkit pull <model> into the volume, or
baked into the image, below). The server also writes its training jobs and imported models
beside the models; ModelWorkDirectory moves those writes to a directory of their own, which
is what a read-only deployment sets:
docker run -d --name lmkit -p 5189:5189 -p 7221:7221 \
-v lmkit-state:/data/state -v lmkit-models:/data/models:ro \
-e ModelWorkDirectory=/data/state/work \
lmkitone/lm-kit-one
A request that needs a model the read-only volume lacks answers 503 model_store_read_only
naming the model to pull; a training job on a read-only volume with no work directory is refused
the same way, naming the setting. The startup log, the dashboard status and lmkit doctor
report the posture on every start.
Mounting versus baking models. Mounting (above) is the default: the volume outlives image
rebuilds and upgrades. The alternative is baking models into the image at build time, with
lmkit pull in a stage on top of the official image exactly as section 2 shows
(The Command Line). The image grows by the model file, but the result is
a single immutable artifact that serves on first start with zero egress: the container form of
the golden image (Air-Gapped Deployment). Pick one strategy per
deployment; a baked image with a models volume mounted over /data/models hides its own baked
files.
4GPU passthrough#
NVIDIA (CUDA) is the path that works predictably. On Linux, install the
NVIDIA Container Toolkit
on the host and run with --gpus all (or the Compose and Kubernetes equivalents below); on
Windows, Docker Desktop reaches the host's NVIDIA driver through WSL2 and the same flag works
with nothing else installed. The image ships the CUDA 13 runtime libraries (cudart, cublas,
cublasLt) and, for a host whose driver is too old for CUDA 13, the CUDA 12 set, both registered
with the container's dynamic linker: the container needs no CUDA installation of its own, the
host contributes the driver, and nothing else. Verify what the runtime actually sees:
docker exec lmkit lmkit devices
If no GPU is listed, the server still serves, on CPU; the probe result is the fact to trust, not the run flags. Backend selection and driver details are GPUs, Drivers, and Backends.
Podman passes GPUs through the Container Device Interface (CDI): the device name
nvidia.com/gpu=all must be described by a specification on the host, which the NVIDIA
Container Toolkit writes and lists:
sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml
nvidia-ctk cdi list
Then the run line names the device (--gpus all resolves to the same device):
podman run -d --name lmkit --security-opt=label=disable --device nvidia.com/gpu=all \
-p 5189:5189 -p 7221:7221 \
-v lmkit-state:/data/state -v lmkit-models:/data/models \
lmkitone/lm-kit-one
--security-opt=label=disable is for hosts enforcing SELinux, which is every Fedora, RHEL and
Rocky machine as installed: SELinux confines the container's processes away from the device
nodes and driver libraries the specification injects, so the container starts and the runtime
finds no GPU. The flag is a no-op on a host without SELinux, so the line is safe to keep as is.
Regenerate the specification after every driver upgrade: it pins the driver's library versions.
On Windows, Podman runs its containers inside a WSL2 machine, so the toolkit is installed and
the specification generated in there: podman machine ssh, the toolkit package from NVIDIA's
repository, then the same two commands; the
Podman Desktop GPU page carries the package
commands for each machine image.
When the GPU is not seen. The runtime's own reading is the fact to trust: lmkit devices
inside the container (docker exec lmkit lmkit devices, podman exec lmkit lmkit devices) and
the startup log (docker logs lmkit, podman logs lmkit), which names the reason a CUDA
backend was set aside. In a container the CUDA runtime libraries are inside the image, so what
the host's ldconfig lists never enters into it; the two questions are whether the GPU reached
the container and whether the process may use it.
| What you see | What it means | What to do |
|---|---|---|
Podman stops with unresolvable CDI devices nvidia.com/gpu=all |
No CDI specification describes the device, or the one on disk predates a driver upgrade | sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml, then nvidia-ctk cdi list must show nvidia.com/gpu=all |
Podman stops with could not discover GPU vendor: no know GPU vendor found in CDI specs (the misspelling is Podman's own) |
--gpus all on a host with no CDI specification at all |
The same generation; --device nvidia.com/gpu=all is the explicit form |
The container runs, the admin Hardware panel reads RUNNING ON CPU, lmkit devices lists nothing, and podman exec lmkit nvidia-smi fails |
SELinux keeps the container away from the injected devices and libraries | Add --security-opt=label=disable to the run line; getenforce on the host says whether SELinux is enforcing |
| The same, with SELinux permissive or absent | The GPU never reached the container | Docker: install the NVIDIA Container Toolkit, sudo nvidia-ctk runtime configure --runtime=docker, restart Docker, run with --gpus all. Podman: regenerate the specification and check that grep libcuda /etc/cdi/nvidia.yaml prints the driver library |
nvidia-smi inside the container works, lmkit devices still lists nothing |
The host driver is older than the CUDA 12 runtime the image carries as its floor | Upgrade the host driver; the image needs nothing else |
AMD and Intel (Vulkan) work on a Linux host with one flag: --device /dev/dri hands the
GPU's render node to the container, and the image carries the rest, the Vulkan loader and Mesa's
drivers (RADV for AMD, ANV for Intel). Mesa drives the GPU from user space over the kernel's
stable interface, so nothing in the image has to match the host's driver version; the host needs
only its kernel driver (amdgpu, i915 or xe), which every current distribution ships. Running with
--user adds --group-add for the group that owns /dev/dri/renderD* (render or video).
Mesa's software rasterizer is not in the image, so a CPU is never mistaken for a GPU. There is no
Vulkan path on a Windows or macOS host: Docker Desktop passes NVIDIA GPUs only, through WSL2 and
CUDA, and on those hosts an AMD or Intel GPU serves from the native package instead.
docker run -d --name lmkit --device /dev/dri -p 5189:5189 -p 7221:7221 \
-v lmkit-state:/data/state -v lmkit-models:/data/models \
lmkitone/lm-kit-one
Podman takes the same flag; on an SELinux host add --security-opt=label=disable, or the render
node is visible but refused:
podman run -d --name lmkit --security-opt=label=disable --device /dev/dri -p 5189:5189 -p 7221:7221 \
-v lmkit-state:/data/state -v lmkit-models:/data/models \
lmkitone/lm-kit-one
Nothing has to be installed inside the container, and VK_DRIVER_FILES never needs setting: the
loader reads the manifests the image ships. When the Hardware panel still reads RUNNING ON CPU,
docker exec lmkit vulkaninfo --summary (or podman exec) is the loader's own account: a listed
device with driverName = radv (AMD) or Intel open-source Mesa driver means the GPU reached the
container and the SDK elects it on the next start; Failed to detect any valid GPUs means no
render node reached the container (the --device flag is missing, or SELinux refused it); a
device whose type is CPU would be a software rasterizer, which the image does not carry and the
SDK never elects.
5Health probes#
Three routes, built for orchestration and wired in the manifests below; the full contract is in Observability:
| Route | Auth | Meaning |
|---|---|---|
GET /health |
None, always anonymous | 200 while the process is alive, warming and draining included. Liveness. |
GET /lmkit/v1/health/ready |
None, always anonymous | 200 with ready once the backend is initialized and the warmup lineup is resident; 503 with not_ready, warming or draining otherwise, and nothing else in the body. Readiness. |
GET /lmkit/v1/ready |
API key (bearer) | The same decision with the detailed body: counts, model names, saturation, node. Readiness with detail. |
The manifests probe the anonymous readiness route, so no key sits in a manifest and the
readinessProbe gates on warming and draining for free. The bearer route exists for probers
that want the body, through Kubernetes httpHeaders carrying a dedicated key granted nothing
else (Keys and Authentication), and for KEDA's metrics-api scaler in
section 9. Probers that cannot send a header (a cloud load balancer's health check, IIS ARR,
the App Service health check) use the anonymous readiness route and keep the gate
(Fleet Recipes has each platform's line). The image's
HEALTHCHECK stays on /health: Docker and Swarm restart a container whose check fails, and
a node that is warming or draining must not be restarted for it.
6Compose#
A complete single-host deployment: the official image, both volumes, the GPU reservation, a stop
window sized for the drain, and a published port. This shape is equally the homelab answer: a VM
on a hypervisor or a NAS runs it identically, with docker compose up -d.
services:
lmkit:
image: lmkitone/lm-kit-one:latest # pin a version (lmkitone/lm-kit-one:2026.9.0) for reproducible rollouts
container_name: lmkit
restart: unless-stopped
hostname: lmkit-1 # a stable node name; the default is the container id, new on every recreate
stop_grace_period: 120s # advertise + ShutdownDrainSeconds + teardown (section 1)
ports:
- "5189:5189" # API, plain HTTP
- "7221:7221" # admin panel, HTTPS (section 1)
environment:
Admin__InitialPassword: "change-on-first-login"
volumes:
- lmkit-state:/data/state
- lmkit-models:/data/models
deploy: # NVIDIA. For an AMD or Intel GPU, replace this block with:
resources: # devices:
reservations: # - /dev/dri:/dev/dri
devices:
- driver: nvidia
count: all
capabilities: [gpu]
volumes:
lmkit-state:
lmkit-models:
Other containers on the Compose network reach the server at http://lmkit:5189; host clients
use the published port. Drop the deploy block on CPU-only hosts. The hostname: line (or
LMKIT_NODE_NAME in the environment) gives the node a name that survives docker compose up
recreating the container: without it the node is named after the container id, and a
fine-tuning run journaled under the previous id is listed under a node that no longer exists
until the heartbeat rule adopts it (Scaling Out section 6,
Fine-tuning).
7Kubernetes: one pod, or a fleet#
Two honest shapes, and the difference between them is exactly what is shared:
One pod keeps everything node-local: state and models on ReadWriteOnce PVCs, panel
edits persisted, Recreate strategy so a rollout never puts two writers on the same volumes.
This remains the right shape for a single box's worth of load.
apiVersion: apps/v1
kind: Deployment
metadata:
name: lmkit-one
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels: { app: lmkit-one }
template:
metadata:
labels: { app: lmkit-one }
spec:
containers:
- name: lmkit
image: lmkitone/lm-kit-one:<version> # a version tag, never latest, so a rollout is deliberate
ports:
- containerPort: 5189
resources:
limits:
nvidia.com/gpu: 1
livenessProbe:
httpGet: { path: /health, port: 5189 }
periodSeconds: 15
readinessProbe:
httpGet: { path: /lmkit/v1/health/ready, port: 5189 } # anonymous, status only
periodSeconds: 15
failureThreshold: 4
volumeMounts:
- { name: state, mountPath: /data/state }
- { name: models, mountPath: /data/models }
volumes:
- name: state
persistentVolumeClaim: { claimName: lmkit-state }
- name: models
persistentVolumeClaim: { claimName: lmkit-models }
The nvidia.com/gpu resource requires the
NVIDIA device plugin on the cluster; omit the
limit on CPU nodes. Backups in this shape are snapshots of the state PVC, taken with the pod
stopped for a clean copy, exactly as the runbook prescribes.
A fleet scales by replication: every pod is a complete engine with its own resident models, and what is shared is exactly three things plus the manifest: the admin-domain database, the upload volume, the model volume, and the configuration the manifest owns. The operator plane (dashboard, logs, request history) stays each pod's own view, and so do memory facts, panel-authored skills, custom-model records and the live sessions the pod created; the admin footer names the pod serving it. The platform-independent contract behind these manifests, the setup of the shared admin domain (Scaling Out section 3), managed configuration (section 4), what honestly stays per node (section 5) and which paths the ingress must pin (section 6) are all Scaling Out.
apiVersion: apps/v1
kind: Deployment
metadata:
name: lmkit-one
spec:
replicas: 2 # KEDA drives this from here (section 9)
strategy:
type: RollingUpdate
rollingUpdate: { maxSurge: 1, maxUnavailable: 0 } # warm joins + drains keep rollouts lossless
selector:
matchLabels: { app: lmkit-one }
template:
metadata:
labels: { app: lmkit-one }
annotations: # or a ServiceMonitor, where the Prometheus Operator runs
prometheus.io/scrape: "true"
prometheus.io/port: "5189"
prometheus.io/path: "/metrics"
spec:
terminationGracePeriodSeconds: 120 # above DrainAdvertiseSeconds + ShutdownDrainSeconds
containers:
- name: lmkit
image: lmkitone/lm-kit-one:<version>
ports:
- containerPort: 5189
env:
- name: LMKIT_MANAGED_CONFIG # the manifest owns configuration (Scaling Out section 4)
value: "1"
- name: LMKIT_NODE_NAME # the node name behind X-LMKit-Node and lmk-node (Scaling Out section 6);
valueFrom: # a Deployment's pod name changes on every rollout, a StatefulSet's does not
fieldRef:
fieldPath: metadata.name
- name: LMKIT_STATE_DIR
value: /data/state
- name: LMKIT_MODELS_DIR
value: /data/models
- name: FileManagement__UploadDirectory
value: /data/uploads
- name: Security__NetworkAccess
value: Network
- name: Security__TrustedProxies__0 # the ingress controller's pods ALONE, never the cluster's pod CIDR:
value: "10.244.7.0/24" # every address here may set X-Forwarded-For (see below)
- name: DefaultChatModel
value: "qwen3.5:4b"
- name: Inference__WarmupModels__0 # readiness waits for the lineup (section 9)
value: "default"
- name: Telemetry__Enabled
value: "true"
- name: Telemetry__EnablePrometheus
value: "true"
- name: Admin__Identity__Store # the shared admin domain (Scaling Out section 3)
value: postgres
- name: Admin__Identity__ConnectionString
valueFrom:
secretKeyRef: { name: lmkit-identity, key: connection-string }
- name: Search__Enabled # a fleet member never provisions the embedded cluster;
value: "false" # either switch Search off explicitly ...
- name: Search__AutoProvisionLocalCluster
value: "false"
# ... or declare one external cluster every pod reaches (replace the two rows above):
# - name: Search__Enabled
# value: "true"
# - name: Search__Clusters__0__Id
# value: main
# - name: Search__Clusters__0__IsDefault
# value: "true"
# - name: Search__Clusters__0__FullTextStore
# value: PostgreSql
# - name: Search__Clusters__0__ConnectionString
# valueFrom:
# secretKeyRef: { name: lmkit-search, key: connection-string }
resources:
limits:
nvidia.com/gpu: 1
livenessProbe:
httpGet: { path: /health, port: 5189 }
periodSeconds: 15
readinessProbe:
httpGet: { path: /lmkit/v1/health/ready, port: 5189 } # anonymous, status only
periodSeconds: 5 # the drain advertisement is 5s; probe inside it
failureThreshold: 2
volumeMounts:
- { name: state, mountPath: /data/state }
- { name: models, mountPath: /data/models }
- { name: uploads, mountPath: /data/uploads }
volumes:
- name: state
emptyDir: {} # the per-pod operator history (request trail, logs, forensics);
# memory facts, skills and custom-model records live in the shared admin database
# (Scaling Out section 3), and custom-model blobs on the shared model volume.
- name: models
persistentVolumeClaim: { claimName: lmkit-models-shared }
- name: uploads
persistentVolumeClaim: { claimName: lmkit-uploads-shared }
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: lmkit-models-shared
spec:
accessModes: [ReadWriteMany] # every pod reads; a pod pulling a model writes once
resources: { requests: { storage: 200Gi } }
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: lmkit-uploads-shared
spec:
accessModes: [ReadWriteMany]
resources: { requests: { storage: 50Gi } }
---
apiVersion: v1
kind: Service
metadata:
name: lmkit-one
spec:
selector: { app: lmkit-one }
ports:
- port: 5189
targetPort: 5189
Front the Service with your ingress and terminate TLS there; streamed responses need the
ingress-side buffering settings from Behind a Reverse Proxy, and the
affinity is two rules the same guide gives per ingress: cookie affinity on /admin,
/lmkit/v1/admin and /playground, and Authorization hashing on everything else, because a
job id is minted by whichever endpoint was asked for async work and skip-thinking targets the
exchange streaming on /lmkit/v1/chat, so a path list cannot pin them. Keep
Security__TrustedProxies__0 to the ingress controller's own pods (its namespace's range or
its pod selector's addresses) and add a NetworkPolicy that lets only the ingress reach the
Service: every address in that list may set X-Forwarded-For, so a wider range lets any pod in
it impersonate an allowlisted operator address or a loopback caller to the sign-in throttle,
the admin IP allowlist and the trail. The KEDA ScaledObject that grows and shrinks replicas is in section 9, and the
rest of the fleet's behavior is built in: a new pod reports warming until its lineup is
resident, a stopping pod advertises draining while it finishes its streams, two pods asked
for the same missing model pull it once (a cross-process lock on the shared model volume; the
second pod waits, then loads the file the first one fetched), and every pod refuses
configuration edits because the manifest owns them.
A pod carrying a fleet signal (managed configuration, or a shared identity store) never
provisions the embedded search cluster: with neither Search__Enabled nor a cluster declared,
Search stays disabled and the pod names the two remedies in its log and in the Search panel,
declare Search__Clusters on an external engine every pod reaches, or set
Search__Enabled=false to keep Search off deliberately. The manifest above takes the second
remedy and shows the first in comments; the embedded engine would be one pod's file on the
emptyDir state volume, diverging per pod (Storage Engines and Deployment).
8One admin domain across nodes#
The fleet manifest points every pod at one dedicated identity database, and that database is
what makes operators, sessions, API keys and stored responses one domain; the rules behind it
(the dedicated-database requirement, how the first operator is seeded, the cache windows, and
what happens while the database is unreachable) are Scaling Out
section 3. The recipe is the Admin__Identity__* rows in the section 7 manifest, or the same
keys in a mounted settings file:
{
"Admin": {
"Identity": {
"Store": "postgres",
"ConnectionString": "Host=db;Database=lmkit_identity;Username=lmkit;Password=..."
}
}
}
9Autoscaling with KEDA#
The serving signal, the probes, and the drain behavior are built in; KEDA supplies the scaling loop. Scaling is by replication: every pod is a complete engine with its own resident models, and the fleet grows or shrinks on inference pressure.
The signal. With telemetry and the Prometheus endpoint enabled, /metrics exports the
first-party gauges anonymously: lmkit_inference_slots_saturation (busy share of the
concurrent-completion budget, 0 to 1: scale out as it approaches 1, because the queue only
grows after it gets there), lmkit_inference_queue_depth, and lmkit_inference_active. On a
Prometheus-less stack, /lmkit/v1/ready serves the same number as slots_saturation (with
queue_depth beside it) for KEDA's metrics-api scaler, authenticated with an API key.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: lmkit-one
spec:
scaleTargetRef: { name: lmkit-one }
minReplicaCount: 1 # scale-to-zero is dishonest for minute-scale model loads
cooldownPeriod: 600 # models are expensive to load; shed capacity slowly
triggers:
- type: prometheus
metricType: Value # the query is already a fleet average; AverageValue would divide it by the replica count again
metadata:
serverAddress: http://prometheus.monitoring:9090
query: avg(lmkit_inference_slots_saturation)
threshold: "0.8" # add a pod while the fleet still absorbs the load
The metrics-api variant for stacks without Prometheus:
triggers:
- type: metrics-api
metricType: Value # one pod's reading, sampled through the Service; Prometheus averages properly
metadata:
url: http://lmkit-one.default:5189/lmkit/v1/ready
valueLocation: slots_saturation
targetValue: "0.8"
authenticationRef: { name: lmkit-api-key } # Bearer via a TriggerAuthentication
Warmup gates readiness. A pod KEDA adds is useless until its models are resident, so opt
in with Inference:WarmupModels (model ids, or default for the default chat model):
both readiness routes answer 503 warming until the lineup is loaded, the load balancer holds
traffic back, and a model that cannot load parks the pod visibly not-ready
(warmup_failed names it) instead of letting it serve refusals. Pair it with the model
delivery choice from section 3: image-baked or pre-warmed volumes make warmup fast;
pull-on-demand makes the first pod slow exactly once.
Scale-down drains. The moment a pod receives its stop signal, /ready flips to 503
draining and the listener stays open for a short advertise window (default 5 seconds on
Kubernetes and on the other platforms the server detects as routing by readiness; top-level
DrainAdvertiseSeconds overrides it, 0 disables): endpoint deregistration races the stop signal, and the window
serves the requests a load balancer routes before it observes the failed probe, so no
preStop sleep hook is needed. The listener then closes while in-flight requests, streamed
completions included, finish within ShutdownDrainSeconds (default 100); liveness stays
healthy throughout, so nothing kills the pod mid-drain. Set terminationGracePeriodSeconds
above advertise plus drain plus 15 seconds of teardown, 120 with the defaults; the server
cannot read the pod's value, so its startup warning assumes the platform default.
10Managed configuration#
A fleet's configuration belongs to the manifest, not to any one pod: LMKIT_MANAGED_CONFIG=1
(or top-level ManagedConfiguration: true in a mounted settings file) makes the deployment the
owner, and the exact split, refused versus editable, is Scaling Out
section 4. What is container-specific: settings come from double-underscore environment keys
or a ConfigMap-mounted file, the environment wins where both state a value, and the process
never writes appsettings.json, so a read-only mount and a read-only root filesystem both
work. Secrets belong in the environment or a secretKeyRef, never in the mounted file, because
the file is read as written and an exported configuration masks them.
Deployment__DomainSource=Manifest keeps the shared objects (agents, connectors, policies,
clusters) in the manifest as well; without it they live in the identity store and are edited
from any pod's panel (Scaling Out section 4).
env:
- name: LMKIT_MANAGED_CONFIG
value: "1"
- name: LMKIT_STATE_DIR
value: /state
- name: Inference__WarmupModels__0
value: "default"
11First-run refusals#
Five answers meet most first container runs. Each names its cause; this is where they are read together.
| What the caller sees | What it means | What to do |
|---|---|---|
403 on /lmkit/v1/admin/... saying the admin interface requires a secure connection |
The call reached the container through a published port, so the client is a network peer and the admin surface takes HTTPS only (section 1) | Call https://<host>:7221 (the self-signed certificate), or list a TLS-terminating proxy in Security__TrustedProxies; on a trusted private network, -e Security__RequireHttpsForAdmin=false |
401 on any API route |
The container runs in Network posture, where every call needs a key (section 2) | Mint a key in Access and send it as Authorization: Bearer |
400 saying the API key has no Search access |
The key was minted without Search access (None chosen in the create dialog, or clusterGrants omitted through the API) and no cluster has been granted since; the key's row in Access says Search access none |
Grant it a cluster from that row (or mint the key with the cluster chosen); an omitted cluster_id then resolves to the default cluster (Access Model) |
503 model_store_read_only, or a job failed with that reason |
/data/models is mounted read-only; the model the request needs is not downloaded, or a training job has no writable place for its files (section 3). The startup log, the dashboard status and lmkit doctor report the same |
Mount the model volume writable, or keep it read-only with every model pulled beforehand and ModelWorkDirectory pointing at a writable path for training jobs and imported models |
RUNNING ON CPU, no device listed |
The GPU did not reach the container | Section 4 reads the symptoms |
12Stated plainly#
- The official image is
lmkitone/lm-kit-oneon Docker Hub andghcr.io/lm-kit/lm-kit-one, one tag for both architectures, Ubuntu 24.04 LTS underneath. Windows and macOS run it through Docker Desktop, and a site that needs more extends it with aFROMline instead of rebuilding from the archive. - Inside a container the posture is Network, so every API call needs a key;
/healthstays anonymous for probes,/readydoes not. The admin panel is HTTPS-only from any published port: reach it on 7221, or through a TLS-terminating ingress listed as a trusted proxy. The first sign-in isadminwith the one-time password the startup log printed, unlessAdmin__InitialPasswordseeded one. - The image pins
LMKIT_STATE_DIRandLMKIT_MODELS_DIRonto volumes; the state volume is the installation, the model volume is a bandwidth saver, and baking models instead is the air-gapped play. The model volume may be mounted read-only once every model is pulled beforehand andModelWorkDirectorygives training jobs and imported models a writable home. - ffmpeg rides inside the image, so transcription accepts video containers and every audio codec with nothing installed on the host, and the transcription capabilities route says so.
- CUDA passthrough via the NVIDIA Container Toolkit (or Docker Desktop's WSL2 on Windows) works
with nothing installed beyond what the image ships: the CUDA 13 and CUDA 12 runtimes ride
inside. Podman needs the toolkit's CDI specification generated first and, on an SELinux host,
--security-opt=label=disable; section 4 reads the symptoms of a GPU that did not reach the container. AMD and Intel GPUs serve through Vulkan on a Linux host with--device /dev/dri: the loader and Mesa's drivers ride inside too. - Operator identity scales across installations through a dedicated shared database
(
Admin:Identity); document content and identity never share one, and the server refuses a configuration that mixes them. - Scaling is by replication: each pod is a complete engine with its own resident models.
Sharing the admin-domain database and the upload volume makes any pod serve the stateless
surfaces and the shared records (grounded chat sessions, MCP sessions, async jobs, memory
facts, skills and custom-model records live in that database); chat-with-document sessions
and the MCP event stream stay on the pod that opened them, so the ingress hashes everything
outside the console paths by
Authorizationand cookie-pins the console, and the operator plane stays per pod (Scaling Out sections 5 and 6). KEDA grows the fleet on the saturation gauge (section 9), and the admin footer names the pod. - A fleet member never provisions the embedded search cluster: declare an external cluster or
set
Search__Enabled=falsewithSearch__AutoProvisionLocalCluster=false. - A fleet runs
LMKIT_MANAGED_CONFIG=1: the manifest owns configuration and every configuration write answers 409 (Scaling Out section 4); a single pod with a persistent state volume may keep panel edits instead.