Skip to main content

Deploy Your Own Image

Use this path to run your own container image on Enclava instead of a template. You build and sign the image in CI, register the app with its signer identity, and deploy it by digest.

Prerequisites​

  • An Enclava account and organization.
  • The enclava CLI.
  • Docker and a container registry. The examples below use GitHub Container Registry (GHCR).
  • A Dockerfile in the repository root.

Scaffold the app config​

From the repository root:

enclava init

init prompts for an app name and port, then writes enclava.toml and a build-and-sign workflow at .github/workflows/enclava-deploy.yml. Pass --app-name and --port to skip the prompts. With piped stdin, init uses the directory name and the Dockerfile EXPOSE port (or 3000) as defaults.

init checks the app name before writing anything, using the same rules create enforces:

  • lowercase letters, digits, and hyphens ([a-z0-9-])
  • starts with a letter
  • no consecutive hyphens
  • at most 63 characters
  • not a reserved system name

enclava prepare writes the same files without prompting on a first run. If either file already exists, it asks before overwriting; pass --yes to skip that confirmation. Without a TTY and without --yes, it fails with "not a terminal". In CI, run prepare once, commit both files, and don't re-run it over them.

The generated config:

[app]
name = "my-app"
port = 8080
command = ["/usr/local/bin/app"]

[storage]
paths = ["/data"]
size = "5Gi"
tls_size = "2Gi"

[unlock]
mode = "password"

[resources]
cpu = "1"
memory = "1Gi"

[health]
path = "/health"
interval = 30
timeout = 5

Set command to the process the image runs and storage.paths to the in-container paths that need persistent encrypted storage. Each storage.paths entry must be declared as a VOLUME in the image.

Image requirements​

Enclava runs the container on a read-only root filesystem inside the TEE. The image needs to meet these requirements:

RequirementWhy
EXPOSE <port>Optional scaffolding metadata: init uses it to suggest app.port. Runtime uses app.port from the signed config, not EXPOSE.
VOLUME ["<path>", …] for every storage.paths entryenclava-init bind-mounts each path. Without a volume declaration the bind fails with Read-only file system, which enclava status shows as tee_error: enclava_init_failed: … (os error 30).
Pin by digest at deploycreate can resolve a tag to a digest; deploy rejects tags and requires image@sha256:<digest>.
FROM python:3.12-slim
WORKDIR /app
COPY server.py .
EXPOSE 8080
VOLUME ["/data"]
CMD ["python3", "/app/server.py"]

Leaving out [storage] does not turn storage off: Enclava still defaults to paths = ["/data"]. If the app needs no persistent storage, set paths = [] explicitly and skip the VOLUME.

Build and sign the image​

Enclava requires both a cosign signature and a bound provenance manifest. The recommended path is keyless signing with GitHub Actions OIDC: GitHub's OIDC provider issues a short-lived Fulcio certificate, cosign signs the digest, Rekor records it, and GitHub publishes build provenance. You don't have to manage a signing key.

The starter workflow that init and prepare write builds, pushes, signs, and attests on every push to main. This is the minimum complete flow:

name: Build signed image

on:
push:
branches: [main]

permissions:
contents: read
packages: write
id-token: write
attestations: write

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- uses: docker/setup-buildx-action@v3

- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}

- name: Install cosign
# v4.1.2 — immutable pin; installs cosign 3.x
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6

- name: Sign image
run: >-
cosign sign --yes
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}

- name: Attest build provenance
uses: actions/attest-build-provenance@v2
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
note

Workflows generated by older CLI versions may pin cosign-installer@v3. Replace that step with the pinned v4.1.2 commit shown above.

The generated workflow ends by printing a ready-to-run enclava deploy --image <image>@<digest> command with the digest filled in. You can also read the digest from the GHCR package page or with docker buildx imagetools inspect ghcr.io/<org>/<repo>:<tag>. Enclava has to be able to pull the image: if the package is private and the pull fails, set the GHCR package to public.

To confirm the exact identity to pin, verify locally:

cosign verify \
--certificate-identity https://github.com/<org>/<repo>/.github/workflows/<workflow>.yml@refs/heads/main \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/<org>/<repo>@sha256:<digest>

Create the app​

