Run a confidential workload

Admit an HTTP application, route to its private mesh endpoint, and send a request through a verified encrypted session.

Run your application on an ordinary HTTP port. c8s injects the private mesh endpoint, and the router authenticates its Certificate Distribution Service (CDS)-issued identity before forwarding a request. The confidential.ai/cw annotation supplies a stable Service name; every pod covered by the webhook receives mesh injection.

Prerequisites

  • An installed deployment, a namespace covered by its webhook, and kubectl access to create workloads there.
  • c8s, kubectl, and crane locally, with registry access for the application image.
  • A digest-pinned application image whose default command runs as a numeric non-root user, listens on 0.0.0.0:8080, needs no volume mounts, and serves a harmless GET /app-health. Adapt the manifest and derive its policy again if your application needs different inputs.
  • A reachable direct CDS HTTPS URL, its approved cds-pin.json image/launch-file policy, an authorized operator.key, and the trusted public-key bundle operator.pub.
  • The independently approved router node policy saved as router-pin.json, the intended current node policy as nodes-policy.json, and a pinned cluster mesh-ca.pem. See Verify a deployment for obtaining and checking these anchors. A CA advertised by the target alone is not an independent cluster pin.
  • The SDK policy router-client-policy.json, prepared in the request step. Its concrete examples use a baked router node's trusted image manifest and exact launch-bundle pubkey, saved locally as router-pubkey.pem. Obtain these through your approved build and deployment records; the node launch contract identifies the files and launch bindings. Managed-platform clients need their own SDK platform policy.
  • A reachable router HTTPS hostname with working client-trusted TLS, attestation enabled, and a built c8s-verify package in a Node application. Follow the JavaScript prerequisites and installation; the request below needs Node 20.19 or later and the package's WASM assets.

Use one local working directory for the manifests, trusted files, and request script below.

Prepare the application

Check namespace coverage before choosing <NAMESPACE>:

kubectl get mutatingwebhookconfiguration <RELEASE>-pod-injector -o yaml
kubectl get namespace <NAMESPACE> --show-labels

Replace <RELEASE> with the Helm release name (c8s by default). Compare the namespace's labels with pods.c8s.confidential.ai's namespaceSelector; every expression must match. In the chart's selector, kubernetes.io/metadata.name must be outside the excluded names. Choose an existing namespace that matches, and use it throughout this guide.

Save this as deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-inference
  namespace: <NAMESPACE>
spec:
  selector:
    matchLabels:
      app: my-inference
  template:
    metadata:
      labels:
        app: my-inference
      annotations:
        confidential.ai/cw: my-inference
    spec:
      enableServiceLinks: false
      automountServiceAccountToken: false
      securityContext:
        runAsNonRoot: true
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: server
          image: <IMAGE_REPOSITORY>@sha256:<DIGEST>
          ports:
            - name: http
              containerPort: 8080
          readinessProbe:
            httpGet:
              path: /app-health
              port: http
          securityContext:
            allowPrivilegeEscalation: false
            capabilities:
              drop: ["ALL"]

Replace <NAMESPACE> with the covered namespace, <IMAGE_REPOSITORY> with your registry repository, and <DIGEST> with its 64-character SHA-256 manifest digest. The image's default command must actually start the server; containerPort only declares the port. Keep the application free of mesh certificate mounts. The webhook rewrites this HTTP readiness probe through the mesh health listener, so rollout readiness waits for the application's harmless endpoint to respond successfully.

The operator creates the headless Service c8s-my-inference and requests the certificate DNS name c8s-my-inference.<NAMESPACE>.svc. Use a DNS-label-safe annotation value.

Admit the complete application

kubectl create --dry-run=client -o json -f deployment.yaml > deployment.json
c8s allowlist derive inference-policy deployment.json \
  --env any --mounts deny > entry.json
c8s allowlist lint entry.json
c8s allowlist apply entry.json \
  --url https://<CDS_HOST>:<CDS_PORT> \
  --image-policy-file cds-pin.json \
  --operator-key operator.key

Replace <CDS_HOST> and <CDS_PORT> with the directly reachable CDS endpoint. The derive command reads the image's baked command through crane. It includes every application init and main container and excludes injected platform containers.

Here, --env any leaves the environment unconstrained; use an explicit environment policy when those values affect trust. --mounts deny admits platform mounts alone, which suits this no-volume application. See Manage the allowlist for exact environment and data-mount rules.

inference-policy is the complete allowlist entry name, distinct from the annotation's Service ID my-inference. CDS stamps the matched entry name into the mesh leaf using the sandbox's retained admission records, including exited containers. A matching stamp proves admission under that entry, not current container liveness.

Start the workload and check its Service

kubectl apply -f deployment.yaml
kubectl rollout status deployment/my-inference -n <NAMESPACE> --timeout=5m
kubectl get service c8s-my-inference -n <NAMESPACE>
kubectl get endpointslices -n <NAMESPACE> \
  -l kubernetes.io/service-name=c8s-my-inference

Confirm the rollout completes, the Service has CLUSTER-IP None, and its EndpointSlices contain ready pod addresses. A ready pod alone does not prove the router can authenticate its mesh leaf; the verified request below checks the routed path.

Check the router's public endpoint

kubectl get deployments,services,pods -n c8s-router

Use your deployment's ingress or node host port to reach the router on HTTPS. Configure DNS, firewalls, and the public certificate for <ROUTER_HOST>, the lowercase hostname used below without a port. An internal name such as <RELEASE>-router.c8s-router.svc is not a public endpoint. Pod port-forwarding is not a substitute for this network path.

For chart-managed routing, router.san selects nginx hostnames and its first entry selects the CDS-issued certificate name. router.publicTLS.mode selects cds, webpki, or acme; a WebPKI TLS Secret belongs in c8s-router. Baked nodes take public hostnames and TLS selection from the signed launch configuration. See the router values. Keep router.attest.enabled enabled and expose /.well-known/c8s/ through the ingress.

The final request uses attest-pq, which encrypts application data to the attested router inside the Trusted Execution Environment (TEE), including with a WebPKI front door. WebPKI mode refuses the ordinary-TLS attest-lb mode.

Sign the route

Check for an existing signed manifest:

c8s routes show \
  --url https://<CDS_HOST>:<CDS_PORT> \
  --image-policy-file cds-pin.json --operator-keys operator.pub

If CDS reports no stored manifest, check your deployment records: absence alone cannot distinguish first initialization from a CDS reset. Add --new to the next command only when those records confirm this is the cluster's first manifest. After a reset, restore the last saved complete manifest with c8s routes put before adding this route. Resolve other errors before continuing.

In the displayed manifest, check for an exact route at /app-health on <ROUTER_HOST>. The next command replaces that entry if it exists; confirm that its traffic belongs to inference-policy before writing. An exact route takes precedence over prefix routes for the final request.

c8s routes add \
  --url https://<CDS_HOST>:<CDS_PORT> \
  --image-policy-file cds-pin.json \
  --operator-key operator.key --operator-keys operator.pub \
  --host <ROUTER_HOST> --path /app-health --match exact \
  --address c8s-my-inference.<NAMESPACE>.svc:8080 \
  --workload inference-policy

c8s routes show \
  --url https://<CDS_HOST>:<CDS_PORT> \
  --image-policy-file cds-pin.json --operator-keys operator.pub \
  --raw > routes.json
c8s routes digest routes.json > route-digest.txt

The command verifies the stored signature and preserves other routes. Review the exact /app-health route's host and backend in routes.json before accepting its digest as the client route pin.

The router authenticates the backend as inference-policy. When the router carries a workload stamp, it also requires the backend's allowlist digest to match its own. Traffic reaches port 8080 through the injected mesh; the application receives HTTP.

Verify the anchors and route

Wait at least one configured router.routing.pollInterval after the write, then verify the new digest. The default is 30s; adjust the wait below if your interval is longer.

sleep 30
c8s verify https://<CDS_HOST>:<CDS_PORT> --kind cds \
  --image-policy-file cds-pin.json --operator-keys operator.pub \
  --served-policy-file nodes-policy.json

c8s verify https://<ROUTER_HOST> --kind lb --mode attest-pq \
  --image-policy-file router-pin.json --mesh-ca mesh-ca.pem \
  --route-digest "$(cat route-digest.txt)"

Require exit code 0; investigate a partial result (4) before sending application data. Successful router verification confirms that the reached replica attests the new route digest. If a route-digest mismatch persists after polling, inspect that router pod's cds-attest logs for route-fetch or signature failures before retrying. nodes-policy.json must describe the independently intended current node set, including any signed changes since initialization. The router uses its own approved policy, not the CDS pin. These checks verify their own exchanges. The application client must verify its own session.

Send a request through the verified session

Create router-client-policy.json using one of these baked-node examples. For AMD SEV-SNP:

{
  "platform": "snp",
  "measurements": ["<ROUTER_SHA384_LAUNCH_DIGEST>"]
}

Use snp_variants[].measurement.snp_launch_digest for the deployed vCPU count in the trusted node manifest.json; see supported vCPU counts. The script below derives initDataHash as SHA-256 of the exact router-pubkey.pem bytes, matching the baked node's HOST_DATA launch binding.

For Intel TDX, use this complete image-policy shape instead:

{
  "platform": "tdx",
  "tdxImage": {
    "mrtd": "<MRTD>",
    "rtmr1": "<RTMR1>",
    "rtmr2": "<RTMR2>"
  }
}

Copy the three 96-character hex values from the trusted manifest's tdx object. The script sets operatorPublicKeyPem from router-pubkey.pem for the TDX RTMR[3] launch binding; it sets neither measurements nor initDataHash for this TDX example.

Add required TCB and policy-history constraints using client configuration. For SNP revocation checking, obtain the AMD KDS CRL for the node's processor generation as binary DER amd-crl.der and set requireCollateral: true in the JSON. The script loads its bytes as a Uint8Array; binary snpCrl does not belong in JSON. TDX uses the documented tdxCollateral object. The SDK policy is a separate format from the CLI's router-pin.json.

Save this as request.mjs in your Node application:

import { readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { C8sClient } from "c8s-verify";

const read = (path) => readFile(path, "utf8");
const policy = JSON.parse(await read("router-client-policy.json"));
const pubkey = await readFile("router-pubkey.pem");
if (policy.platform === "snp") {
  policy.initDataHash = createHash("sha256").update(pubkey).digest("hex");
  if (policy.requireCollateral) policy.snpCrl = await readFile("amd-crl.der");
} else if (policy.platform === "tdx") {
  policy.operatorPublicKeyPem = pubkey.toString("utf8");
} else {
  throw new Error("This example requires a baked SNP or TDX node policy");
}
const client = new C8sClient({
  ...policy,
  baseUrl: "https://<ROUTER_HOST>",
  meshCaPem: await read("mesh-ca.pem"),
  routeDigests: [(await read("route-digest.txt")).trim()],
  allowPartial: false,
});
const session = await client.connect();
const response = await session.fetch("/app-health");
if (response.status !== 200) {
  throw new Error(`Application returned HTTP ${response.status}`);
}
console.log(response.status, response.text());

Replace <ROUTER_HOST> with the same public hostname used in the signed route, then run:

node request.mjs

Success is a verified session followed by the application's expected 200 response and body. A route or router identity change can invalidate the session; reconnect and verify again. The SDK reports non-success tunnel HTTP responses as channel_error and does not transparently replay your request. Already running streams are not stopped by these admission checks.

What the webhook adds

The first three injected init containers are c8s-mesh, c8s-cert, and c8s-cert-wait. The mesh endpoint and certificate client run as sidecars; the wait container gates application startup on the first certificate. Only injected platform containers mount /etc/c8s/certs. Application certificate mounts and credential-path overrides are rejected.

See Workload annotations for reserved names and probe restrictions. Add application secrets or encrypted volumes with their documented annotations and corresponding allowlist rules.

Confirm it worked

If startup or the request fails, inspect the pod and its events:

kubectl get pods -n <NAMESPACE> -l app=my-inference
kubectl get pod <POD> -n <NAMESPACE> \
  -o jsonpath='{range .spec.initContainers[*]}{.name}{"\n"}{end}'
kubectl describe pod <POD> -n <NAMESPACE>
kubectl logs <POD> -n <NAMESPACE> -c c8s-cert

Replace <POD> with the pod name from the first command. An allowlist miss can prevent runtime container creation after the injected containers already appear in the pod spec. A 502 can indicate backend mesh authentication failure, including an absent named stamp or mismatched allowlist digests while certificates renew. See Troubleshoot an install.