Verified chat over confidential vLLM

Deploy an offline vLLM backend and send chat requests through an attestation-verified, encrypted session with your c8s router.

Build a Node chat client that verifies your c8s router and sends each prompt through the resulting encrypted session. The router runs in a Trusted Execution Environment (TEE), then forwards requests over the private mesh to vLLM's ordinary HTTP listener. The client protects application traffic even when an intervening proxy terminates the outer HTTPS layer.

This tutorial uses your own cluster and route. The verification model explains the trust chain from the router to its admitted backend.

Prerequisites

Prepare these inputs before starting:

  • A running cluster from Your first confidential cluster, with the default c8s release, c8s-system namespace, and admitted platform components. Keep its default router TLS mode and certificate name c8s-router.c8s-router.svc.
  • c8s, kubectl, and Node 20.19 or newer on your workstation, plus a built c8s-verify package implementing c8s/attest-pq/v1. Follow its installation instructions before starting; this page does not assume a publicly available npm release or a usable WASM build.
  • The reachable external Certificate Distribution Service (CDS) endpoint, its approved cds-pin.json binding its image and exact measured launch configuration, operator.key, operator.pub, and the retained mesh-ca.pem. Copy the first tutorial's cds-bootstrap/cds-pin.json to cds-pin.json in this working directory.
  • The first tutorial's approved nodes-policy.json and router-pin.json. Obtain these complete CLI image policies from your trusted image producer or deployment operator through the Azure policy preparation procedure. nodes-policy.json must describe the independently intended current node set CDS admits; obtain its approved replacement from the operator if admission policy has changed. router-pin.json contains only identities acceptable for nodes hosting this router.
  • For this SNP path, an operator-supplied current AMD KDS certificate revocation list (CRL) in binary DER, with confirmation that it matches the router nodes' processor generation. Save it as snp-crl.der in Step 4. Obtain it through the operator's validated vendor-collateral procedure before starting; missing collateral leaves this prerequisite unmet.
  • A digest-pinned CPU vLLM image that your image producer has validated with the manifest below. Its entrypoint serves HTTP on 0.0.0.0:8000, loads a bundled model from /models/tutorial-model, and exposes the OpenAI chat model ID tutorial-model. Include the tokenizer and chat template in the image; startup and inference must work offline. The image runs as UID/GID 1000, writes scratch data under /cache and /dev/shm, and fits your nodes' CPU/RAM budget, including those two memory volumes. The nodes must be able to pull it. Obtain the image's HTTP readiness path from its producer: an unauthenticated GET on port 8000 must return 200 only when the bundled model is loaded and ready for inference, and a failure status while it is unavailable. Use a clean absolute path with unreserved URL characters in each segment and no query.
  • A network path from this workstation to the router on TCP 443, through its existing node host port or routed Service address. Configure workstation DNS or a hosts entry so c8s-router.c8s-router.svc resolves to that reachable address. Any load balancer on this path must pass TLS through; pod port-forwarding is not a substitute.

Use public demonstration weights and non-sensitive prompts for this exercise. Bundling weights in a registry image does not provide encrypted model storage. A validated serving image, resource sizing, registry access, and DNS/reachability are required deployment inputs; this tutorial does not supply or runtime-test them.

Step by step

1. Deploy a confidential vLLM backend

Set the image reference and external CDS URL, replacing all angle-bracket placeholders:

export VLLM_IMAGE='<REGISTRY>/<REPOSITORY>@sha256:<IMAGE_DIGEST>'
export VLLM_READY_PATH='<IMAGE_READINESS_PATH>'
export CDS_URL='https://<CDS_HOST>:8443'
export CHAT_HOST='c8s-router.c8s-router.svc'

<IMAGE_DIGEST> is the validated image's 64-hex manifest digest. <IMAGE_READINESS_PATH> is the producer-confirmed readiness path from the image contract above. <CDS_HOST> is the VM endpoint from installation. CHAT_HOST matches the default router certificate's DNS name.

Save the deployment using that image:

