Sam Rose

Development environment bootstrapping for an inference platform

September 2026. Part 1 of Building an LLM inference platform. Code: tag post-1.

I created this series to document the process of building a self-hosted LLM serving platform on Kubernetes. The series is five parts, with the repo updated alongside each one. This first part sets up the foundation: the tooling, a local cluster, and a stand-in for the inference engine.

This series' goal is not just being a guide or blog post. My aim is to provide a very comprehensive, reproducible approach to building a production-ready LLM inference platform.

The code is at github.com/samrose/inference-platform, and each post has a tag.

What this part covers

Most of an LLM inference infrastructure platform can be built and tested on a laptop without a GPU. The chart, probes, metrics scraping, dashboards, alerts and autoscaling only depend on how the engine behaves at its edges: the network port it listens on, what the health endpoint returns while weights load, and what the metrics are called. This post covers setting up a local tier with a stand-in engine that gets those right.

Pinned tooling

The repo I created for this inference platform needs about a dozen CLIs (kubectl, helm, kind, kubeconform, OpenTofu, argocd, just, python, uv etc). I pin them with a Nix flake for local development, and later for continuous integration. The flake provides reproducible versions for both environments, and the flake.lock is committed. So bumping a version of one of these tools is a reviewable diff (ADR 0001). Nix is only used for tooling here. The production OCI images are built with Docker and charts with Helm. If you don't want Nix, install the versions below some other way. Docker is the one thing the flake doesn't provide (I use OrbStack on a Mac).

$ nix develop
inference-platform dev shell
  kubectl  v1.37.0
  helm     v4.3.0
  kind     v0.32.0
  tofu     v1.12.6
  argocd   refs/tags/v3.4.6

I selected OpenTofu rather than Terraform because of the 2023 license change, since this is a public repo (ADR 0002). Helm 4 and kind 0.32 were chosen because Helm 3 reaches end of life in 2027 and this is a new repo.

kubectl is v1.37.0 and the cluster below is v1.36.1. They're pinned in different files on purpose. The gap is within Kubernetes' version skew policy, which supports a kubectl one minor version either side of the API server. Making them match would add a flake override to maintain and gain nothing. The dev shell prints versions on entry so that this kind of skew is visible.

A disposable cluster from one command

Every local operation is a recipe in the justfile (just is a command runner if you are not familiar with it). The KUBECONFIG is set to local/kubeconfig in both the justfile and the dev shell, so nothing here can touch a cluster in ~/.kube/config.

just up creates a 3 node kind cluster (one control plane and two workers, so that scheduling across nodes and drains can be tested later), with the node image pinned by digest from the kind v0.32.0 release notes. This command also starts a registry:2 container on 127.0.0.1:5001, and connects it to the kind Docker network + configures containerd on each node to pull localhost:5001 images from it. I decided to run a local registry over kind load docker-image so that the local workflow is build, push, reference by digest. The benefit is that the workflow will be the same as it will be in the cloud (ADR 0003).

just down removes the cluster and keeps the registry, just nuke removes both. just up takes about a minute and is safe to run repeatedly:

$ just up
cluster 'inference' already exists
just _connect-registry
configmap/local-registry-hosting unchanged
kubectl wait --for=condition=Ready nodes --all --timeout=120s
node/inference-control-plane condition met
node/inference-worker condition met
node/inference-worker2 condition met
just status
kubectl get nodes -o wide
NAME                      STATUS   ROLES           AGE   VERSION   INTERNAL-IP     EXTERNAL-IP   OS-IMAGE                       KERNEL-VERSION                                CONTAINER-RUNTIME
inference-control-plane   Ready    control-plane   28h   v1.36.1   192.168.148.2   <none>        Debian GNU/Linux 13 (trixie)   7.0.14-orbstack-00380-ga7e0a2dc9535 (arm64)   containerd://2.3.1
inference-worker          Ready    <none>          28h   v1.36.1   192.168.148.4   <none>        Debian GNU/Linux 13 (trixie)   7.0.14-orbstack-00380-ga7e0a2dc9535 (arm64)   containerd://2.3.1
inference-worker2         Ready    <none>          28h   v1.36.1   192.168.148.3   <none>        Debian GNU/Linux 13 (trixie)   7.0.14-orbstack-00380-ga7e0a2dc9535 (arm64)   containerd://2.3.1
registry: localhost:5001 (running)
justfile
# justfile — one entrypoint for every local operation.
# Run `just` to list recipes.

