Skip to content

Deployment

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 push credential is stable, and calling again does not rotate it

Section titled “The push credential is stable, and calling again does not rotate it”

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.
  • It is not returned once: the platform can derive it again, so you can fetch it whenever you need it instead of storing it. The CLI fetches it for you on every deploy.
  • You cannot rotate it on demand. It changes only when the platform rotates registry credentials for every app at once. The account can push only to your app’s own project, and tags there are immutable, so a leaked password can’t overwrite an existing version. Retiring the app deletes the project and the account.
  • Push credentials are issued only while the app has an active subscription; otherwise the request is refused with 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).


Trusted deploy: SDK-gated, developer-signed, quorum-attested

Section titled “Trusted deploy: SDK-gated, developer-signed, quorum-attested”

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.

Deploy strategy: always a zero-downtime RollingUpdate

Section titled “Deploy strategy: always a zero-downtime RollingUpdate”

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