cat > vllm.yaml <<EOF_MANIFEST
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 }
    spec:
      enableServiceLinks: false
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        runAsGroup: 1000
        seccompProfile: { type: RuntimeDefault }
      containers:
        - name: vllm
          image: ${VLLM_IMAGE}
          env:
            - { name: HOME, value: /cache }
            - { name: TMPDIR, value: /cache }
          ports: [{ containerPort: 8000 }]
          readinessProbe:
            httpGet: { path: "${VLLM_READY_PATH}", port: 8000 }
            periodSeconds: 5
            timeoutSeconds: 2
          securityContext:
            allowPrivilegeEscalation: false
            capabilities: { drop: ["ALL"] }
          volumeMounts:
            - { name: shm, mountPath: /dev/shm }
            - { name: cache, mountPath: /cache }
      volumes:
        - name: shm
          emptyDir: { medium: Memory, sizeLimit: 1Gi }
        - name: cache
          emptyDir: { medium: Memory, sizeLimit: 1Gi }
EOF_MANIFEST

kubectl create namespace workloads --dry-run=client -o yaml | kubectl apply -f -
kubectl apply -f vllm.yaml

confidential.ai/cw: vllm names the workload's Service intent: the operator creates the headless Service c8s-vllm.workloads.svc.cluster.local. Every covered pod receives mesh injection. The webhook adds three init entries: persistent c8s-mesh and c8s-cert sidecars, and the c8s-cert-wait gate. Only platform containers hold the private mesh credentials; vLLM serves plain HTTP on port 8000 inside the pod. The injector forwards the readiness probe through the mesh endpoint to the application's HTTP path. The rollout wait in Step 2 therefore includes the image's serving-readiness check.

The image may remain blocked until Step 2 admits it. Ordinary outbound TCP is captured by the mesh, so this deployment relies on its bundled model rather than a startup download.

2. Allow the vLLM image

Check CDS against its approved launch pin, current node policy, and operator keys before changing the allowlist:

c8s cds verify "$CDS_URL" --image-policy-file cds-pin.json \
  --served-policy-file nodes-policy.json --operator-keys operator.pub

Require exit code 0 and inspect warnings. In an HA deployment, signed Genesis can select a node set different from the measured configuration's default, and later signed commands can change it. Use the intended current set in nodes-policy.json.

c8s allowlist add "${VLLM_IMAGE##*@}" "$VLLM_IMAGE" \
  --url "$CDS_URL" --image-policy-file cds-pin.json \
  --operator-key operator.key

Record the name after added, then set it for the route:

export WORKLOAD_ENTRY='<PRINTED_ALLOWLIST_ENTRY>'
kubectl rollout status -n workloads deployment/vllm --timeout=10m
kubectl get pods -n workloads

Expect the vLLM pod to become Running with 3/3 ready: vLLM and the two persistent sidecars. A rollout timeout requires investigating pod events and the image's resource/runtime contract. It is not an attestation verdict.

The private signing key stays local; a signed token authorizes the write. This convenience entry admits the image with any command, arguments, environment, and mounts, and grants no egress exits. It does not prove the exact model or application configuration. For stricter entries, see Manage the allowlist.

For a future bootstrap, generate the component/workload seed before building cds-config.yaml. CDS seeds from that measured configuration; changing cluster Helm values does not rewrite the running external VM's seed.

3. Point the Load Balancer at vLLM

Check the router pods and the Service in their dedicated namespace:

kubectl get pods -n c8s-router
kubectl get svc -n c8s-router c8s-router

Require ready router pods and the prepared network path. This tutorial keeps the existing front-door configuration: the client reaches the router using its certificate's DNS name and trusts the retained mesh CA for outer HTTPS in Step 6. The same CA separately anchors the SDK's attested mesh identity. Keep TLS certificate validation enabled.

Read the existing route state before choosing whether to create the first manifest:

c8s routes show --url "$CDS_URL" --image-policy-file cds-pin.json \
  --operator-keys operator.pub

If this prints a verified manifest, omit --new from both commands below. The specific error no route manifest stored on CDS means the store is empty: use --new only when creating the cluster's first manifest. If a CDS reset lost earlier routes, restore the retained approved manifest before continuing. Stop for any other error.

