Verified chat over confidential vLLM

Build a small chat client that verifies a c8s Load Balancer's TEE attestation with c8s-verify, then talks to a downstream vLLM running confidentially in the cluster — over a post-quantum over-encrypted channel.

This tutorial builds a tiny chat client that talks to a vLLM model server running inside a c8s cluster — but only after cryptographically verifying it's a genuine TEE. Using c8s-verify, the client fetches the Load Balancer's attestation, checks it against a pinned measurement and your cluster's mesh CA, and only then opens a post-quantum over-encrypted channel to the LB enclave. Every prompt and completion is sealed end-to-end — a TLS-terminating proxy in front of the LB sees only ciphertext.

You do not need to read anything first. If you want the design behind what you are about to do, the verification model has it — afterwards.

What you verify here is the LB, plus that it belongs to your cluster (its CDS-issued cert chains to your pinned mesh CA). By transitivity of trust, an attested LB only forwards to attested pods over the RA-TLS mesh — so verifying the LB transitively covers the vLLM backend behind it.

Prerequisites

Step by step

1. Deploy a confidential vLLM backend

Run vLLM as a confidential workload. The confidential.ai/cw: vllm annotation opts the pod in — the value vllm is the workload id. The c8s webhook injects two get-cert containers, the c8s-cert identity sidecar and a c8s-cert-wait gate that holds your container until the first CDS-issued certificate lands (and, under --cvm-mode=pod, the confidential RuntimeClass), and the operator mints a headless Service c8s-vllm.<NAMESPACE>.svc. Headless DNS returns pod IPs, which the node mesh wraps in attested mTLS. We serve facebook/opt-125m, a tiny model that's quick to load on CPU.

This tutorial uses a CPU build of vLLM.

vLLM's published vllm/vllm-openai image is a CUDA build that won't start without a GPU, and vLLM ships no official prebuilt CPU image — so build one from vLLM's CPU Dockerfile, push it to a registry your cluster can pull, and reference that image below (pinning its digest in Step 2).

Running the GPU build instead? Your cluster must be GPU-provisioned for confidential passthrough before c8s installs — GPUs bound to vfio-pci, GPU CC mode on, BAR resize done (this is host provisioning's job; do not install the NVIDIA GPU Operator — it assumes host-visible GPUs with a host driver and conflicts with the passthrough model). Then use the CUDA image and add a GPU resource limit with the per-model name your node advertises (kubectl describe node <GPU_NODE> | grep nvidia.com), e.g. nvidia.com/GB202GL_RTX_PRO_6000_BLACKWELL_SERVER_EDITION: 1 — the webhook injects the confidential GPU RuntimeClass from the GPU request alone. Don't set a memory limit on GPU pods, and expect one vCPU per GPU pod on SEV-SNP — see Kata containers.

# vllm.yaml
apiVersion: apps/v1
kind: Deployment
metadata: { name: vllm, namespace: workloads }
spec:
  replicas: 1
  selector: { matchLabels: { app: vllm } }
  template:
    metadata:
      labels: { app: vllm }
      annotations: { confidential.ai/cw: vllm }   # workload id → headless Service c8s-vllm
    spec:
      containers:
        - name: vllm
          image: <YOUR_REGISTRY>/vllm-cpu:v0.6.3   # your CPU build (see the callout above)
          args: ["--model", "facebook/opt-125m"]   # tiny test model
          ports: [{ containerPort: 8000 }]         # vLLM's OpenAI server port
kubectl create namespace workloads
kubectl apply -f vllm.yaml

You don't create a Service yourself — the operator mints the c8s-vllm headless Service from the annotation. (The pod won't actually start until its image is allowlisted, next.)

2. Allow the vLLM image

Nothing runs unless its image digest is on the allowlist — otherwise enforcement blocks the container at creation. Resolve the digest of the exact image you deployed:

crane digest <YOUR_REGISTRY>/vllm-cpu:v0.6.3
# sha256:…

Add it to the live allowlist with c8s allowlist, signed by the operator key you created when installing the cluster (operator.key — its public half is what you pinned with --operator-keys).

