Skip to content

EKS Enclave Deployment

Deploy Containment Chamber in a Nitro Enclave on EKS

Before deploying, ensure your EKS cluster has:

  1. 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).

  2. Nitro Enclaves Allocator — configure /etc/nitro_enclaves/allocator.yaml on 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: 4
    memory_mib: 2048
  3. 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.yaml

    Set ENCLAVE_CPU_ADVERTISEMENT=true in 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.

  4. Node labels:

    Terminal window
    kubectl label node <node-name> aws-nitro-enclaves-k8s-dp=enabled
  5. Hugepages — configure each node with hugepages that match the chart values. The default chart values request 2Gi of 1Gi hugepages, so each node that can run one default enclave pod needs two 1Gi hugepages reserved:

    Terminal window
    echo 2 | sudo tee /sys/kernel/mm/hugepages/hugepages-1048576kB/nr_hugepages

    Persist this through your node image or launch-template boot configuration. If you change enclave.hugepages.size to 2Mi, use the matching hugepages-2048kB node setting instead. Keep enclave.memoryMib aligned with the hugepage size; the default 1Gi hugepages require a multiple of 1024 MiB.

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.

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:

Terminal window
cp custody/ceremony.ron.example custody/ceremony.ron
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:

Terminal window
just docker-enclave path/to/signing-cert.pem path/to/signing-key.pem \
containment-chamber:my-custody-tag custody/ceremony.ron

The 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.

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:

custody/pinned.ron
(
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:

Terminal window
just docker-enclave path/to/signing-cert.pem path/to/signing-key.pem \
containment-chamber:my-custody-tag custody/ceremony.ron custody/pinned.ron

Like 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.

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.

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):

Terminal window
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.ron

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:

  1. Fork privately and commit your custody inputs. All custody/*.ron is 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"
  2. Add the signing secrets to the fork — ENCLAVE_SIGNING_CERT and ENCLAVE_SIGNING_KEY (the EIF signing certificate and key).

  3. Push a v* tag. The build-enclave job 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 exact PCR0.

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.

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):

Terminal window
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.yaml

When enclave.enabled=true, the chart automatically:

  • Uses the <appVersion>-nitro image tag by default when image.tag is unset
  • Requests aws.ec2.nitro/nitro_enclaves: "1" device resource
  • Requests aws.ec2.nitro/nitro_enclaves_cpus matching enclave.cpuCount
  • Requests hugepages-<size> matching enclave.hugepages.amount
  • Sets the parent-side container env vars (ENCLAVE_CPU_COUNT, ENCLAVE_MEMORY_MIB, HEALTH_PORT, METRICS_PORT, INGRESS_PORT_TLS)
  • Adds a preStop hook to terminate the enclave on pod shutdown

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")

Enclave pods scale like any other K8s workload:

  • Multiple replicas — set replicaCount > 1 for 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.
Terminal window
# Check pod is running
kubectl get pods -l app.kubernetes.io/name=containment-chamber
# Check the enclave is running inside the pod
kubectl exec -it <pod-name> -- nitro-cli describe-enclaves
# Test the signing API
kubectl port-forward svc/containment-chamber 9443:9443
containment-chamber operator status \
--auth-token env:AUTH_TOKEN \
--signer-url https://localhost:9443

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-state

Each 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.

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.

  • 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.