Preview an exact route for /v1/chat/completions. It takes precedence over prefix routes for that endpoint and replaces any existing exact route at that host and path. Review that replacement and every retained route before publishing:

c8s routes add --url "$CDS_URL" --image-policy-file cds-pin.json \
  --operator-key operator.key --operator-keys operator.pub \
  --host "$CHAT_HOST" --path /v1/chat/completions --match exact \
  --address c8s-vllm.workloads.svc.cluster.local:8000 \
  --workload "$WORKLOAD_ENTRY" --new --dry-run

Review every route in the preview, then publish and read back the signed manifest:

c8s routes add --url "$CDS_URL" --image-policy-file cds-pin.json \
  --operator-key operator.key --operator-keys operator.pub \
  --host "$CHAT_HOST" --path /v1/chat/completions --match exact \
  --address c8s-vllm.workloads.svc.cluster.local:8000 \
  --workload "$WORKLOAD_ENTRY" --new

c8s routes show --url "$CDS_URL" --image-policy-file cds-pin.json \
  --operator-keys operator.pub

Confirm the stored host, exact /v1/chat/completions path, backend, and workload name match this deployment, then retain the printed digest:

export ROUTE_DIGEST='<ROUTE_DIGEST>'

Replace <ROUTE_DIGEST> with the reviewed manifest's 64 lowercase hex characters. This pin is required in Step 5. The router polls routes every 30 seconds by default.

The route's workload is the allowlist entry name, distinct from the vllm Service ID. The router authenticates the backend's CDS-issued mesh leaf and its workload stamp, requiring its allowlist digest to equal the stamped router's. The pod's mesh endpoint decrypts that connection and delivers ordinary HTTP to vLLM. After an allowlist change, requests can fail until the router and backend certificates carry matching policy digests.

4. Prepare the client pins

Keep the router identity, CDS identity, and mesh CA separate. Recheck the retained CA through the approved CDS pin without overwriting it:

c8s cds ca --url "$CDS_URL" --image-policy-file cds-pin.json \
  --out mesh-ca.candidate.pem
cmp mesh-ca.pem mesh-ca.candidate.pem

Require the CA read to succeed; cmp exit 0 confirms unchanged bytes. The CA command prints SHA-256 fingerprints of certificate DER on stderr. Investigate any difference through CDS verification before accepting a replacement. A singleton restart regenerates its CA and can lose nonpersistent routes; a surviving HA cluster can restore a restarting replica's state.

Create router-policy.json from the approved router-pin.json obtained through Azure policy preparation. For this AKS/AMD SEV-SNP path, copy the router-approved entries' measurements[].measurement values into the SDK's measurements array. The one-digest example is:

{
  "platform": "az-snp",
  "measurements": ["<APPROVED_ROUTER_NODE_LAUNCH_DIGEST>"],
  "requireCollateral": true
}

Replace the placeholder with the approved 96-hex SHA-384 value; include additional approved router-node digests as separate array entries. Use snp for native SEV-SNP evidence. These are router-node values, separate from the external CDS policy in cds-pin.json.

Copy the binary DER CRL supplied by the deployment operator:

cp '<OPERATOR_SUPPLIED_CRL_DER>' snp-crl.der

<OPERATOR_SUPPLIED_CRL_DER> is the file obtained through the prerequisite's validated vendor-collateral procedure. Have the operator confirm its processor generation against the router nodes' deployment inventory and provide a current CRL. Keep the DER bytes unchanged; Step 5 loads them as a Uint8Array and requires successful collateral verification.

For an existing native Intel TDX deployment, obtain its approved router-node manifest.json through launch measurement preparation. Copy mrtd, rtmr1, and rtmr2 from its tdx object (or its top level for a flat manifest) into this SDK policy. All three 96-character lowercase hex values must come from one build:

{
  "platform": "tdx",
  "tdxImage": {
    "mrtd": "<APPROVED_MRTD>",
    "rtmr1": "<APPROVED_RTMR1>",
    "rtmr2": "<APPROVED_RTMR2>"
  },
  "requireCollateral": true
}