Where you point --url depends on your front door. The chart publishes the complete /allowlist API through tls-lb by default, so on a cluster whose front door still carries its CDS-issued certificate you use the same URL as application traffic. Not here: the front door you build in Step 3 terminates a real hostname with a WebPKI certificate, which isn't yet bound to the discovery attestation — so the CLI deliberately refuses it. Talk to the CDS directly instead. It has no public ingress, so port-forward it:

kubectl port-forward -n c8s-system svc/c8s-cds 8443:8443 &

c8s allowlist add sha256:<VLLM_IMAGE_DIGEST> <YOUR_REGISTRY>/vllm-cpu:v0.6.3 \
  --url https://localhost:8443 \
  --measurements <CDS_LAUNCH_DIGEST> \
  --operator-key operator.key
# added sha256:<VLLM_IMAGE_DIGEST>

That's the only port-forward you need — the CLI verifies the CDS's RA-TLS attestation in-process, so nothing else has to be reachable. --measurements is the CDS launch digest; on a node-as-CVM cluster that's the node measurement every pod on the node reports, which c8s verify prints for you. Omit it and you get:

warning: no --measurements set; accepting any attested endpoint build (UNSAFE)

Fine for a tutorial cluster, but pin it in production so a write can never land on a rogue CDS. The enforcement plugins poll the CDS-served list, so the vLLM pod from Step 1 starts on its next retry:

kubectl get pods -n workloads -w
# vllm-…   2/2   Running   ← the vLLM container plus the injected c8s-cert sidecar

Prefer a version-controlled allowlist? The bootstrap path pins digests in a values file instead (nriImagePolicy.bootstrapAllowlist.digests), baking them into the boot-time floor the chart seeds into the CDS — better suited to GitOps flows. See Seeding and bootstrap and Managing it with the c8s CLI.

3. Point the Load Balancer at vLLM

You don't hand-edit nginx. The chart wires the front door — tls-lb (public TLS + over-encryption) → your engine, over the node mesh's attested mTLS — and you aim it at your vLLM workload with --workload-ref + --upstream. The attestation and over-encryption endpoints your client needs are served by default, so there's nothing extra to switch on. Apply the routing with one upgrade — re-run your original c8s install with the two flags added:

c8s install \
  --single-node \
  --cvm-mode aks \
  --operator-keys operator.pub \
  --workload-ref vllm=workloads/deployment/vllm:8000 \
  --upstream vllm

Repeat the flags you first installed with — --cvm-mode is required on every install, and if you pass a -f values.yaml too, the values these flags derive win on the keys they set.

The ref's :8000 (vLLM's default port) derives tlsLb.upstream = c8s-vllm.workloads.svc.cluster.local:8000, so tls-lb dials vLLM's headless Service directly and that hop rides the node mesh's attested mTLS — hence no nginx surgery. The confidential.ai/cw: vllm stamp the ref applies is a no-op — Step 1's manifest already carries it. A request your client sends over the over-encryption tunnel (Step 6) is decrypted inside the LB enclave and forwarded over the mesh to vLLM's OpenAI-compatible /v1/chat/completions.

Giving that front door a public address is a separate, cluster-specific choice, and it's made in a values file rather than with flags: tlsLb.service.type: LoadBalancer for a public IP on AKS, tlsLb.san for the hostnames nginx answers on, and tlsLb.publicTLS.secretName for the certificate it presents. Pass the file with -f values.yaml alongside the flags above. The outer TLS layer is untrusted transport either way — Step 5's attestation is what establishes trust — but a WebPKI certificate is what lets a browser, or a stock Node client, reach https://chat.example.com without relaxing its own PKI check.

4. Gather the two pinned values

Verification is meaningless without pinning your cluster's identity out of band:

  • LB launch measurement — the SHA-384 launch digest of the LB enclave. Use the published or recomputed digest (see Obtaining launch measurements). On a node-as-CVM cluster this is the node's measurement, the same value the operator passed to c8s install --measurements — and the same one you pinned for the CDS in Step 2.
  • Mesh CA certificate — your cluster's CDS mesh CA (PEM). Capture it from a trusted context at install time (the CDS publishes its public CA bundle at GET /ca). This is the cluster-unique anchor — why.
