Prerequisites
Section titled “Prerequisites”Before deploying, ensure your EKS cluster has:
-
Nitro Enclave-capable nodes — use instance types with Nitro Enclave support (e.g.,
m5.xlarge,c5.xlarge,r5.xlarge). Enable Nitro Enclaves in the launch template (EnclaveOptions.Enabled: true). -
Nitro Enclaves Allocator — configure
/etc/nitro_enclaves/allocator.yamlon each node to pre-allocate CPUs and memory for enclaves. These resources are taken offline from the host at boot./etc/nitro_enclaves/allocator.yaml cpu_count: 4memory_mib: 2048 -
Device plugin DaemonSet — install the Nitro Enclaves Kubernetes device plugin. Enable CPU advertisement so the scheduler can properly account for enclave CPU allocation:
Terminal window kubectl apply -f https://raw.githubusercontent.com/aws/aws-nitro-enclaves-k8s-device-plugin/main/aws-nitro-enclaves-k8s-ds.yamlSet
ENCLAVE_CPU_ADVERTISEMENT=truein the DaemonSet env to advertise offline CPUs as a schedulable resource (aws.ec2.nitro/nitro_enclaves_cpus). This prevents K8s from over-scheduling enclave pods on a node. -
Node labels:
Terminal window kubectl label node <node-name> aws-nitro-enclaves-k8s-dp=enabled -
Hugepages — configure each node with hugepages that match the chart values. The default chart values request
2Giof1Gihugepages, so each node that can run one default enclave pod needs two1Gihugepages reserved:Terminal window echo 2 | sudo tee /sys/kernel/mm/hugepages/hugepages-1048576kB/nr_hugepagesPersist this through your node image or launch-template boot configuration. If you change
enclave.hugepages.sizeto2Mi, use the matchinghugepages-2048kBnode setting instead. Keepenclave.memoryMibaligned with the hugepage size; the default1Gihugepages require a multiple of 1024 MiB.
Cluster Autoscaler
Section titled “Cluster Autoscaler”If using Cluster Autoscaler, add these tags to the enclave-capable ASG so CA knows what resources the ASG provides:
k8s.io/cluster-autoscaler/node-template/resources/aws.ec2.nitro/nitro_enclaves = "4"k8s.io/cluster-autoscaler/node-template/resources/aws.ec2.nitro/nitro_enclaves_cpus = "4"With Karpenter, use a NodePool constrained to enclave-capable instance types — Karpenter reads device resources from existing nodes automatically.
Building the Custody EIF
Section titled “Building the Custody EIF”On a Nitro build the custody parameters — the Shamir KMS keys, the threshold, the root-token recipients, and the ceremony generation — are compiled into the binary and therefore measured into PCR0. They are never read from runtime config, so a malicious parent cannot substitute them over the vsock config channel. That is the whole point of the enclave custody model.
These parameters come from a RON file resolved at build time. Copy the tracked template and edit it:
cp custody/ceremony.ron.example custody/ceremony.ron( generation: 1, // monotonic; bump when the key set / threshold / recipients change kms_threshold: 2, // Shamir M — minimum shares to reconstruct the master key (1 <= M <= N) kms_keys: [ // the N custody keys; each lists one logical key's regional replica ARNs (arns: ["arn:aws:kms:us-east-1:111122223333:key/..."]), (arns: ["arn:aws:kms:us-east-1:444455556666:key/..."]), (arns: ["arn:aws:kms:us-east-1:777788889999:key/..."]), ], retired_kms_keys: [], // old keys kept decryptable across a rotation; empty in steady state root_token_recipients: [ // age X25519 recipients; any one can decrypt the root token "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p", ],)custody/*.ron is gitignored — never commit a real ceremony. Build the EIF and pod image, passing the file as the final argument. This first pass bakes only the ceremony — a bootable Nitro EIF also requires the signed-config trust manifest (below), where the command grows to its full form:
just docker-enclave path/to/signing-cert.pem path/to/signing-key.pem \ containment-chamber:my-custody-tag custody/ceremony.ronThe recipe prints the resulting PCR0. Because the ceremony is part of the measured binary, changing it changes PCR0 — admit the new value in every region’s KMS key policy (see AWS Setup) before the rebuilt image can decrypt its shares.
Pinning configuration into the image
Section titled “Pinning configuration into the image”The ceremony locks the custody parameters. The same build-time mechanism can lock other security-critical config so the parent cannot override it over the vsock config channel — a per-field overlay baked into the EIF and measured into PCR0.
The motivating case is the mnemonic-backup recipients (chamber.keys.backup.recipients). Left to runtime config, a malicious parent could point them at an age key it controls and exfiltrate every generated validator mnemonic. Pinning the recipients into the image closes that: a conflicting parent value is rejected at boot.
Pins come from their own gitignored RON file. Each field is optional — list only the leaves this image must lock:
( backup_recipients: Some([ "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p", ]),)custody/*.ron is gitignored — never commit a real one. Pass the file as the fifth argument, after the ceremony file. Pinning is optional — the trust manifest below is still required before this EIF will boot:
just docker-enclave path/to/signing-cert.pem path/to/signing-key.pem \ containment-chamber:my-custody-tag custody/ceremony.ron custody/pinned.ronLike the ceremony, the pins are part of the measured binary, so changing them changes PCR0 — re-admit the new value in your KMS key policies (see AWS Setup) before the rebuilt image can decrypt its shares.
How a pin behaves at boot
Section titled “How a pin behaves at boot”For each pinned leaf, the runtime/vsock config is reconciled against the image value, fail-closed:
| Runtime/vsock config for a pinned leaf | Result |
|---|---|
| unset (or empty) | takes the pinned value |
| set to the same value | accepted |
| set to a different value | boot aborts — the parent cannot override a pinned leaf |
A leaf that is not pinned stays fully runtime-tunable, and all pins are per-leaf opt-in — including the signer_state.backend.table / anti_slashing.table storage-identity pins. The signed config + replay gate already bind the (full-ARN) table identity, so pinning the tables is optional defense-in-depth, not required. Standard (non-Nitro) mode requires no pins.
Baking the signed-config trust manifest
Section titled “Baking the signed-config trust manifest”The ceremony locks who holds the master key; the signer manifest locks who may author the runtime config. On a signed Nitro build the chamber verifies the vsock-delivered config against a quorum of trusted public keys (operators + CI) before accepting it, so a compromised parent cannot feed it a tampered config. Like the ceremony, those trust roots are baked in and measured into PCR0 — the parent cannot swap them.
Like the other custody inputs it holds only public keys, but the trusted signer set is per-deployment — so it is gitignored too, committed only in your private fork. There, being public, it stays reviewable and checkable against the EIF’s baked trust set via the containment-chamber config verify CLI:
// custody/config-signers.ron (per-deployment — public keys only)( threshold: 2, // distinct trusted keys that must verify required: ["github-ci"], // signer ids that MUST be present in any accepted set signers: [ (id: "operator-a", role: operator, alg: ecdsa_p256, key_pem: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"), (id: "github-ci", role: ci, alg: ed25519, key_pem: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"), ],)Pass it as the sixth argument, after the ceremony and the (optional) pinned file — pass "" in the pinned slot to skip pinning entirely, or keep custody/pinned.ron to pin backup recipients (and optionally the storage tables):
just docker-enclave path/to/signing-cert.pem path/to/signing-key.pem \ containment-chamber:my-custody-tag \ custody/ceremony.ron custody/pinned.ron custody/config-signers.ronBuilding the EIF in CI (private fork)
Section titled “Building the EIF in CI (private fork)”The upstream release workflow builds a stateless reference EIF (no ceremony.ron/config-signers.ron → refuses to boot) only to publish a reproducible PCR0. To get a bootable custody EIF from CI, build it in a private fork that commits its own RON inputs:
-
Fork privately and commit your custody inputs. All
custody/*.ronis gitignored (per-deployment), so force-add whatever you bake:Terminal window git add -f custody/ceremony.ron custody/config-signers.ron # required — force-add (all gitignored)git add -f custody/pinned.ron # optional pins (omit if you pin nothing)git commit -m "chore: bake custody inputs for my deployment" -
Add the signing secrets to the fork —
ENCLAVE_SIGNING_CERTandENCLAVE_SIGNING_KEY(the EIF signing certificate and key). -
Push a
v*tag. Thebuild-enclavejob detects the committed RON files, bakes them (CEREMONY_FILE/PINNED_FILE/SIGNERS_FILE) into the measured EIF, publishes the pod image, and emits release notes whose reproduce command regenerates that exactPCR0.
Images
Section titled “Images”The chart uses one image repository and selects the image tag from the deployment mode:
| Tag | Contents | Where It Runs |
|---|---|---|
v1.0.0 |
Signer binary (scratch base) | Standard mode — EC2, ECS, K8s |
v1.0.0-nitro |
Pod controller + vsock proxies + EIF baked in | Parent EC2 instance, manages the enclave |
When image.tag is unset, standard mode defaults to the chart app version and enclave mode defaults to <appVersion>-nitro. If you override image.tag, provide the complete tag yourself, for example v1.0.0-nitro for enclave mode.
The Nitro image contains the EIF (Enclave Image File) baked in — no S3 download at boot.
Helm Chart
Section titled “Helm Chart”The standard Containment Chamber chart supports enclave mode via enclave.enabled: true. In enclave mode the runtime config MUST be the operator-signed config, passed verbatim with --set-file signedConfig= so its signature trailer survives to the enclave (a plain --set-json config=… has no trailer and the enclave rejects it as TrailerMissing):
helm install containment-chamber \ oci://ghcr.io/unforeseen-consequences/charts/containment-chamber \ --set enclave.enabled=true \ --set enclave.cpuCount=2 \ --set enclave.memoryMib=2048 \ --set serviceAccount.annotations."eks\.amazonaws\.com/role-arn"=arn:aws:iam::ACCOUNT_ID:role/ROLE_NAME \ --set-file signedConfig=signed-config.yamlWhen enclave.enabled=true, the chart automatically:
- Uses the
<appVersion>-nitroimage tag by default whenimage.tagis unset - Requests
aws.ec2.nitro/nitro_enclaves: "1"device resource - Requests
aws.ec2.nitro/nitro_enclaves_cpusmatchingenclave.cpuCount - Requests
hugepages-<size>matchingenclave.hugepages.amount - Sets the parent-side container env vars (
ENCLAVE_CPU_COUNT,ENCLAVE_MEMORY_MIB,HEALTH_PORT,METRICS_PORT,INGRESS_PORT_TLS) - Adds a
preStophook to terminate the enclave on pod shutdown
Resource Accounting
Section titled “Resource Accounting”The enclave uses three separate resource types in K8s:
| Resource | What it represents | Pod value |
|---|---|---|
aws.ec2.nitro/nitro_enclaves |
Enclave slots (max 4 per node) | Always "1" |
aws.ec2.nitro/nitro_enclaves_cpus |
CPUs dedicated to the enclave | enclave.cpuCount (e.g., "2") |
resources.requests.cpu |
Host-side CPUs for proxy processes | Small (e.g., "250m") |
Scaling
Section titled “Scaling”Enclave pods scale like any other K8s workload:
- Multiple replicas — set
replicaCount > 1for HA. Replicas can run on the same node (up to 4 enclave slots per node) or across different nodes. - HPA — works normally. New pods trigger Cluster Autoscaler if no node has a free enclave slot.
- RollingUpdate — works when there’s a free enclave slot on any node. The new pod starts on an available slot while the old one drains.
Verifying the Deployment
Section titled “Verifying the Deployment”# Check pod is runningkubectl get pods -l app.kubernetes.io/name=containment-chamber
# Check the enclave is running inside the podkubectl exec -it <pod-name> -- nitro-cli describe-enclaves
# Test the signing APIkubectl port-forward svc/containment-chamber 9443:9443containment-chamber operator status \ --auth-token env:AUTH_TOKEN \ --signer-url https://localhost:9443PostgreSQL Anti-Slashing in Enclave Mode
Section titled “PostgreSQL Anti-Slashing in Enclave Mode”Inside an enclave, every outbound connection goes through a parent-side
vsock-proxy. Reaching an external PostgreSQL (RDS, self-hosted, etc.)
from the enclave is just an extra entry under
config.tee.nitro.egress.endpoints[] — no PostgreSQL-specific knobs in
the chart, and no per-service env vars in the parent.
Add the RDS endpoint to the Nitro egress endpoint list:
config: tls: mode: ratls tee: platform: nitro nitro: egress: endpoints: - hostname: kms.us-east-1.amazonaws.com loopback: 127.0.0.2 vsock_port: 8000 - hostname: dynamodb.us-east-1.amazonaws.com loopback: 127.0.0.3 vsock_port: 8001 # PostgreSQL — non-443 services declare their TCP port: - hostname: my-db.abc123.us-east-1.rds.amazonaws.com port: 5432 loopback: 127.0.0.4 vsock_port: 8003 signer_state: backend: dynamodb table: arn:aws:dynamodb:us-east-1:ACCOUNT_ID:table/containment-signer-stateEach endpoint becomes one Helm-rendered vsock-proxy process on the
parent and one /etc/hosts line + TCP forwarder inside the enclave. Then
point the anti-slashing backend at the same hostname:
config: anti_slashing: backend: postgres url: "host=my-db.abc123.us-east-1.rds.amazonaws.com port=5432 user=validator password=... dbname=slashing sslmode=require"Prefer key-value connection strings (host=... port=...). If
anti_slashing.force_ipv4: true is enabled, Containment Chamber can only
inject hostaddr=<ip> automatically into key-value strings; URL format
(postgres://...) is passed through unchanged. In enclave mode the
host= value is still the real RDS hostname, while /etc/hosts maps it
to the declared loopback IP so TLS validates against the RDS certificate
hostname.
If you must use URL format and also need force_ipv4: true, add
hostaddr=<loopback-ip> as a URL query parameter yourself.
Network Policy
Section titled “Network Policy”Allow egress from the enclave parent pod/node to every upstream hostname
or CIDR listed in config.tee.nitro.egress.endpoints[]. For PostgreSQL,
add the RDS CIDR manually when using Cilium, Kubernetes NetworkPolicy, or
security-group rules — this is not inferred from the Helm chart values.
Limitations
Section titled “Limitations”- Declare every hostname. The egress layer is an explicit allowlist. If the application may dial a writer endpoint, reader endpoint, STS endpoint, or regional AWS service endpoint, add each hostname separately with a unique loopback IP and vsock port. Prefer Aurora’s cluster/writer endpoint for HA instead of changing hostnames at runtime.
- No IAM authentication at the egress layer. RDS IAM auth is handled at the application layer by the PostgreSQL connection string.
Next Steps
Section titled “Next Steps”- Enclave Operations — logging, metrics, PCR rotation, and troubleshooting
- RA-TLS — client-side verification and the TOFU trust model
- Production Hardening — network policies and certificate rotation