set shell := ["bash", "-euo", "pipefail", "-c"]
set dotenv-load := true

# ---- configuration (override with `just VAR=value recipe` or a .env file) ----
cluster      := env_var_or_default("CLUSTER_NAME", "inference")
reg_name     := env_var_or_default("REGISTRY_NAME", "kind-registry")
reg_port     := env_var_or_default("REGISTRY_PORT", "5001")
kind_config  := "local/kind-config.yml"
kubeconfig   := "local/kubeconfig"
chart        := "charts/inference-service"

export KUBECONFIG := kubeconfig

# default: show available recipes
default:
    @just --list --unsorted

# ---- lifecycle ----------------------------------------------------------------

# create the local registry, the kind cluster, and wire them together
up: registry
    @if kind get clusters | grep -qx "{{cluster}}"; then \
        echo "cluster '{{cluster}}' already exists"; \
    else \
        kind create cluster --name "{{cluster}}" --config "{{kind_config}}" --kubeconfig "{{kubeconfig}}"; \
    fi
    just _connect-registry
    kubectl wait --for=condition=Ready nodes --all --timeout=120s
    just status

# delete the cluster (registry is left running; `just nuke` removes both)
down:
    kind delete cluster --name "{{cluster}}"
    rm -f "{{kubeconfig}}"

# delete the cluster and the registry container
nuke: down
    docker rm -f "{{reg_name}}" >/dev/null 2>&1 || true

# start the local OCI registry if it isn't running
registry:
    @if [ "$(docker inspect -f '{{{{.State.Running}}' "{{reg_name}}" 2>/dev/null)" != "true" ]; then \
        docker run -d --restart=always -p "127.0.0.1:{{reg_port}}:5000" \
            --network bridge --name "{{reg_name}}" registry:2; \
    fi

# tell containerd on every node about the registry and advertise it to tooling
_connect-registry:
    #!/usr/bin/env bash
    set -euo pipefail
    reg_dir="/etc/containerd/certs.d/localhost:{{reg_port}}"
    for node in $(kind get nodes --name "{{cluster}}"); do
        docker exec "$node" mkdir -p "$reg_dir"
        cat <<EOF | docker exec -i "$node" cp /dev/stdin "$reg_dir/hosts.toml"
    [host."http://{{reg_name}}:5000"]
    EOF
    done
    if [ "$(docker inspect -f='{{{{json .NetworkSettings.Networks.kind}}' "{{reg_name}}")" = "null" ]; then
        docker network connect kind "{{reg_name}}"
    fi
    kubectl apply -f - <<EOF
    apiVersion: v1
    kind: ConfigMap
    metadata:
      name: local-registry-hosting
      namespace: kube-public
    data:
      localRegistryHosting.v1: |
        host: "localhost:{{reg_port}}"
        help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
    EOF

# cluster and registry health at a glance
status:
    kubectl get nodes -o wide
    @echo "registry: localhost:{{reg_port}} ($(docker inspect -f '{{{{.State.Status}}' "{{reg_name}}" 2>/dev/null || echo absent))"

# ---- verification ---------------------------------------------------------------

# prove the registry round-trips: push an image, run it in the cluster
# (no --platform: Docker pulls the host arch, which is what the kind nodes run)
smoke:
    #!/usr/bin/env bash
    set -euo pipefail
    img="localhost:{{reg_port}}/busybox:1.36"
    docker pull busybox:1.36
    docker tag busybox:1.36 "$img"
    docker push "$img"
    kubectl delete pod smoke --ignore-not-found >/dev/null
    kubectl run smoke --restart=Never --image="$img" -- echo "registry round-trip OK"
    if ! kubectl wait pod/smoke --for=jsonpath='{.status.phase}'=Succeeded --timeout=60s; then
        echo "--- smoke pod did not succeed; describe follows ---"
        kubectl describe pod smoke | sed -n '/Events:/,$p'
        kubectl delete pod smoke --ignore-not-found >/dev/null
        exit 1
    fi
    kubectl logs smoke
    kubectl delete pod smoke >/dev/null