Supply matching Intel PCS collateral as tdx-collateral.json in the SDK's tdxCollateral format, using strings for the verbatim signed JSON bodies and PEM certificate/CRL fields. The script below supports snp, az-snp, and native tdx; its collateral-required policy does not support az-tdx. SDK policy JSON is not a CLI --image-policy-file, and binary SNP collateral cannot be embedded in it as a JSON array.

These reference values come from approved build/deployment records, not from the endpoint being tested. A launch pin establishes only the state that platform measures; a bare AKS digest does not identify every guest or application file. Stock baked nodes leave TDX policy measurement off. For a supported deployment that enables it, prepare the additional history acceptance inputs before connecting.

5. Verify and connect

Create chat.mjs with this block, then append both JavaScript blocks from Step 6 to the same file. Keep the policy, collateral, and mesh-ca.pem beside it:

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

function requiredEnv(name) {
  const value = process.env[name];
  if (!value) throw new Error(`Set ${name} before running chat.mjs`);
  return value;
}

const policy = JSON.parse(readFileSync("router-policy.json", "utf8"));
if (policy.platform === "snp" || policy.platform === "az-snp") {
  policy.snpCrl = new Uint8Array(readFileSync("snp-crl.der"));
} else if (policy.platform === "tdx") {
  policy.tdxCollateral = JSON.parse(readFileSync("tdx-collateral.json", "utf8"));
} else {
  throw new Error("This tutorial requires snp, az-snp, or native tdx evidence");
}

const client = new C8sClient({
  ...policy,
  baseUrl: `https://${requiredEnv("CHAT_HOST")}`,
  meshCaPem: readFileSync("mesh-ca.pem", "utf8"),
  routeDigests: [requiredEnv("ROUTE_DIGEST")],
  requireCollateral: true,
  allowPartial: false,
});

const session = await client.connect();
console.error(`Verified router: ${session.attestation.measurement}`);
for (const warning of session.attestation.warnings) console.error(warning);

connect() generates a nonce and an X-Wing keypair (X25519 + ML-KEM-768), posts the nonce and encapsulation key to /.well-known/c8s/attest-pq, and verifies the returned evidence and transcript. It checks the router policy, mesh CA, route digest, and supplied collateral before decapsulating and deriving the client channel. Failed checks return no session; do not bypass them or enable partial results to continue. See what it verifies.

6. Chat over the sealed channel

Append the request function. The model ID matches the preconfigured image from Step 1:

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

Append the chat loop:

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

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

Run from the directory containing the files, with CHAT_HOST and ROUTE_DIGEST from earlier steps still set:

NODE_EXTRA_CA_CERTS=mesh-ca.pem node chat.mjs

Expect Verified router: …, then you>. Send a short prompt; a bot> reply means an application request and response traversed this verified session. Enter /exit to finish. If connection or inference fails, resolve the reported failure before sending another prompt.

session.fetch seals the method, path, headers, and body; the router decrypts them, routes to vLLM, and seals the response. The tunnel buffers a complete response, so this example sets stream: false. Ordinary HTTPS routes can stream, but their requests do not use this sealed session. A successful standalone CLI verification does not bind a later ordinary HTTP request.

Route or router policy changes can reject later requests and end the session. The SDK reports outer non-2xx failures as channel_error; it neither reconnects nor replays requests. Review any changed pins, then restart the client to establish a fresh session. Checks on a later request do not cancel an already running exchange.

What you've proven

Your trusted client has checked fresh router evidence against its approved identity, retained mesh CA, and reviewed route digest, then used the resulting channel for a chat request. Channel secrets are derived at the client and attested router. An outer TLS terminator sees sealed application data; the client, router, and application process plaintext.

The router's mesh checks transitively reach the CDS-admitted backend named by your route under operator policy. They authenticate CDS-issued identities, without repeating hardware attestation on every mesh connection. This tutorial's broad image entry does not establish an exact model/configuration or control every application log and storage sink. These checks also do not establish end-to-end GPU attestation.

Where to go next