Your first confidential cluster

Start to finish in one sitting — create a single-node AKS cluster on a confidential node pool, build the c8s CLI, install c8s, and verify that what came up is a genuine TEE running the code you expect.

By the end of this tutorial you will have a working c8s cluster and will have proved, from outside it, that your workloads run inside hardware you can check. It goes from an empty Azure subscription to a verified cluster in a single pass — every choice is already made for you, so follow it top to bottom rather than picking pieces out of it.

You need no prior confidential-computing knowledge. Every term is introduced where it first matters, and nothing here has to be read first.

The cluster you build runs the node-as-CVM shape: the node is one confidential VM and your pods are ordinary containers inside it. That's the only shape Azure supports — see why at the end.

You run everything from your laptopkubectl and c8s install talk to the managed cluster over az aks get-credentials. There's nothing to SSH into.

Prerequisites

On your laptop:

  • An Azure subscription, with the Azure CLI installed and logged in (az login).
  • kubectl (az aks install-cli if you don't have it).

Step 3 adds the toolchain for building the CLI (make, Go, Helm, crane) — nothing to install up front. There's no access token to create and no registry login to do either: the c8s source repo and every c8s component image are public. If you mirror the images into a registry that does require authentication, step 4 has a short note on wiring that up.

What you'll build

  • A single-node AKS cluster with a Standard_DC*as_v5 confidential node pool — AMD SEV-SNP, with a vTPM.
  • c8s installed — operator, CDS, attestation-api, RA-TLS mesh, image policy, and the tls-lb front door — and verified.

AKS confidential node pools use the AMD SEV-SNP DCas_v5 family; Standard_DC4as_v5 is a comfortable single-node dev size. Confidential VM sizes exist only in some regions (for example northeurope) — check az vm list-skus --location <REGION> --size Standard_DC first. The provisioning guide has the full SKU table, including Azure's Intel TDX sizes, which c8s supports on the same vTPM path.

Step by step

1. Create the AKS cluster with a confidential node pool

A DCas_v5 node size makes the pool a SEV-SNP confidential VM node pool — each node boots as a CVM with a vTPM. --node-count 1 keeps it single-node.

az group create --name c8s-rg --location northeurope

az aks create \
  --resource-group c8s-rg \
  --name c8s-aks \
  --node-count 1 \
  --node-vm-size Standard_DC4as_v5 \
  --os-sku Ubuntu \
  --generate-ssh-keys

Prefer Intel TDX? Use a DCes_v5 / ECes_v5 size instead — Standard_DC4es_v5 is the direct counterpart of the one above. Nothing else in this step changes; the difference is a single install flag in step 4.

Verify the node-pool shape.

If your subscription/region only allows the confidential size on a user pool, create a regular system pool and add the confidential pool with az aks nodepool add --mode User --node-vm-size Standard_DC4as_v5 --os-sku Ubuntu … — then drop --single-node in step 4 and let the CDS schedule onto the confidential pool. See the provisioning guide.

2. Point kubectl at the cluster

az aks get-credentials --resource-group c8s-rg --name c8s-aks

# wait until the node reports Ready
kubectl get nodes -o wide

get-credentials merges the cluster into your kubeconfig, so kubectl (and c8s install) talk to it directly from your laptop.

While you're looking at that output, check the KERNEL-VERSION column. c8s needs a kernel new enough for the TEE — 6.11 or later on AMD SEV-SNP, 6.16 or later on Intel TDX. Both clear the Linux 6.5 floor that SO_PEERPIDFD imposes, which nri-image-policy's admission inventory depends on; on an older kernel, sandbox-token fetches fail closed. If the node image Azure gave you is older than that, pick a newer node image before going on.

3. Install the c8s CLI

There's no prebuilt binary yet, so build the CLI from source on your laptop — Linux and macOS both work. It shells out to helm, kubectl, and crane.

Install the build tools (make, Go, Helm, crane)

On Linux:

# make + Go 1.26+ (the c8s go.mod floor)
sudo apt-get update && sudo apt-get install -y make
curl -sL https://go.dev/dl/go1.26.3.linux-amd64.tar.gz | sudo tar -C /usr/local -xz
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc && export PATH=$PATH:/usr/local/go/bin

# Helm
curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash

# crane (only needed with the default --resolve-digests=true)
CRANE_VER=$(curl -s https://api.github.com/repos/google/go-containerregistry/releases/latest | grep -oP '"tag_name": "\K[^"]+')
curl -sL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VER}/go-containerregistry_Linux_x86_64.tar.gz" \
  | sudo tar -xz -C /usr/local/bin crane

On macOS, brew install make go helm crane covers all four.

Clone the repo and build. make install runs go install, which drops the binary in $(go env GOPATH)/bin (default ~/go/bin) — add that directory to your PATH:

git clone https://github.com/confidential-dot-ai/c8s.git
cd c8s
make install     # builds and installs `c8s` to $(go env GOPATH)/bin (default ~/go/bin)

export PATH=$PATH:$(go env GOPATH)/bin
c8s --help

4. Install c8s

On Azure, attestation comes from the vTPM at /dev/tpm0, not a native /dev/sev-guest or /dev/tdx-guest device. Select that path with --cvm-mode aks — despite the name it means "Azure vTPM". Note this is a node-as-CVM shape, not --cvm-mode=pod: per-pod CVMs need nesting Azure doesn't provide.

--cvm-mode (the deployment shape) and --hardware-platform (the CPU TEE) are separate axes, and both matter on Azure. The vTPM's HCL report wraps an SNP report on a SEV-SNP CVM and a TD quote on an Intel TDX CVM, so the platform still has to be declared:

Your node poolFlags
AMD SEV-SNP (DCas_v5)--cvm-mode aks (--hardware-platform defaults to sev-snp)
Intel TDX (DCes_v5 / ECes_v5)--cvm-mode aks --hardware-platform tdx

First mint the operator keypair whose public half the install pins into the CDS — the private key is what will authorize allowlist writes later, and without --operator-keys the installer stops and asks for --force:

openssl ecparam -name prime256v1 -genkey -noout -out operator.key
openssl ec -in operator.key -pubout -out operator.pub

Then install:

c8s install \
  --single-node \
  --cvm-mode aks \
  --operator-keys operator.pub

--single-node makes every node CDS-eligible (no dedicated CDS node). When it finishes, the CLI tells you the one thing still outstanding:

+ tls-lb attestation sidecar enabled, but the mesh is UNPINNED (accepts any
  attested TEE). Pin it with the node image's launch measurement M (its
  manifest.json): reinstall with --measurements <M>. Clients verify with the
  same M: c8s verify https://<tls-lb> --measurements <M>.