# ---- chart quality gates (filled in as step 1 progresses) -----------------------

# static checks: lint, render, validate against the cluster's API schemas
lint:
    helm lint "{{chart}}"
    helm template test "{{chart}}" | kubeconform -strict -summary -kubernetes-version "$(just _k8s-version)"

# unit tests for the chart's templates
test:
    helm unittest "{{chart}}"

# everything CI runs
check: lint test

# ---- helpers --------------------------------------------------------------------

_k8s-version:
    @kubectl version -o json | jq -r '.serverVersion.gitVersion' | sed 's/^v//'

# ---- mock engine ------------------------------------------------------------------

mock_image := "localhost:" + reg_port + "/mock-engine"

# build the mock engine for the local arch and push it; records the digest
mock-push:
    #!/usr/bin/env bash
    set -euo pipefail
    tag="{{mock_image}}:dev"
    docker build -t "$tag" mock-engine
    docker push "$tag"
    digest=$(docker inspect --format '{{{{index .RepoDigests 0}}' "$tag" | cut -d@ -f2)
    echo "$digest" > local/mock-engine.digest
    echo "pushed {{mock_image}}@$digest"

# run the mock engine in the cluster by digest and exercise every endpoint
mock-smoke:
    #!/usr/bin/env bash
    set -euo pipefail
    img="{{mock_image}}@$(cat local/mock-engine.digest)"
    kubectl delete pod mock --ignore-not-found >/dev/null
    kubectl run mock --restart=Never --image="$img" --port=8000 \
        --env STARTUP_DELAY_SECONDS=5 --env TTFT_MS=100 --env TPOT_MS=10
    kubectl wait pod/mock --for=condition=Ready --timeout=60s
    kubectl port-forward pod/mock 18000:8000 >/dev/null 2>&1 & pf=$!
    trap 'kill $pf; kubectl delete pod mock >/dev/null' EXIT
    sleep 2
    echo "--- health (expect 503 until startup delay elapses, then 200)"
    for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w "%{http_code}\n" localhost:18000/health; sleep 1; done
    echo "--- streamed completion"
    curl -sN localhost:18000/v1/chat/completions -H 'content-type: application/json' \
        -d '{"model":"mock/mock-8b","stream":true,"max_tokens":5,"messages":[{"role":"user","content":"hi"}]}'
    echo "--- admin: force queue depth"
    curl -s -X POST localhost:18000/admin/state -H 'content-type: application/json' -d '{"waiting": 12, "kv_cache_usage": 0.85}'
    echo; echo "--- metrics (vllm:* only)"
    curl -s localhost:18000/metrics | grep '^vllm:' | grep -v '_bucket'
local/kind-config.yml
# local/kind-config.yml — the local tier's cluster definition.
# Pinning: the node image is pinned by digest; change it deliberately via an ADR.
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: inference

# Tell containerd on every node to read per-registry config from certs.d.
# `just _connect-registry` writes /etc/containerd/certs.d/localhost:5001/hosts.toml
# into each node so pulls from localhost:5001 resolve to the kind-registry container.
containerdConfigPatches:
  - |-
    [plugins."io.containerd.grpc.v1.cri".registry]
      config_path = "/etc/containerd/certs.d"

nodes:
  - role: control-plane
    # Take the full image string for your chosen Kubernetes minor from the
    # kind v0.32.0 release notes ("Images built for this release").
    image: kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5
    # Label the control plane so a gateway/ingress can be pinned to it later.
    kubeadmConfigPatches:
      - |
        kind: InitConfiguration
        nodeRegistration:
          kubeletExtraArgs:
            node-labels: "ingress-ready=true"
    # Host ports for the gateway (step 5). Unused until then; harmless now.
    extraPortMappings:
      - containerPort: 80
        hostPort: 8080
        protocol: TCP
      - containerPort: 443
        hostPort: 8443
        protocol: TCP

  - role: worker
    image: kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5

  - role: worker
    image: kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5

