Backend
Your backend declares no ports. The edge reaches it on localhost at backend.port, which is why 8080 is reserved.
Minting gives your app its identity, NFT, and ACTIVE status. Deployment is the separate, later step of turning that already-ACTIVE app into a running service. The two are decoupled on purpose (mint-first / deploy-last): an ACTIVE app isn’t necessarily a running one, and deploy allocates nothing — it reuses the minted appId and only attaches runtime.
Every Smart App pod has the same layout, whether you deploy a backend, a frontend, or both:
flowchart TB
subgraph Pod["Your app's pod"]
direction TB
Init["bundle fetcher (only with a frontend)<br/>fetches your frontend bundle by SHA-256"]
Backend["backend (your image)<br/>listens on backend.port<br/>no ports of its own"]
Edge["edge proxy<br/>port 8080<br/>page requests → frontend<br/>API calls → backend<br/>adds X-App-Id header"]
end
Edge -->|localhost| Backend
Init -.->|frontend files| Edge
Backend
Your backend declares no ports. The edge reaches it on localhost at backend.port, which is why 8080 is reserved.
Edge
The edge is a proxy the platform provides, and the only container that receives traffic. It serves your frontend for browser page requests, forwards every other request to your backend on localhost, and adds an X-App-Id header to each forwarded request.
Bundle fetcher
The optional bundle fetcher runs before the others start. It fetches your frontend bundle, checked by its SHA-256, for the edge to serve.
Every request reaches the pod through the Smart Gateway, which rate-limits it per app and per calling IP. The default is 600 requests per sliding 60-second window. A caller over budget gets 429 with Retry-After from the gateway, and your backend never sees that request. The edge applies no rate limit of its own.
A deployed app’s outbound traffic is blocked by default: it can reach DNS and the platform’s own endpoint only. To call an external API, request the host in the manifest’s backend.egress.
Your image lives in a private registry project for your app. Tags there are immutable, so every new version needs a fresh backend.tag.
Your image is digest-pinned, not run by a mutable tag. Before starting your container, the platform resolves your tag to its manifest digest and runs the image as @sha256:<digest>, so re-pushing a tag can’t change what runs. The deploy check resolves the same digest and refuses the deploy if it can’t (see Trusted deploy).
The registry account you push as is one stable account per app. Its password is derived by the validators from your app’s keys, not generated per call. So:
client.deployment.init, pushCredentials and reissuePushCredentials all return the same username and password. Calling one again does not revoke the password you already hold.402.Your app runs on every cluster, and each cluster has its own registry. A single docker push reaches only one of them. So hsuite deploy copies the image to every cluster’s registry itself, before deploying, then checks that every cluster serves the exact digest you pushed. It aborts the deploy if any cluster can’t. One push credential works on every cluster’s registry, which is what makes the copy possible. hsuite deploy --skip-distribute opts out (for a single-cluster registry).
Every deploy is checked end to end — it can’t be an arbitrary container, it’s bound to the developer who signed it, and a record of it can be verified offline. Four legs, built on keys you already have (your XRPL wallet) and the validators’ post-quantum signatures:
1. SDK-gated
hsuite deploy refuses to build or sign a project that isn’t built on @hsuite/smart-engines-sdk. The host checks again on its side, by inspecting the pushed image itself rather than trusting what the attestation says.
2. Developer-signed
After pushing the image (recording its manifest digest) and uploading the optional frontend bundle (recording its bundleSha256), the CLI signs a build attestation with your XRPL key. The host verifies that signature against your app’s developer wallet and checks that the attested imageDigest equals the digest the registry actually serves. Nobody else can deploy as you.
3. Quorum-attested
The validators re-attest the build attestation with an ML-DSA-87 (post-quantum) certificate and anchor it on an HCS topic. The certificate’s contentHash equals the SDK’s computeAttestationHash, so anyone can verify the deploy offline with verifyPqcAttestation.
4. Digest-pinned + CVE-gated
The platform runs the image pinned by @sha256: digest, and the host waits for the image’s vulnerability scan to complete before admitting the deploy.
sequenceDiagram
autonumber
participant CLI as hsuite deploy
participant Reg as Your registry project
participant H as Smart Host (deploy check)
participant Q as Validators
participant HCS as HCS
CLI->>CLI: check the project — refuse if not built on the SDK
CLI->>Reg: docker push (records imageDigest)
CLI->>CLI: sign the build attestation with your XRPL key
CLI->>H: deploy { attestation, imageDigest, bundleSha256 }
H->>H: verify the signature against your developer wallet
H->>Reg: resolve the manifest digest — must equal the attested one
H->>Reg: inspect the image, wait for its vulnerability scan
H->>Q: request the quorum attestation
Q->>HCS: ML-DSA-87 certificate (contentHash == computeAttestationHash)
H->>H: start the image pinned by @sha256
By default the host refuses a deploy whose signature is missing or invalid, whose digest doesn’t match, whose image isn’t an SDK app, or whose vulnerability scan hasn’t completed. Blocking a deploy on the severity of vulnerabilities the scan finds is built, but it currently only warns.
Besides your own env values (see Secrets), the platform sets these variables in your container. You don’t set them, and the CLI never forwards your local values for the URLs:
| Variable | What it is |
|---|---|
APP_ID |
Your app’s id (appId == entityId). |
APP_OWNER_WALLET |
Your developer wallet. An SDK app can pass it as appOwnerWallet for its @AppOwnerOnly() routes instead of copying the wallet into config. |
APP_TOKEN |
A token for your app, signed by the validators. The host does not accept it yet, so a deployed app signs in with its wallet (the XRPL_SEED the CLI forwards). |
SMART_HOST_URL, VALIDATOR_URL |
The platform endpoint your SDK talks to from inside the cluster. |
BaasClient.fromEnv(process.env) reads APP_TOKEN, SMART_HOST_URL and APP_ID and returns a client, or null outside a deployed app. Until the host accepts APP_TOKEN, a deployed app signs in with its wallet instead (see SDK → Authentication).
GET /api/v3/baas/deployment/apps/:appId/status (SDK client.deployment.status(appId), CLI hsuite status) answers for an app you own:
{ "appId": "smart-app-0f3a9c…", "state": "ACTIVE", "url": "https://0f3a9c….<domain>", "runtime": { "image": "…/app:v3", "runtimeState": "RUNNING", "replicas": 2, "lastReconciledAt": "2026-10-23T09:14:05.123Z", "deploymentHistory": [ { "tag": "v3", "imageDigest": "sha256:…", "bundleSha256": "…", "deployedAt": "2026-10-23T09:13:50.000Z", "status": "active" }, { "tag": "v2", "deployedAt": "2026-10-20T11:02:11.000Z", "status": "superseded" } ] }}runtime is always there for an ACTIVE app. Before the first deploy it reads { "runtimeState": "NOT_DEPLOYED", "image": null, "replicas": 0, "deploymentHistory": [] }, so a client branches on runtime.runtimeState alone.url is the app’s public https address — the same value the deploy response returns — or null until the app is deployed (and while it is not ACTIVE). It is https://<hash>.<domain>, where <hash> is the appId without its smart-app- prefix. One rule produces it everywhere (deploy and update responses, status, list, and the marketplace listing’s hostingUrl).deploymentHistory is newest first, at most the last 10 revisions, and is what rollback validates against: rollback({ toTag }) accepts any tag listed. Each revision is recorded when it reaches RUNNING, with the image digest the platform pinned and the frontend bundle’s sha256 when they were recorded (older entries carry neither). status is active (the newest revision that reached RUNNING, which keeps serving; a SUSPENDED app serves nothing, so read state), failed (the runtime is FAILED at that revision right now; a revision that never reached RUNNING shows up as the head entry, and a failed entry disappears once a later deploy succeeds, because only successful deploys are kept), rolled_back (replaced by a re-deploy of an earlier revision, which is what rollback produces) or superseded. A deploy still in flight is not in the history yet.GET /api/v3/baas/deployment/apps (hsuite list) lists your apps that have asked for push credentials at least once, so an app appears — status: "inactive", url: null — before its first deploy. A subscribed app that has never asked for push credentials is not listed (use status(appId) for it). status there is derived from the runtime (active, deploying, error, inactive), and the response carries total.GET …/apps/:appId/metrics returns your app’s Prometheus metrics (200). Checked in this order: 403/404 (not your app), 503 with retryable: false and “metrics are not enabled on this cluster” when the cluster runs with monitoring off (retrying does not help), 409 when the app has not been deployed, and 502 only if the platform’s deployer is unreachable.The lifecycle and runtime state machines (Anatomy & lifecycle) map to concrete actions. These move the runtime axis (and ACTIVE ↔ SUSPENDED on the lifecycle axis) — they never re-mint:
| Command | Effect |
|---|---|
suspend (hsuite suspend) |
Stops routing traffic to the app, drains it and scales it to zero. |
resume (hsuite resume) |
Back to ACTIVE; traffic is routed to the app again. |
| update | Applies manifest changes and restarts with a rolling update (see below). |
| rollback | POST .../rollback { toTag } validates toTag against deploymentHistory, then runs the update path with that tag. |
delete / retire (hsuite delete) |
Requires an owner-signed retire proof (the CLI and SDK sign it for you; see Retiring an app) and is allowed even on a lapsed subscription. Drains the app, deletes its namespace, and removes its registry project. |
Each runtime transition (runtime.deploying, running, failed, suspended, resumed, retired) can be delivered to your own HTTPS endpoint: register it with client.deployment.setWebhook(appId, url). Each delivery carries X-HSuite-Signature: sha256=<hmac-sha256(secret, raw body)>, signed with the webhookSecret that call returns; recompute it over the raw body to verify the delivery.
Every app rolls out as a RollingUpdate with maxSurge: 1, maxUnavailable: 0, at every replica count: the running container keeps serving until its replacement is ready, and a rollout that stalls or fails leaves the previous revision up.
The strategy field on the deploy request is advisory: a recreate request is turned into a rolling update (with a warning).