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 deploysvolumedand pins its image into the allowlist floor. - Build tools on the machine that runs
c8s volume create.mkfs.erofsandveritysetupfor an immutable volume,mkfs.ext4(e2fsprogs) for a mutable one. The build runs in process: no root, no loop device, nocryptsetup. - 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 attachon 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_pg80c8s 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" # optionalEach 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-volumenative sidecar, ordered afterc8s-cert-wait— it authenticates with the leaf that sidecar writes; - per volume, an
emptyDirnamedc8s-volume-<NAME>, mounted into every container withmountPropagation: HostToContainer. TheemptyDiruses 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/weightsFor 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/markerThe 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 useAn 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: RecreateImmutable volumes need none of this — many pods may open one device at once, because nothing writes.
See also
- Encrypted volumes — the two artifacts, the mount decision, and the threat tables.
- CLI reference →
c8s volume— every flag. - Workload annotations —
confidential.ai/c8s-volumesand the reserved names. - Troubleshoot — when the directory stays empty.