The first bug encountered

The first bug I encountered writing this dev env was related to how just handles its own variables. just uses {{ }} for its own variables, and so does docker inspect --format. To pass a literal {{ through just you write {{{{, and the closing }} is left alone. I escaped both sides:

if [ "$(docker inspect -f='{{{{json .NetworkSettings.Networks.kind}}}}' "{{reg_name}}")" = "null" ]; then
    docker network connect kind "{{reg_name}}"
fi

Docker receives {{json .NetworkSettings.Networks.kind}}}} and treats the trailing }} as text, so for a container that is not on the kind network it prints null}} instead of null. The comparison fails and docker network connect never runs. No error, exit code 0, and just up looks healthy.

just smoke then timed out. The recipe used kubectl run --rm -i, which reports a bare timeout when a pod never starts and deletes the pod afterwards, so there was nothing to inspect. A wrong image, wrong architecture, or unreachable registry would all have looked identical.

The pod events had the answer. This is what they look like with the registry disconnected from the kind network:

error: timed out waiting for the condition on pods/smoke-repro
--- smoke pod did not succeed; describe follows ---
Events:
  Type     Reason     Age                From               Message
  ----     ------     ----               ----               -------
  Normal   Scheduled  45s                default-scheduler  Successfully assigned default/smoke-repro to inference-worker
  Normal   BackOff    21s (x2 over 44s)  kubelet            Back-off pulling image "localhost:5001/busybox:repro-1789847397"
  Warning  Failed     21s (x2 over 44s)  kubelet            Error: ImagePullBackOff
  Normal   Pulling    10s (x3 over 45s)  kubelet            Pulling image "localhost:5001/busybox:repro-1789847397"
  Warning  Failed     10s (x3 over 45s)  kubelet            Failed to pull image "localhost:5001/busybox:repro-1789847397": failed to pull and unpack image "localhost:5001/busybox:repro-1789847397": failed to resolve reference "localhost:5001/busybox:repro-1789847397": failed to do request: Head "http://kind-registry:5000/v2/busybox/manifests/repro-1789847397?ns=%5BREDACTED%5D": dial tcp: lookup kind-registry on 0.250.250.254:53: no such host
  Warning  Failed     10s (x3 over 45s)  kubelet            Error: ErrImagePull

The long line ends with:

failed to do request: Head "http://kind-registry:5000/v2/busybox/manifests/repro-1789847397?ns=%5BREDACTED%5D": dial tcp: lookup kind-registry on 0.250.250.254:53: no such host

The pod events showed the pull failing because the node couldn't resolve kind-registry by name.

The fix was removing two characters. I also changed smoke so it waits 60 seconds for the pod and prints the events if it fails:

if ! kubectl wait pod/smoke --for=jsonpath='{.status.phase}'=Succeeded --timeout=60s; then
    echo "--- smoke pod did not succeed; describe follows ---"
    kubectl describe pod smoke | sed -n '/Events:/,$p'
    kubectl delete pod smoke --ignore-not-found >/dev/null
    exit 1
fi

A timeout told me something was wrong but not what. We really need failing checks in this repo to say why, and the same consideration will apply to probes and alerts later on in this series and platform I create.

A mock engine

To build everything above the engine without a GPU, I need something that behaves like vLLM at the points where the platform touches it:

  • OpenAI-compatible /v1/chat/completions with SSE streaming, and /v1/models, on port 8000 with the same paths as vllm serve, so the chart needs no mock-specific branches
  • /health returns 503 until the weights are loaded, then 200. The mock takes a STARTUP_DELAY_SECONDS
  • /metrics with vLLM's metric names, labels and histogram buckets, so PromQL written against the mock works unchanged against the real engine

mock-engine/ is a FastAPI app of about 190 lines that does this (ADR 0004).