export LB_MEASUREMENT=<LB_LAUNCH_DIGEST>
# plus mesh-ca.pem, captured out of band from your cluster operator / CDS bootstrap

Both values come to you out of band on purpose. Reading them off the endpoint you are about to verify would prove nothing.

5. Verify and connect

Create a C8sClient with the LB origin and your pinned values, then connect(). That generates a nonce, fetches the LB attestation, verifies the attestation evidence in WASM, checks the measurement and the report_data binding, confirms the CDS cert chains to your mesh CA, and runs the X25519 + ML-KEM-768 handshake — all fail-closed:

import { C8sClient } from "c8s-verify";
import { readFileSync } from "node:fs";

const client = new C8sClient({
  baseUrl: "https://chat.example.com",            // your c8s LB
  measurements: [process.env.LB_MEASUREMENT],     // pinned LB launch digest
  meshCaPem: readFileSync("mesh-ca.pem", "utf8"), // pinned cluster anchor
});

const session = await client.connect();           // throws C8sVerifyError on any failure
console.error(`✓ verified LB enclave — measurement ${session.attestation.measurement}`);

If anything is off — wrong measurement, tampered evidence, or a cert that doesn't chain to your mesh CA — connect() throws and no channel is opened. See what it verifies.

6. Chat over the sealed channel

session.fetch seals the entire request and sends it down the tunnel; the LB enclave decrypts it, forwards it to vLLM over the mesh, and seals the response back. Call vLLM's OpenAI-compatible endpoint — the model must match what you served in Step 1:

async function ask(session, messages) {
  const res = await session.fetch("/v1/chat/completions", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ model: "facebook/opt-125m", messages }),
  });
  if (res.status !== 200) throw new Error(`vLLM returned HTTP ${res.status}: ${res.text()}`);
  return JSON.parse(res.text()).choices[0].message.content;
}

Wrap it in a REPL for an actual chat loop (chat.mjs):

import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";

const rl = createInterface({ input, output });
const history = [];
for (;;) {
  const prompt = await rl.question("you> ");
  if (!prompt || prompt === "/exit") break;
  history.push({ role: "user", content: prompt });
  const reply = await ask(session, history);       // session from Step 5
  history.push({ role: "assistant", content: reply });
  console.log(`bot> ${reply}\n`);
}
rl.close();

Run it:

LB_MEASUREMENT=$LB_MEASUREMENT node chat.mjs

Keep "stream": true out of these requests. The tunnel seals one whole request envelope and one whole response envelope, so a token stream can't be delivered incrementally through it. Streaming does work through tls-lb's ordinary route — nginx forwards Server-Sent Events straight through rather than buffering them — but that path gives you TLS to the front door, not a channel sealed to the enclave. Pick per endpoint: sealed for the prompts that matter, streamed for the ones where latency does.

What you've proven

By the time the first reply comes back, you've established — from an untrusted client, over an untrusted network — that:

  1. the LB you reached is a genuine TEE running the exact measured image you pinned;
  2. it belongs to your cluster (its cert chains to your mesh CA), not an attacker's look-alike;
  3. the session key was minted inside that enclave (the report_data binding), so the over-encrypted channel terminates there and nowhere else;
  4. by transitivity, the vLLM backend it forwards to is itself an attested pod on the c8s mesh.

Your prompts and the model's completions are sealed end-to-end to the enclave — the TLS terminator, the host, and the infrastructure operator never see plaintext.

Where to go next

  • Verify without the tunnel — the bare-evidence path and the full client API are in Consumer verification; the same check from a shell or CI is c8s verify.
  • A bigger serving graphNVIDIA Dynamo runs frontend, router, worker, and discovery as confidential workloads, and KServe does the same for InferenceService model serving.
  • Automate the digests — wire c8s allowlist add into the pipeline that builds your images: Automating the allowlist.