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 laptop — kubectl 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-cliif 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_v5confidential 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-keysPrefer 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 wideget-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 craneOn 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 --help4. 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 pool | Flags |
|---|---|
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.pubThen 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
cranecan resolve each image tag to a digest whilec8s installruns (the default--resolve-digests=trueshells out tocraneon your laptop, reading~/.docker/config.json). Skipping it producescrane 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 akubernetes.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-secretValidate 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 -wA 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-systemCheck 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 patchedNow 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:
- Deploy NVIDIA Dynamo — a multi-component serving graph run confidentially.
- Serve a model with KServe —
InferenceServiceserving, every image digest-allowlisted. - Run a confidential workload — any Deployment, one annotation.
Or understand what you just deployed:
- The trust root — what the CDS vouches for and how.
- The image allowlist — how c8s decides what may run.
- Limitations — the honest list of what this milestone does not do.