Create an encrypted volume

Build an encrypted volume with c8s volume create — immutable by default, mutable with --mutable — keep the escrow file, present the image to a node, grant the workload its key path, and mount it into a pod.

An encrypted volume is how c8s carries data too large to be a secret — model weights are the case it is built for, and a mutable volume gives a workload durable scratch space. It sits as ciphertext on storage the untrusted host reads freely and decrypts only inside a Trusted Execution Environment (TEE), for a workload the allowlist names.

This page is the procedure for both kinds. For the layers, what happens at mount time, and what each kind does and does not defend against, see Encrypted volumes.

Before you start

  • A cluster that serves volumes. The node agent is off by default. Install with c8s install --volumes, which deploys volumed and pins its image into the allowlist floor.
  • Build tools on the machine that runs c8s volume create. mkfs.erofs and veritysetup for an immutable volume, mkfs.ext4 (e2fsprogs) for a mutable one. The build runs in process: no root, no loop device, no cryptsetup.
  • Operator credentials for the CDS — the key whose public half CDS pins via c8s install --operator-keys, and the launch measurement of the endpoint you write to.
  • A node that can carry the device. The image becomes a block device on one node, and the pod is scheduled there.

A mutable volume is not integrity-protected.

--mutable gives up dm-verity. The volume stays confidential, but the host can flip bits or roll the device back and c8s cannot detect it. Anything the workload's security depends on — weights, a policy file, trusted input — belongs on an immutable volume. See What this defends.

Build the image and store the key

c8s volume create builds the image, generates a key, encrypts, and PUTs the key blob to the CDS secret store. It modifies no workload. Every flag is in the CLI reference.

Immutable (the default). --source is packaged into an erofs image with a dm-verity tree inside the encryption, and the image sizes itself to its contents:

c8s volume create \
  --name weights \
  --source ./llama-3.1-8b \
  --out ./weights.img \
  --path /tenant-a/volumes/weights \
  --escrow-out ./weights.escrow.json \
  --node node-1 \
  --url https://cds.example \
  --measurements-file ./measurements.txt \
  --operator-key ./operator.key
+ ./weights.img (26214400 data blocks)
+ key stored at /tenant-a/volumes/weights
+ key escrowed to ./weights.escrow.json — keep it; a CDS restart needs it

Attach ./weights.img to the node as a raw block device with serial c8s-vol-weights.

Pod annotations:
  confidential.ai/cw: <workload-id>
  confidential.ai/c8s-volumes: "weights=/tenant-a/volumes/weights"

Pod nodeSelector (the device is on one node):
  kubernetes.io/hostname: node-1

Allowlist grant for the workload entry (read-only, exact path):
  "secrets": {"policy": "allow", "read": ["/tenant-a/volumes/weights"]}

A subtree grant would cover every volume beneath it, so this names one path.

Mutable. --mutable builds a writable ext4 filesystem of --size bytes. --source is optional here: with it the tree is preloaded, without it the volume starts empty:

c8s volume create --mutable \
  --name scratch \
  --size 50Gi \
  --out ./scratch.img \
  --path /tenant-a/volumes/scratch \
  --escrow-out ./scratch.escrow.json \
  --node node-1 \
  --url https://cds.example \
  --measurements-file ./measurements.txt \
  --operator-key ./operator.key
+ ./scratch.img (50Gi, mutable, read-write)
  mutable volumes have no integrity protection: the host can flip bits or roll back undetected
+ key stored at /tenant-a/volumes/scratch
+ key escrowed to ./scratch.escrow.json — keep it; a CDS restart needs it

The rest of the output is the same: the serial to attach with, the pod annotations, the nodeSelector from --node, and the exact-path allowlist grant.

--size takes a byte count or a quantity — 50Gi, 500M, 2Ti. Give --source without --size and the size is inferred from the tree and printed back; pin that value with --size next time so a rebuild is predictable. The floor is 16 MiB. --size outside --mutable is refused: an immutable image sizes itself.

Budget the full size at build time.

Every sector of a mutable image holds ciphertext, free space included, so --size 200Gi writes a 200 GiB file on the build machine and needs a 200 GiB device on the node. There is no resize afterwards.

The store write is create-only. A path that already holds a value is refused: a volume's key and its ciphertext are one unit, so replacing the key at a path some volume already uses strands that volume rather than rotating anything. Choose another path.

Keep the escrow file

CDS keeps secrets in process memory and nowhere else. A CDS restart makes every volume in the cluster unopenable until its key is written back, and the escrow file is what you write it back from:

c8s secrets put /tenant-a/volumes/weights \
  --from-file ./weights.escrow.json \
  --url https://cds.example \
  --operator-key ./operator.key \
  --mesh-ca ./mesh-ca.pem

--mesh-ca names the CDS you trust: the mesh CA the endpoint serves must chain to that bundle, or the write is refused. It is the same anchor c8s verify --mesh-ca takes. Against an https endpoint the write needs it, or --force to write without the check.

The escrow file is the only copy of the key outside the CDS process.

Lose it and restart CDS, and the ciphertext is unrecoverable — there is no other copy, no versioning, and no recovery path. Its compromise is equivalent to handing over the plaintext, permanently. For a mutable volume that includes everything the workload has written since. Store escrow files somewhere durable and access-controlled.

Present the image to a node

The image is ciphertext. Copy it to the node by any means, including through the untrusted host — that the host holds the bytes is the premise, not a compromise of it.

