Deploy Containers
Deploy a container application on the Privasys Developer Platform inside Enclave OS Virtual (TDX).
Container applications run inside Enclave OS Virtual on Intel TDX hardware, providing hardware-level memory encryption and attestation for standard Docker/OCI images.
Prerequisites
- A GitHub account (for authentication)
- A publicly accessible container image (from GitHub Container Registry, Docker Hub, or any OCI registry)
Submitting a container application
- Go to Dashboard > New Application
- Select Container as the application type
- Provide the container image reference (e.g.
ghcr.io/org/app:v1.0.0) - Configure the deployment settings (see below)
- Submit the application
Container settings
| Setting | Description |
|---|---|
| Image reference | Full image URI including tag or digest. Use a digest (@sha256:...) for reproducibility. |
| Port | The port your container listens on. Only one HTTP port is exposed through the RA-TLS proxy. |
| Configure endpoint | Optional config_api declaration ({ method, path }) describing the in-process endpoint the platform POSTs to once at first request, before any other traffic is allowed through. Declared via the org.privasys.config_api OCI label or in privasys.json. See Configure & Attest. |
| Encrypted storage | Toggle to enable an encrypted filesystem volume for persistent data. |
Per-deployment configuration is delivered in-process via the declared
config_apiendpoint, then frozen by the manager. The container persists what it needs (typically on the encrypted volume) and callssetConfigComplete()to lift the freeze. Subsequent restarts replay the sealed config without going back to the platform.
What happens on deployment
When a container application is deployed:
- Enclave OS Virtual boots on a TDX-enabled node
- The UEFI firmware measures the OS image into TDX RTMR registers
- The container image is pulled over an attested channel
- The container starts with the configured port and storage; the manager holds the very first request, POSTs the deployment-time configuration to
config_api, and only then unblocks normal traffic - An RA-TLS proxy terminates TLS in front of your container, serving certificates that include TDX attestation evidence
- The deployment is complete when the container passes a health check
Attestation for containers
Container deployments are attested through TDX RTMR registers:
| Register | Measures |
|---|---|
| RTMR[0] | UEFI firmware |
| RTMR[1] | OS kernel and initrd |
| RTMR[2] | OS configuration and runtime |
| RTMR[3] | Application layer (container image digest, per-app OID extensions) |
Clients can verify the full boot chain from firmware through to your application. The RA-TLS certificate includes quotes signed by the TDX hardware, which can be validated against Intel's provisioning certificates.
Image best practices
Pin to a digest. Tags like :latest or :v1 can change. Using a digest (@sha256:abc123...) ensures that attestation measurements remain stable and verifiable.
Minimise the image. Smaller images reduce the attack surface. Use multi-stage builds and distroless or scratch base images where possible.
Do not embed secrets in the image. Receive secrets at the declared config_api endpoint inside the enclave instead, and (optionally) seal them to encrypted storage for replay across restarts.
Encrypted storage
When encrypted storage is enabled, the container receives a mounted volume that is:
- Encrypted at rest using keys derived inside the enclave
- Bound to the enclave identity (sealed data)
- Transparent to the application (standard filesystem operations)
Data persists across container restarts on the same enclave but is inaccessible outside that specific enclave instance.
Networking
Traffic to and from the container flows through the RA-TLS proxy:
- Inbound: Clients connect over HTTPS. The proxy terminates TLS using the RA-TLS certificate (which embeds the TDX attestation quote). Requests are forwarded to your container on the configured port. Browsers and plain HTTPS clients, which cannot verify an RA-TLS certificate, reach the container through the platform gateway's terminated leg instead, under the rules of the next section.
- Outbound: The container can make outbound connections. DNS resolution and egress work normally.
Surfaces: private, static and public
Everything a container serves is private unless the application declares otherwise in its measured manifest. Private routes are reached over RA-TLS by attested clients, or over a sealed session by browsers; on both, nothing between the client and the enclave reads the traffic. A web application also needs two things a sealed session cannot give it: a page that loads before any session exists, and routes that third-party machines (a webhook sender, an OAuth redirect, a plain curl) can reach with ordinary HTTPS. Those are served in the clear on the platform gateway's terminated leg, where the gateway, a Privasys-operated machine outside any enclave, reads the traffic. The standard is therefore:
What an application declares public is served in the clear, visible to verifiers, and anonymous by construction. Everything else is private.
Which surface each audience gets
| Audience | Needs | Surface | Attestation | Identity the app may trust |
|---|---|---|---|---|
| Attested client (wallet, CLI, service, another enclave) | private data | private, over RA-TLS | verified by the client | bearer, mutual RA-TLS identity, spend token |
| Browser user who signed in | private data | private, over a sealed session | verified by the wallet, inherited by the SDK | X-Privasys-Sub asserted by the runtime, or the inner bearer |
| Browser visitor, not signed in | the page, public reads | static (the shell) and public (reads), in the clear | gateway-vouched only | none |
Third-party machine (webhook sender, OAuth redirect, federation peer, plain curl) | public reads, inbound events | public, in the clear | gateway-vouched only | none; the payload carries its own proof (a webhook signature, an OAuth state) |
"Gateway-vouched" is exact: on its internal leg the gateway verifies the enclave's RA-TLS certificate against the control plane's policy for the route and refuses to forward on a mismatch. A clear caller trusts Privasys operations for that check, the same tier as a non-wallet sign-in, and does not verify the hardware itself.
The three surfaces
| Surface | Reached over | Methods | Who sees the traffic | Identity the app may trust | What is on it |
|---|---|---|---|---|---|
| private | RA-TLS, or a sealed session on the terminated leg | any | the client and the enclave only | X-Privasys-Sub, the inner bearer, X-Privasys-App-*, the payer headers | everything not declared |
| static | in the clear, terminated leg | GET, HEAD | the gateway | none | the declared prefixes |
| public | in the clear, terminated leg | GET, HEAD, POST, PUT, PATCH, DELETE as declared; OPTIONS answered by the runtime | the gateway | none | the declared method and path pairs |
The declaration
Surfaces are declared in the surfaces block of privasys.json, which rides the org.privasys.manifest image label next to the tools (see the app store page for the label). The label is inside the digest-pinned image configuration that the enclave verifies at load, so the surfaces are measured: a verifier reads off the image exactly what the application serves in the clear, and changing them changes the image digest.
{
"tools": [ /* your API, as on the MCP tool discovery section below */ ],
"surfaces": {
// Path prefixes served in the clear for GET and HEAD: the browser shell
// (measured code, no user data). An entry of exactly "/" is the root
// document only. Nothing under /api can be static.
"static": ["/", "/assets/", "/favicon.svg", "/manifest.webmanifest"],
// Explicit routes served in the clear. A path ending in "/" is a prefix,
// any other path is exact. A route is one method: a public GET on a
// prefix never implies POST. "/" itself cannot be a public route.
"public": [
{ "method": "GET", "path": "/api/v1/public/" },
// "headers" lists the request headers this route needs beyond the
// default allow-list, typically a webhook sender's signature header.
// Authorization and Cookie can never be declared.
{ "method": "POST", "path": "/api/v1/webhooks/stripe", "headers": ["Stripe-Signature"] },
{ "method": "GET", "path": "/.well-known/openid-federation" }
],
// "*": public routes answer any origin, with no credentials, and the
// runtime answers OPTIONS preflights itself. "same-origin" (the default)
// sends no cross-origin headers at all; your own page needs none.
"cors": "*",
// Per-client rate limit on the clear leg. Defaults: 10 requests per
// second with a burst of 50.
"limits": { "rps": 10, "burst": 50 }
}
}Applications built before the surfaces block existed declared their shell through the org.privasys.static-unsealed-prefixes image label, a comma-separated list of prefixes. The label still works as an alias for surfaces.static (the runtime takes the union of both). New applications declare the manifest.
What the runtime enforces on a static or public request
These rules are enforced by the enclave runtime, so a mistake in application code cannot turn a public route into a channel for account data.
- Anonymous by construction. Before the application sees the request, the runtime keeps only an allow-list of request headers (
Accept,Accept-Encoding,Accept-Language,Cache-Control,Content-Type,Content-Length,If-None-Match,If-Modified-Since,Range,User-Agent,Origin,X-Forwarded-Proto,X-Forwarded-Host, and the route's declared headers) and deletes every other one.Authorization,Cookieand everyX-Privasys-*header are gone, the runtime never assertsX-Privasys-Subor a payer, and nothing on the request identifies an account. A credential that still arrives (a bearer your page attached to a public fetch) is dropped and counted: the application's attestation tab shows that credentials reached the gateway on a public route, so you learn on dev, before production. - Nothing for the browser to send. On the response the runtime keeps only
Content-Type,Content-Length,Content-Encoding,Content-Disposition,Content-Range,Accept-Ranges,Cache-Control,Expires,ETag,Last-Modified,Location,Retry-After,VaryandDate, plus its own CORS headers.Set-Cookienever passes, so no cookie is ever set on the application origin and a browser has none to send past the gateway. An application header such asX-User-Idis dropped. Static responses carryReferrer-Policy: same-origin. - The application can tell. Every request carries
X-Privasys-Transport, set by the runtime and never by a client:publicon the clear leg,sealedfrom a sealed session,attestedwhen TLS ended at the enclave. A private handler refusespublic. - No payer, no price. A route that carries an
x-privasys.pricecannot be static or public. The platform refuses the version, and the runtime refuses to load the image, with a clear message. The configure endpoint can never be public either. - Bounded. A per-client token bucket (keyed by the client address the gateway forwards) applies to every clear request, with defaults of 10 requests per second and a burst of 50, overridable in
surfaces.limits. A full bucket answers429withRetry-After. Private routes are bounded by the session and spend machinery instead. - Preflight. When
corsis"*", the runtime answersOPTIONSon a public path itself:204,Access-Control-Allow-Origin: *, the declared methods and headers, no credentials. Otherwise a preflight is refused. - No upgrade. A WebSocket, or any
Upgrade, on a public route is403. The sealed WebSocket is the only browser socket. - Everything else is private. An undeclared path on the terminated leg answers
403 sealed-transport-required.
The confirmation at deploy
Public routes run inside the enclave, so anyone can verify from the code that they leak nothing. The remaining exposure is a developer putting personal data on a route the gateway reads. The platform makes that a deliberate, recorded act: when a version's manifest adds a static prefix or a public route beyond the set an owner last confirmed, deploying or staging that version is refused until an owner confirms that those routes carry no personal data. The confirmation names the exact routes and stays with the version, who and when, next to the attestation that records the routes themselves.
The CLI asks during privasys apps deploy and privasys apps versions stage, and --confirm-surfaces answers it for scripts. The confirmation also exists as its own verb, and privasys apps describe prints what a version serves in the clear:
privasys apps versions confirm-surfaces my-app [version]
privasys apps describe my-appThe confirmation is asked once per version and only when something new goes into the clear. A version that declares nothing, or adds nothing beyond the last confirmed set, ships without a question. Because the surfaces are measured, a change to them also changes the image digest, so the owner approves the upgrade as for any measurement change (see Application lifecycle).
The gateway logs TLS metadata and route outcomes only, never a header, a query string or a body, and keeps nothing that transits it. The data you place on a declared surface is your responsibility; Privasys forwards it and retains none of it. The application's store listing and attestation tab show the routes served in the clear, so a user choosing an application sees your declaration before trusting it.
What you must never do
- Serve account data on a public route. The runtime strips every identity, so a public handler has nothing to look up a user with; do not add your own.
- Trust a header on a public route. Only the allow-list and your declared headers arrive, and an anonymous caller set them. A webhook is authenticated by its signature over the body, never by a header value alone.
- Price a public route. A public caller cannot pay. The version is refused.
- Put a secret in a path or a query string. URLs are visible to the gateway on every terminated-leg request, sealed ones included. Keep page state in the hash.
- Attach a credential to a public fetch. Call public URLs with
credentials: 'omit'and noAuthorization; anything else has crossed the gateway before the runtime drops it. - Point a vanity domain at anything but a redirect to the enclave host. A proxy in front of the enclave would read every route.
The container-app-web template is a small service with all three surfaces declared and a public handler kept in its own router, with the four curl checks as its smoke test. The tutorial walks it end to end.
MCP tool discovery
Container applications can be exposed as MCP tool servers by adding a privasys.json manifest to their GitHub repository. When the platform detects this file, it reads the declared tools and surfaces them in the Developer Platform's AI Tools tab.
Adding a privasys.json manifest
Create a privasys.json file in the root of your repository:
{
"container_mcp": {
"tools": [
{
"name": "browse",
"description": "Browse a URL and return the page content.",
"inputSchema": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The URL to browse."
}
},
"required": ["url"]
}
}
]
}
}Each tool in the tools array follows the standard MCP tool format: a name, a human-readable description, and a JSON Schema inputSchema for the parameters.
How it works
- When you create or update a container application with a GitHub commit URL, the platform checks the repository for a
privasys.jsonfile. - If
container_mcpis present, the tool manifest is stored alongside the application. - The AI Tools tab in the Developer Platform displays your container's tools, parameter schemas, and connection instructions.
- AI agents can discover and call your container's API over attested connections — the same TDX attestation that protects all container traffic.
No code changes to your container are required. The privasys.json file is read by the platform at submission time, not at runtime.
Comparison with WASM MCP
| WASM | Container | |
|---|---|---|
| Tool source | Auto-derived from WIT types and doc comments | Declared in privasys.json manifest |
| Schema format | Generated JSON Schema from WIT | Hand-written JSON Schema |
| Tool invocation | Connect RPC to WASM function | HTTP to container endpoint |
| Attestation | SGX (per-function code hash) | TDX (VM boot chain) |
See MCP Tools for the WASM-specific MCP reference.