enclava create \
--image ghcr.io/<org>/<repo>:latest \
--signer-subject https://github.com/<org>/<repo>/.github/workflows/<workflow>.yml@refs/heads/main

create registers the app, resolves the tag to a digest, and pins the signer identity. --signer-issuer defaults to the GitHub Actions OIDC issuer; set it only for a different OIDC provider. The signer subject must match the identity that signed the image, which is the workflow file path. init and prepare name that file enclava-deploy.yml.

First deploy on a fresh account

A new organization has no signing authority yet, and the first create refuses with organization signing authority is missing; run enclava key setup --backup-out <offline-backup.json> first. The full first sequence is: login (device flow — approve in the browser) → enclava key setup --backup-out <file> (passphrase prompt; --passphrase-file/--new-passphrase-file for scripts) → create → deploy (the attended claim — see below) → enclava key backup --out <file> — note the last file is a second artifact: the key setup backup holds the org signing authority, key backup exports the per-app recovery material.

Deploy​

enclava deploy \
--image ghcr.io/<org>/<repo>@sha256:<digest> \
--storage-password-file "$HOME/.enclava/my-app-storage-password"

With --storage-password-file, the first deploy waits for the TEE bootstrap endpoint and claims ownership directly; restarts unlock with the same password. No separate enclava claim is needed on this path. Without it, deploy prompts for the password interactively (or run enclava claim first).

The first claim must run at a real terminal

The claim is one-time: the TEE returns the recovery mnemonic exactly once, so the CLI refuses unattended claims (CI or scripts, no terminal on stdin) until response-loss recovery exists. --storage-password-file (like claim --password-file) replaces the password prompt and makes recorded output (2>&1 | tee) safe — it does not lift the terminal requirement. Hand the first deploy to a person at a terminal. The mnemonic is saved to the protected local keystore and never printed; enclava key backup exports it right after.

Verify​

enclava status

A healthy deploy shows Status: running, TEE: unlocked, Unlock: unlocked. On failure, status shows the tee_error from enclava-init. Check it first when a deploy stalls.

Config and secrets​

Deliver runtime configuration through Enclava rather than baking it into the image:

enclava config set FEATURE_FLAG=enabled
enclava config get

config set accepts only inline KEY=VALUE arguments, which may enter local process listings and shell history; do not use it for sensitive plaintext. For sensitive values, redeploy with deploy --set-file KEY=/secure/path/token. Values travel directly from the CLI to your attested TEE; Enclava does not store or return their plaintext, and config get lists names only.

Recovery​

warning

Export recovery material immediately after the first deploy. Without it, a lost storage password means your data can't be opened again.

enclava key backup --out "$HOME/.enclava/my-app-recovery.json"

Keep the backup outside the repository. enclava recover --mnemonic-file … can re-derive ownership from it after a lost password.

Storage unlock​

By default your app's encrypted storage is protected by the password you set on the first deploy, and each restart needs an unlock. To let the app restart unattended, enable auto-unlock after the first deploy:

enclava auto-unlock enable --image ghcr.io/<org>/<repo>@sha256:<digest>

Storage, unlock and recovery explains both modes and how recovery works.

Troubleshooting​

  • tee_error: … Read-only file system (os error 30): a storage.paths entry is not a VOLUME in the image. Add the VOLUME and redeploy.
  • portable_verification_material_unavailable: Enclava could not find usable signature or provenance material for the image. A common cause is a legacy cosign 2.x .sig object without portable DSSE, but registry or referrer errors, missing provenance, malformed bundles, and size limits return the same code. Inspect both the signature and the provenance objects.
  • First deploy in auto mode is rejected: set [unlock] mode = "password" for the first deploy.
  • A failed deploy does not roll on a follow-up deploy: a deploy that fails partway can leave the app with Status: drifted. Run enclava destroy --app <name> --force, then create and deploy again from scratch.
  • destroy keeps reporting that the confidential workload teardown did not complete: by design the destroy does not finish — the app stays in the deleting state and stays wired, and the destroy is retryable once the workload is reachable again. If recovery is ruled out, enclava destroy --app <name> --force --abandon-teardown finishes it anyway and records a waiver: the previous owner seed may remain in the platform key store, and recreating the same app name is refused with stale_owner_seed until an operator erases the stale seed and clears the recorded waiver — or pick a different app name. See Storage, unlock and recovery.