I considered vLLM on CPU with a small model. The mock starts in under a second and answers in milliseconds, so the feedback loop is instant. It emits a deliberately chosen subset of vLLM's metrics, with the same names, labels, and buckets, so every query written against it works unchanged in production. To test autoscaling on queue depth I need to set the queue to 12 and see what happens. So the mock has POST /admin/state, which sets queue depth and KV cache usage. Unlike queue depth and cache usage, which the admin endpoint fakes, the num_requests_running gauge is genuinely counted: it goes up when a request arrives and down when the response finishes. So concurrent requests to the mock show up in that metric the same way they would on a real engine.

One detail worth noting: prometheus_client adds a _created series for every counter and histogram by default, and vLLM's output doesn't have them. The Dockerfile sets PROMETHEUS_DISABLE_CREATED_SERIES=true so that the mock's metrics stay a subset of the real engine's, otherwise a dashboard could end up depending on a series that isn't there in production.

just mock-push builds and pushes the image and records its digest. just mock-smoke runs it in the cluster with a 5 second startup delay and hits each endpoint:

$ just mock-smoke
pod/mock created
pod/mock condition met
--- health (expect 503 until startup delay elapses, then 200)
503
503
503
503
200
200
--- streamed completion
data: {"id": "chatcmpl-50193b636528", "object": "chat.completion.chunk", "created": 1789847337, "model": "mock/mock-8b", "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": null}]}

data: {"id": "chatcmpl-50193b636528", "object": "chat.completion.chunk", "created": 1789847337, "model": "mock/mock-8b", "choices": [{"index": 0, "delta": {"content": "tok0 "}, "finish_reason": null}]}

[tok1 through tok4 omitted]

data: {"id": "chatcmpl-50193b636528", "object": "chat.completion.chunk", "created": 1789847337, "model": "mock/mock-8b", "choices": [{"index": 0, "delta": {}, "finish_reason": "length"}]}

data: [DONE]

--- admin: force queue depth
{"ready":true,"waiting":12,"kv_cache_usage":0.85}
--- metrics (vllm:* only)
vllm:num_requests_running{engine="0",model_name="mock/mock-8b"} 0.0
vllm:num_requests_waiting{engine="0",model_name="mock/mock-8b"} 12.0
vllm:kv_cache_usage_perc{engine="0",model_name="mock/mock-8b"} 0.85
vllm:gpu_cache_usage_perc{engine="0",model_name="mock/mock-8b"} 0.85
vllm:time_to_first_token_seconds_count{engine="0",model_name="mock/mock-8b"} 1.0
vllm:time_to_first_token_seconds_sum{engine="0",model_name="mock/mock-8b"} 0.10264143301174045
vllm:time_per_output_token_seconds_count{engine="0",model_name="mock/mock-8b"} 5.0
vllm:time_per_output_token_seconds_sum{engine="0",model_name="mock/mock-8b"} 0.05
vllm:e2e_request_latency_seconds_count{engine="0",model_name="mock/mock-8b"} 1.0
vllm:e2e_request_latency_seconds_sum{engine="0",model_name="mock/mock-8b"} 0.15268072301114444
vllm:prompt_tokens_total{engine="0",model_name="mock/mock-8b"} 1.0
vllm:generation_tokens_total{engine="0",model_name="mock/mock-8b"} 5.0
vllm:request_success_total{engine="0",finished_reason="length",model_name="mock/mock-8b"} 1.0

Note pod/mock condition met followed by four 503s. The pod is Ready before the engine is, because a pod from kubectl run has no probes. With a real engine that gap is as long as the weights take to load, and the pod would be receiving traffic during it. The chart in part 2 fixes that.

What this mock engine can't tell you

The mock set up doesn't batch, it doesn't model latency under load, and it doesn't tokenize. The time to first token and time per output token aren't measured; they're whatever numbers I set in environment variables, and the mock just sleeps for that duration. No performance conclusions can come from it. Benchmarks come later, from the real engine on real hardware.

The local tier is arm64 on my machine and cloud GPU nodes are amd64. just mock-push builds for the host only, so the digest in local/mock-engine.digest won't work in the cloud. Multi-arch builds come with the cloud tier (ADR 0005).

Next

In Part 2 I will provide the Helm chart to hold the values file an app team will use to put a model into service onto Kubernetes. In addition I'll include startup probe timing, chart tests that run without a cluster, and a rolling update under load.