The node finds a volume by disk serial c8s-vol-<name>, read from sysfs, so no udev rules are needed. A confidential node has no persistent writable storage — the root overlay is reformatted on every boot — so a volume has to be its own device rather than a file on the node's filesystem. For a mutable volume that device is where the writes persist.

How the device gets that serial depends on the hypervisor:

  • QEMU/KVM. Attach the image in the VM spec: -device virtio-blk,drive=<DRIVE>,serial=c8s-vol-weights. The node reads it from /sys/block/<DEV>/serial. Nothing else is needed.
  • Azure, Hyper-V, or any cloud disk. There is no virtio bus to set a serial on, and a cloud disk's serial belongs to the provider. Run c8s volume attach on the node, as root — it builds a local SCSI disk from the image whose unit serial (SCSI VPD page 0x80) is the one c8s asks for, and the node matches on /sys/block/<DEV>/device/vpd_pg80:
sudo c8s volume attach weights --image /var/lib/c8s/weights.img
+ weights attached as a disk with serial c8s-vol-weights
  confirm: grep -l c8s-vol-weights /sys/block/*/device/vpd_pg80

c8s volume detach weights removes the disk again. It removes the device, not the image: the ciphertext stays where it is and the key stays in CDS. Detach refuses while something still holds the disk, and names the holder — release a mounted volume by deleting the pod that has it first.

Replace an image with detach → overwrite → attach.

An attached disk serves the file the command opened, not the path. Copying a new image over that path leaves the node serving the old one, while a checksum on the node matches the new bytes. For a mutable volume, note that a replacement image is a fresh filesystem: everything the workload wrote lives only in the old ciphertext.

The serial is a selector, not a trust input. The host chooses it and answers the query per read. Pointing a pod at the wrong device fails closed: the key will not decrypt it into anything that mounts, and for an immutable volume verity refuses it as well. Two devices claiming the same serial are refused outright rather than resolved by scan order.

Because the device lives on one node, the pod must be scheduled there. create emits the matching nodeSelector. The node agent opens the device for the scheduled pod.

Grant the workload the key path

Release is gated on the workload entry's secrets grant in the allowlist:

"secrets": { "policy": "allow", "read": ["/tenant-a/volumes/weights"] }

Name the exact path, not a subtree. /tenant-a/volumes/** grants every volume beneath it, and the annotation naming which volume to open is host-written. create prints an exact-path grant for this reason.

read only, for both kinds. The grant governs who may obtain the key blob; whether the volume mounts writable is a property of the blob itself.

Request the volume from the pod

A pod names its volumes in an annotation:

metadata:
  annotations:
    confidential.ai/cw: llama-infer
    confidential.ai/c8s-volumes: "weights=/tenant-a/volumes/weights"
    confidential.ai/c8s-volume-dir: "/models"    # optional

Each entry is NAME=/store/path, comma-separated. NAME is a DNS-1123 label of at most 12 characters — it selects the node's device by its c8s-vol-<NAME> serial and names the directory the plaintext appears in under the volume dir, above /models/weights. Without confidential.ai/c8s-volume-dir the default is /run/c8s/volumes. The annotation is the same for both kinds: nothing in the pod says which one it is.

For a pod carrying confidential.ai/cw, the webhook then injects:

  • a c8s-volume native sidecar, ordered after c8s-cert-wait — it authenticates with the leaf that sidecar writes;
  • per volume, an emptyDir named c8s-volume-<NAME>, mounted into every container with mountPropagation: HostToContainer. The emptyDir uses the default medium.

Writability comes from the mount volumed makes over that directory: read-only for an immutable volume, read-write for a mutable one.

Both names are reserved. A pod may not declare its own container called c8s-volume, and a volume it pre-declares under the c8s-volume- prefix must be an emptyDir of the default medium, or be omitted entirely — a hostPath or a PVC is rejected at admission. See Workload annotations.

Verify the mount landed

The volume appears after the workload starts: release waits for every main container to be running, so the sidecar retries and the mount lands shortly after startup. An empty directory right at startup is expected; an empty directory that stays empty is not — check the c8s-volume sidecar's logs.

For an immutable volume, the files are the check:

kubectl exec -n <NAMESPACE> <POD> -- ls /models/weights

For a mutable volume, check that a write survives the pod. The commands below assume a pod annotated confidential.ai/c8s-volumes: "scratch=/tenant-a/volumes/scratch" with confidential.ai/c8s-volume-dir: "/data". Write a marker:

kubectl exec -n <NAMESPACE> <POD> -- sh -c 'date > /data/scratch/marker'

Delete the pod, let it come back on the same node, and read it again:

kubectl exec -n <NAMESPACE> <POD> -- cat /data/scratch/marker

The marker is still there. That is what separates a volume from the emptyDir underneath it.

Run one writer at a time

A device carrying a mutable volume takes exactly one mount. volumed refuses a second open with HTTP 409 and the c8s-volume sidecar retries — 60 attempts, 5 seconds apart, then it fails the pod:

volume scratch: open volume: 409 Conflict: volume device is already in use

An ordinary RollingUpdate produces exactly this: the new pod starts while the old one still holds the device. Give a workload with a mutable volume one replica and a Recreate strategy:

spec:
  replicas: 1
  strategy:
    type: Recreate

Immutable volumes need none of this — many pods may open one device at once, because nothing writes.

See also