That's the production homework: re-run the same install with --measurements <M> and c8s pins both internal trust boundaries at once — CDS's launch digest (which the mesh and NRI pin) and the mesh peers against each other. On AKS the node image is Azure's, so there's no manifest to read M out of; step 5 reads it off a live attestation report instead. One value covers everything, because every pod on a node-as-CVM node shares the node's measurement — see Obtaining launch measurements.

Pulling the c8s images from a private registry

The c8s images are public, so the default install needs no credentials. If you mirror them somewhere authenticated, you need two independent logins:

  • Locally, so crane can resolve each image tag to a digest while c8s install runs (the default --resolve-digests=true shells out to crane on your laptop, reading ~/.docker/config.json). Skipping it produces crane digest ...: UNAUTHORIZED: authentication required.
  • In the cluster, via a pull Secret in the release namespace, so the kubelet can pull at runtime. Create it before installing, then name it with --image-pull-secret. The install fails fast if the Secret is missing or is not a kubernetes.io/dockerconfigjson.
REGISTRY=<REGISTRY_HOST>
REGISTRY_USER=<REGISTRY_USER>
REGISTRY_TOKEN=<REGISTRY_TOKEN>

# 1. Local login so `crane` can resolve digests during `c8s install`
echo "$REGISTRY_TOKEN" | crane auth login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin

# 2. Cluster-side pull Secret so the kubelet can pull at runtime
kubectl create namespace c8s-system
kubectl create secret docker-registry mirror-secret \
  --namespace c8s-system \
  --docker-server="$REGISTRY" \
  --docker-username="$REGISTRY_USER" \
  --docker-password="$REGISTRY_TOKEN"

c8s install --single-node --cvm-mode aks --operator-keys operator.pub \
  --image-pull-secret mirror-secret

Validate on managed nodes.

This install runs privileged DaemonSets that touch the node host — nri-image-policy patches containerd and restarts it, ratls-mesh sets up iptables, and attestation-api mounts /dev/tpm0. Confirm these complete on your AKS node pool (and survive node image upgrades) before relying on it.

helm --wait makes c8s install block until every component reports Ready, so the command can sit for a few minutes — that's expected, not a hang. To watch progress, in another terminal:

kubectl get pods -A -w

A brief ImageInspectError or a single restart on ratls-mesh early in the rollout is normal: nri-image-policy restarts containerd as it installs, and the mesh pod retries through that and self-heals. The wait is not unbounded, though — outside --cvm-mode=pod the install runs helm --wait --timeout=5m, so if a pod stays stuck in ImagePullBackOff or CrashLoopBackOff, c8s install fails once the timeout elapses. The resources it already applied stay in place, so fix the cause and re-run c8s install — it's idempotent (helm upgrade --install) and completes once every pod is Ready.

Read the install guide for the rest of the flags.

5. Verify the install

Confirm the control plane came up. A node-as-CVM install on AKS runs six components — c8s-cds, c8s-operator, c8s-attestation-api, c8s-ratls-mesh, c8s-nri-image-policy, and c8s-tls-lb — and all should be Running with every container ready:

kubectl get pods -n c8s-system

Check the admission webhook is wired up — the operator patches its caBundle once at startup, so it should be non-empty:

# the config is named <release>-pod-injector (release defaults to c8s):
kubectl get mutatingwebhookconfiguration c8s-pod-injector \
  -o jsonpath='{.webhooks[*].clientConfig.caBundle}' | wc -c
# any non-zero count means it's patched

Now the real test: prove confidentiality from outside the cluster. The tls-lb attestation sidecar is on by default, so the front door is already publishing its evidence. c8s-tls-lb is a ClusterIP Service at this point, so reach it with a port-forward and point the c8s verify CLI at it:

kubectl port-forward -n c8s-system svc/c8s-tls-lb 8443:443 &

c8s verify https://localhost:8443 --kind lb --server-name c8s-tls-lb.c8s-system.svc
✓ VERIFIED  (attestation-go backend)
  source:       discovery document https://localhost:8443/v1/discovery
  verified at:  2026-07-31T12:04:11Z
  platform:     az-snp
  measurement:  9f2c…                    ← this is M

  note:         freshness NOT proven (no per-request nonce bound)
  WARNING:      no --measurements pinned — any genuine TEE is accepted (UNSAFE for production)

--server-name is the SNI the serving certificate carries — the chart-managed Service DNS name, which is what a port-forward would otherwise miss. platform: az-snp is the Azure vTPM SEV-SNP shape you installed (az-tdx on a TDX pool). The freshness NOT proven note is expected for --kind lb: the discovery document carries a challenge fixed at certificate-issuance time rather than a per-request nonce, in exchange for shipping the VCEK so the evidence verifies offline.

That measurement line is the M from step 4 — the node's launch digest, read straight off a live attestation report. Close the loop: re-run c8s install with --measurements <M> to pin the mesh, then re-run c8s verify … --measurements <M> and the warning goes away. Exit code 0 means verified, 2 means the evidence or the policy failed, and 3 means no evidence could be fetched — so the command drops straight into CI.

To do the same check from a browser or a Node client — and to open a sealed channel on top of it — continue to Consumer verification.

Why pod-as-CVM is not available on Azure

Azure's hypervisor does not expose nested virtualization. A pod-as-CVM design launches a new confidential VM inside the node for every pod — that requires nesting, which Azure does not provide. So each AKS confidential node is a single node-level CVM, and all pods on it share that one confidential boundary. Don't use --cvm-mode=pod on Azure.

You still get strong guarantees: pod identities are issued by the CDS, the image allowlist is enforced at the node, inter-node traffic (once you scale past one node) is RA-TLS-protected, and secrets are released only to attested nodes. The tradeoff is attestation granularity — you can't distinguish two pods on the same node, because they're inside the same TEE. If your threat model needs per-pod isolation from the operator, you need a bare-metal-class confidential host that allows nesting; GKE (native /dev/sev-guest, --cvm-mode gke) is the other platform where per-pod CVMs are an option. See pod-as-CVM vs node-as-CVM for the full comparison.

What you've built

A managed Kubernetes cluster whose node is a confidential VM, with c8s installed: an attestation root of trust that will not issue a certificate to a pod it cannot vouch for, a mesh that refuses unattested peers, and image-policy enforcement at container creation. You verified the result from outside the cluster rather than taking its word for it.

Where to go next

Continue the tutorial: Verified chat over confidential vLLM — deploy a vLLM model server onto this cluster, attest that it's running in a TEE, and chat with it over an encrypted channel from your laptop.

Or bring your own serving stack onto it:

Or understand what you just deployed: