diff --git a/.doc-review/state.yml b/.doc-review/state.yml
index b97d83f..20f5f0e 100644
--- a/.doc-review/state.yml
+++ b/.doc-review/state.yml
@@ -1,7 +1,7 @@
# Documentation review state — tracks audit progress and terminology decisions.
# Managed by /doc-review skill. Committed to version control.
version: 1
-last_run: 2026-03-26T12:00:00Z
+last_run: 2026-04-10T14:00:00Z
docs_root: .
phase: 3
documents:
@@ -114,9 +114,14 @@ documents:
- "CVM not introduced on first use (acceptable for reference page)"
- "Replay protection buried in accordion"
phala-cloud/references/error-codes.mdx:
- phase: 0
+ phase: 3
baseline: 2026-03-24T16:00:00Z
- issues: 4
+ structure: 2026-04-10T12:00:00Z
+ copy: 2026-04-10T12:00:00Z
+ consistency: 2026-04-10T12:00:00Z
+ issues: 0
+ deferred:
+ - "HTTP status codes not shown as separate column — embedded in descriptions for non-standard codes only (400 is default for unlisted errors)"
phala-cloud/references/cloud-js-sdk/overview.mdx:
phase: 0
baseline: 2026-03-24T16:00:00Z
@@ -208,6 +213,39 @@ documents:
consistency: 2026-03-26T12:00:00Z
issues: 0
deferred: []
+ phala-cloud/references/cloud-js-sdk/add-compose-hash.mdx:
+ phase: 4
+ baseline: 2026-04-10T12:00:00Z
+ copy: 2026-04-10T12:00:00Z
+ issues: 0
+ deferred: []
+ phala-cloud/references/cloud-js-sdk/cvm-configuration.mdx:
+ phase: 4
+ baseline: 2026-04-10T12:00:00Z
+ copy: 2026-04-10T12:00:00Z
+ issues: 0
+ deferred: []
+ phala-cloud/key-management/deploying-with-onchain-kms.mdx:
+ phase: 2
+ baseline: 2026-04-10T12:00:00Z
+ structure: 2026-04-10T12:00:00Z
+ copy: 2026-04-10T12:00:00Z
+ issues: 0
+ deferred: []
+ phala-cloud/key-management/device-management.mdx:
+ phase: 2
+ baseline: 2026-04-10T12:00:00Z
+ structure: 2026-04-10T12:00:00Z
+ copy: 2026-04-10T12:00:00Z
+ issues: 0
+ deferred: []
+ phala-cloud/key-management/multisig-governance.mdx:
+ phase: 2
+ baseline: 2026-04-10T12:00:00Z
+ structure: 2026-04-10T12:00:00Z
+ copy: 2026-04-10T12:00:00Z
+ issues: 0
+ deferred: []
terminology:
preferred:
"Phala Cloud": []
diff --git a/docs.json b/docs.json
index daaf53e..2b076f2 100644
--- a/docs.json
+++ b/docs.json
@@ -1,6 +1,5 @@
{
"$schema": "https://mintlify.com/docs.json",
- "openapi": "openapi.json",
"colors": {
"dark": "#9FDC00",
"light": "#E8FF7A",
@@ -195,6 +194,7 @@
"/phala-cloud/cvm/set-secure-environment-variables",
"/phala-cloud/cvm/create-with-private-docker-image",
"/phala-cloud/cvm/multi-replica-scaling",
+ "/phala-cloud/cvm/replicating-cvms",
"/phala-cloud/cvm/deployment-cheat-sheet",
"/phala-cloud/cvm/image/overview",
"/phala-cloud/storage/access-database",
@@ -326,13 +326,22 @@
]
},
{
- "group": "Key Management",
+ "group": "Onchain KMS",
"pages": [
"/phala-cloud/key-management/key-management-protocol",
"/phala-cloud/key-management/cloud-vs-onchain-kms",
- "/phala-cloud/references/cloud-js-sdk/on-chain-kms-guide",
- "/phala-cloud/key-management/get-a-key",
- "/phala-cloud/key-management/create-crypto-wallet"
+ "/phala-cloud/key-management/understanding-onchain-kms",
+ "/phala-cloud/key-management/deploying-with-onchain-kms",
+ "/phala-cloud/key-management/updating-with-onchain-kms",
+ "/phala-cloud/key-management/device-management",
+ "/phala-cloud/key-management/multisig-governance",
+ {
+ "group": "Derived Keys",
+ "pages": [
+ "/phala-cloud/key-management/get-a-key",
+ "/phala-cloud/key-management/create-crypto-wallet"
+ ]
+ }
]
},
{
@@ -781,7 +790,12 @@
}
]
},
+ "openapi": "openapi.json",
"redirects": [
+ {
+ "destination": "/phala-cloud/key-management/deploying-with-onchain-kms",
+ "source": "/phala-cloud/references/cloud-js-sdk/on-chain-kms-guide"
+ },
{
"destination": "/phala-cloud/phala-cloud-cli/login",
"source": "/phala-cloud/references/phala-cloud-cli/phala/auth"
diff --git a/images/cloud/app-overview-onchain-kms.jpg b/images/cloud/app-overview-onchain-kms.jpg
new file mode 100644
index 0000000..2f8857a
Binary files /dev/null and b/images/cloud/app-overview-onchain-kms.jpg differ
diff --git a/images/cloud/app-overview-pending-update.jpg b/images/cloud/app-overview-pending-update.jpg
new file mode 100644
index 0000000..bd6fa2f
Binary files /dev/null and b/images/cloud/app-overview-pending-update.jpg differ
diff --git a/images/cloud/configure-dstackapp-contract-deploy-new.jpg b/images/cloud/configure-dstackapp-contract-deploy-new.jpg
new file mode 100644
index 0000000..27dce4c
Binary files /dev/null and b/images/cloud/configure-dstackapp-contract-deploy-new.jpg differ
diff --git a/images/cloud/deploy-wizard-kms-provider-base.jpg b/images/cloud/deploy-wizard-kms-provider-base.jpg
new file mode 100644
index 0000000..c6e9d75
Binary files /dev/null and b/images/cloud/deploy-wizard-kms-provider-base.jpg differ
diff --git a/images/cloud/device-allowlist.jpg b/images/cloud/device-allowlist.jpg
new file mode 100644
index 0000000..4a536d8
Binary files /dev/null and b/images/cloud/device-allowlist.jpg differ
diff --git a/images/cloud/wallet-popup-deploy-and-register-app.jpg b/images/cloud/wallet-popup-deploy-and-register-app.jpg
new file mode 100644
index 0000000..e3a0125
Binary files /dev/null and b/images/cloud/wallet-popup-deploy-and-register-app.jpg differ
diff --git a/images/cloud/webhook-created-signing-secret.jpg b/images/cloud/webhook-created-signing-secret.jpg
new file mode 100644
index 0000000..e688182
Binary files /dev/null and b/images/cloud/webhook-created-signing-secret.jpg differ
diff --git a/images/cloud/webhook-deliveries.jpg b/images/cloud/webhook-deliveries.jpg
new file mode 100644
index 0000000..5c7a98e
Binary files /dev/null and b/images/cloud/webhook-deliveries.jpg differ
diff --git a/images/cloud/webhooks-list.jpg b/images/cloud/webhooks-list.jpg
new file mode 100644
index 0000000..f5e133e
Binary files /dev/null and b/images/cloud/webhooks-list.jpg differ
diff --git a/images/cloud/workspace-home-deploy-button.jpg b/images/cloud/workspace-home-deploy-button.jpg
new file mode 100644
index 0000000..44f8b7e
Binary files /dev/null and b/images/cloud/workspace-home-deploy-button.jpg differ
diff --git a/phala-cloud/cvm/create-with-docker-compose.mdx b/phala-cloud/cvm/create-with-docker-compose.mdx
index 4b9a27e..30a3469 100644
--- a/phala-cloud/cvm/create-with-docker-compose.mdx
+++ b/phala-cloud/cvm/create-with-docker-compose.mdx
@@ -1,5 +1,5 @@
---
-description: Create and manage Confidential Virtual Machines (CVMs) on Phala Cloud.
+description: Deploy a CVM from a docker-compose.yml file using the Phala Cloud dashboard.
title: CVM with Docker Compose
---
diff --git a/phala-cloud/cvm/create-with-private-docker-image.mdx b/phala-cloud/cvm/create-with-private-docker-image.mdx
index b536397..b072870 100644
--- a/phala-cloud/cvm/create-with-private-docker-image.mdx
+++ b/phala-cloud/cvm/create-with-private-docker-image.mdx
@@ -15,7 +15,7 @@ title: Private Container Registry
Phala Cloud supports private image pulls during CVM startup. Registry credentials are passed as encrypted environment variables and only used at runtime inside the trusted boot flow.
-## Method 1: Configure in Cloud UI
+## Method 1: Configure from the Dashboard
1. Open CVM creation and go to **Advanced Features**.
2. Open **Private Container Registry**.
diff --git a/phala-cloud/cvm/deployment-cheat-sheet.mdx b/phala-cloud/cvm/deployment-cheat-sheet.mdx
index c1e564b..fedfd02 100644
--- a/phala-cloud/cvm/deployment-cheat-sheet.mdx
+++ b/phala-cloud/cvm/deployment-cheat-sheet.mdx
@@ -1,5 +1,5 @@
---
-description: Create and manage Confidential Virtual Machines (CVMs) on Phala Cloud.
+description: Common gotchas and quick fixes when deploying CVMs, including cross-architecture image builds and Docker log sizing.
title: Deployment Cheat Sheet
---
diff --git a/phala-cloud/cvm/multi-replica-scaling.mdx b/phala-cloud/cvm/multi-replica-scaling.mdx
index 4aba34c..ad7674e 100644
--- a/phala-cloud/cvm/multi-replica-scaling.mdx
+++ b/phala-cloud/cvm/multi-replica-scaling.mdx
@@ -1,197 +1,74 @@
---
title: Scale with Multiple Replicas
-description: Run multiple copies of your CVM for availability and load distribution using Terraform, SDKs, or the CLI.
+description: Understand when replicas make sense, what you get from them, and where they fall short. For concrete replication steps, see Replicating CVMs.
---
-Replicas let you run multiple copies of the same application across different TEE nodes. Each replica is an independent CVM that shares the same Docker Compose configuration and environment variables, but runs in its own isolated TEE enclave. This gives you both horizontal scaling and fault tolerance.
+Replicas let you run several copies of the same application side by side on Phala Cloud. This page covers the concepts and the decision criteria. For concrete dashboard and CLI steps, see [Replicating CVMs](/phala-cloud/cvm/replicating-cvms).
-## How Replicas Work
+## What a Replica Is
-When you create replicas of an app, Phala Cloud provisions separate CVMs that share a single `app_id`. All replicas use the same compose file and encrypted secrets. If you update the compose or environment, every replica picks up the change.
+A replica is an independent CVM instance provisioned from an existing source CVM. Replicas of the same app share an `app_id` and the same compose hash, but each replica has its own:
-Each replica gets its own CVM ID, endpoint URL, and TEE attestation. Traffic is not automatically load-balanced between replicas — add an external load balancer or DNS-based routing to distribute requests.
+- `vm_uuid`, endpoint URL, and internal IP
+- TEE attestation report
+- Process state, memory, and local disk
+- Billing meter
-## Deploy with Terraform
+Replicas are created one at a time from an existing source, not as an atomic group. If you ask for three replicas, Phala Cloud creates three independent CVMs whose only formal relationship is the shared `app_id`.
-Terraform is the most straightforward way to manage replicas declaratively. Set the `replicas` attribute on a `phala_app` resource and Terraform handles creation, updates, and teardown.
+## What Replicas Give You
-```hcl
-resource "phala_app" "api" {
- name = "api-service"
- size = "tdx.medium"
- region = "US-WEST-1"
- image = "dstack-dev-0.5.7-9b6a5239"
- disk_size = 40
- replicas = 3
+**Horizontal capacity.** More replicas serve more concurrent traffic, assuming your workload is stateless or has a shared data layer behind it.
- docker_compose = <<-YAML
- services:
- api:
- image: myregistry/api:latest
- ports:
- - "8080:8080"
- YAML
+**Failure isolation.** If one replica's node goes down for maintenance, the others keep serving. A single CVM has no such fallback.
- wait_for_ready = true
- wait_timeout_seconds = 900
-}
+**Regional placement.** You can pin different replicas to nodes in different regions by passing a `node_id` when you replicate, which reduces latency for geographically distributed users.
-output "cvm_ids" {
- value = phala_app.api.cvm_ids
-}
+**Independent attestation.** Each replica produces its own TDX quote. A relying party can verify any individual replica without trusting the others, which matters when the attestation is part of your security story.
-output "endpoint" {
- value = phala_app.api.endpoint
-}
-```
+## What Replicas Do Not Give You
-To scale up or down, change the `replicas` value and run `terraform apply`. The provider adds or removes CVMs to match.
+**Load balancing.** Phala Cloud does not distribute traffic across replicas. Each replica gets its own public endpoint. You need an external load balancer, DNS round-robin, or service mesh to split traffic.
-```bash
-# Scale from 3 to 5 replicas
-terraform apply -var="replica_count=5" -auto-approve
-```
+**Shared state.** Replicas do not share filesystems, memory, or any in-process state. Two replicas writing to their local disks write to two separate disks. If your app keeps state locally, replicating it will cause divergence.
-
-Scaling down deletes the newest replicas. Make sure your application handles graceful shutdown, since in-flight requests on removed replicas will be interrupted.
-
+**Automatic propagation of compose updates.** Updating the compose of one replica does not update the others. Each replica is its own CVM and must be updated individually. See [Upgrade Application](/phala-cloud/update/upgrade-application) for the update flow.
-## Deploy with SDKs
+**Linear performance scaling.** Two replicas do not automatically serve twice the throughput. The upstream bottleneck (database, queue, external API) often dominates. Measure before assuming.
-The SDKs expose a `replicateCvm` method that creates a copy of an existing CVM. Call it multiple times to create the number of replicas you need.
-
-
-
-```typescript JavaScript
-import { createClient } from "@phala/cloud";
-
-const client = createClient({ apiKey: process.env.PHALA_CLOUD_API_KEY });
-
-// Create 2 replicas of an existing CVM
-const sourceCvmId = "app_abc123";
-
-const replica1 = await client.replicateCvm({
- id: sourceCvmId,
- name: "api-replica-1",
-});
-
-const replica2 = await client.replicateCvm({
- id: sourceCvmId,
- name: "api-replica-2",
-});
-
-console.log("Replica 1:", replica1);
-console.log("Replica 2:", replica2);
-```
-
-```python Python
-from phala_cloud import create_client
-
-client = create_client()
-
-source_cvm_id = "app_abc123"
-
-replica1 = client.replicate_cvm({
- "id": source_cvm_id,
- "node_id": 5, # optionally pin to a specific node
-})
-
-replica2 = client.replicate_cvm({
- "id": source_cvm_id,
- "node_id": 7,
-})
-
-print("Replica 1:", replica1)
-print("Replica 2:", replica2)
-```
-
-```go Go
-package main
-
-import (
- "context"
- "fmt"
- "log"
-
- phala "github.com/Phala-Network/phala-cloud/sdks/go"
-)
-
-func main() {
- client, err := phala.NewClient()
- if err != nil {
- log.Fatal(err)
- }
-
- ctx := context.Background()
- sourceCvmID := "app_abc123"
-
- replica1, err := client.ReplicateCVM(ctx, sourceCvmID, nil)
- if err != nil {
- log.Fatal(err)
- }
-
- replica2, err := client.ReplicateCVM(ctx, sourceCvmID, nil)
- if err != nil {
- log.Fatal(err)
- }
-
- fmt.Printf("Replica 1: %+v\n", replica1)
- fmt.Printf("Replica 2: %+v\n", replica2)
-}
-```
-
-
-
-## Deploy with CLI
-
-The CLI doesn't have a dedicated replica command, but you can deploy the same compose file multiple times with different names. Each deployment creates an independent CVM.
-
-```bash
-export PHALA_CLOUD_API_KEY="phak_your_key"
-
-phala deploy -n api-primary -t tdx.medium --wait
-phala deploy -n api-replica-1 -t tdx.medium --wait
-phala deploy -n api-replica-2 -t tdx.medium --wait
-```
-
-To replicate from an existing CVM programmatically, use the SDK or Terraform approach instead.
-
-## Monitor Replica Health
+## When to Use Replicas
-Once your replicas are running, check their status from the dashboard or CLI.
+Use replicas when:
-**Dashboard:** Navigate to your app in the CVMs page. Each replica appears as a separate CVM entry with its own status indicator.
+- The workload is **stateless**, or reads and writes go to a shared backing store (managed Postgres, object storage, an external queue).
+- You need **high availability** and one CVM outage would be unacceptable.
+- You want to **distribute traffic geographically** and you have a way to route users to the nearest replica.
+- The attestation model assumes **independent instances** — each replica must be verifiable on its own.
-**CLI:**
+Avoid replicas when:
-```bash
-# List all CVMs and filter by name pattern
-phala cvms list
-```
+- The workload keeps **state on local disk** and you have no replication layer. Two divergent copies of the same database do not make a cluster.
+- The workload assumes it is the **only writer** to an external resource (a singleton job scheduler, a leader-elected worker).
+- You are trying to solve a **vertical scaling** problem. If one replica is slow because it is CPU- or memory-bound, bigger instance types often help more than more copies. See [Resize Resources](/phala-cloud/update/resize-resource).
-**SDK (polling):**
+## Cost Model
-```typescript
-const client = createClient({ apiKey: process.env.PHALA_CLOUD_API_KEY });
+Each replica is billed as an independent CVM at the full rate of its instance type. Three `tdx.medium` replicas cost three times one `tdx.medium`. There is no bulk discount for adding replicas. Factor this into your scaling budget before raising the replica count.
-const cvms = await client.getCvmList();
-for (const cvm of cvms.items) {
- const state = await client.getCvmState({ id: cvm.id });
- console.log(`${cvm.name}: ${state.status}`);
-}
-```
+Stopping a replica pauses compute billing but keeps its disk allocation, which is billed separately. Deleting a replica releases both.
-
-Each replica consumes its own resources and billing. Three replicas of a `tdx.medium` instance cost three times the single-instance price.
-
+## How to Create Replicas
-## When to Use Replicas
+The concrete steps live in [Replicating CVMs](/phala-cloud/cvm/replicating-cvms), which covers:
-Replicas are useful when you need high availability or want to distribute workloads across regions. They work best for stateless services like APIs, proxies, and workers. For stateful applications, consider whether your data layer supports multi-instance access before scaling out.
+- The dashboard **Scale** dialog on the app detail page
+- The `phala cvms replicate` CLI command
+- Onchain KMS workflows for contract-governed apps
+- The replicate API for custom integrations
-## Related
+## Related Documentation
-- [Terraform `phala_app` Resource](/phala-cloud/references/terraform-provider/app-resource) — full attribute reference including `replicas`
-- [CVM Lifecycle (JS SDK)](/phala-cloud/references/cloud-js-sdk/cvm-lifecycle) — `replicateCvm` method reference
-- [CVM Lifecycle (Python SDK)](/phala-cloud/references/cloud-python-sdk/cvm-lifecycle) — `replicate_cvm` method reference
-- [Deploy Your First CVM](/phala-cloud/getting-started/deploy-first-cvm) — getting started with a single CVM
+- [Replicating CVMs](/phala-cloud/cvm/replicating-cvms) — concrete dashboard, CLI, and API steps.
+- [Upgrade Application](/phala-cloud/update/upgrade-application) — how to update compose or environment, including across replicas.
+- [Resize Resources](/phala-cloud/update/resize-resource) — vertical scaling when more replicas is not the answer.
+- [Deploy Your First CVM](/phala-cloud/getting-started/deploy-first-cvm) — single-CVM getting started.
diff --git a/phala-cloud/cvm/overview.mdx b/phala-cloud/cvm/overview.mdx
index 04fd0a4..7707d21 100644
--- a/phala-cloud/cvm/overview.mdx
+++ b/phala-cloud/cvm/overview.mdx
@@ -1,70 +1,59 @@
---
-description: Create and manage Confidential Virtual Machines (CVMs) on Phala Cloud.
+description: Landing page for CVM creation and management on Phala Cloud. Links to deployment methods, scaling, and lifecycle operations.
title: CVM Overview
---
-## Choose Your Preferred Method
+## Choose Your Deployment Method
-Phala Cloud offers multiple ways to create and deploy your Confidential Virtual Machine (CVM). Learn about [Confidential Virtual Machine technology](https://phala.com/confidential-vm) and its enterprise applications.
+Phala Cloud offers multiple ways to create and deploy a Confidential Virtual Machine (CVM). Learn about [Confidential Virtual Machine technology](https://phala.com/confidential-vm) and its enterprise applications.
-### Option 1: Using the Phala Cloud UI (Recommended for Beginners)
+### Option 1: Phala Cloud dashboard
-The Phala Cloud UI walks you through CVM creation with a visual interface:
+The dashboard walks you through CVM creation with a visual interface. Use it if you are new to Phala Cloud, prefer graphical tooling, or want to deploy without touching the command line.
[**Create with Docker Compose →**](/phala-cloud/cvm/create-with-docker-compose)
-Use the Phala Cloud UI if you:
+### Option 2: `phala` CLI
-* Are new to Phala Cloud
-* Prefer graphical interfaces
-* Want quick deployments without command-line knowledge
-
-### Option 2: Using the Command Line Interface (CLI)
-
-For developers who prefer terminal-based workflows or need to automate deployments:
+The CLI is the right tool for automated deployments, CI/CD pipelines, and scriptable infrastructure. Use it if you are comfortable in a terminal and want reproducible deployments.
[**Follow the CLI Guide →**](/phala-cloud/phala-cloud-cli/start-from-cloud-cli)
-Use the CLI if you:
-
-* Need automated deployments
-* Work with CI/CD pipelines
-* Prefer terminal-based workflows
-* Want scriptable infrastructure
-
## CVM Management Features
-After deployment, you can monitor, upgrade, and resize your CVMs from the dashboard or API.
+After deployment, manage your CVMs from the dashboard or the API.
### Monitoring
-* **CVM Status**: Track the health and status of your CVMs
-* **Resource Usage**: Monitor CPU, memory, and storage consumption
-* **Logs**: View application logs and system messages
+- **CVM status** — track the health and status of your CVMs
+- **Resource usage** — monitor CPU, memory, and storage consumption
+- **Logs** — view application logs and system messages
### Upgrades
-* **Environment Variables**: Update your application's environment variables
-* **Docker Images**: Upgrade your application's Docker image
-* **Resource Allocation**: Adjust your CVM's CPU, memory, and storage
+- **Environment variables** — update your application's encrypted secrets
+- **Docker images** — upgrade your application's Docker image
+- **Resource allocation** — adjust CPU, memory, and storage
-### Resizing
+### Scaling
-* **Resource Scaling**: Dynamically adjust your CVM's CPU, memory, and storage
-* **Cost Optimization**: Reduce your cloud spending by downsizing your CVM
+- **Horizontal scaling** — run multiple replicas of the same CVM for availability and regional distribution. See [Scale with Multiple Replicas](/phala-cloud/cvm/multi-replica-scaling) for concepts and [Replicating CVMs](/phala-cloud/cvm/replicating-cvms) for concrete steps.
+- **Vertical scaling** — [resize a single CVM's CPU, memory, or disk](/phala-cloud/update/resize-resource) to handle more load per instance.
### Security
-* **Data Protection**: E2E encrypt your sensitive data at rest
-* **Access Control**: Restrict access to your CVMs and logs
+- **Data protection** — end-to-end encryption of sensitive data at rest
+- **Access control** — restrict who can reach your CVMs and their logs
## CVM Management API
-Phala Cloud provides an [API](/phala-cloud/phala-cloud-api/overview) for managing your CVMs, allowing you to automate your CVM management tasks.
+Phala Cloud provides an [API](/phala-cloud/phala-cloud-api/overview) for managing CVMs programmatically.
## Next Steps
-After creating your CVM, you'll want to:
+After creating your CVM, you probably want to:
-* [Access your applications](/phala-cloud/networking/expose-http-service)
-* [Set up monitoring](/phala-cloud/monitoring/public-logs)
+- [Set secure environment variables](/phala-cloud/cvm/set-secure-environment-variables)
+- [Expose an HTTP service](/phala-cloud/networking/expose-http-service)
+- [Set up monitoring](/phala-cloud/monitoring/public-logs)
+- [Replicate the CVM for scale or availability](/phala-cloud/cvm/replicating-cvms)
diff --git a/phala-cloud/cvm/replicating-cvms.mdx b/phala-cloud/cvm/replicating-cvms.mdx
new file mode 100644
index 0000000..86e9ce3
--- /dev/null
+++ b/phala-cloud/cvm/replicating-cvms.mdx
@@ -0,0 +1,296 @@
+---
+title: Replicating CVMs
+description: Scale an existing CVM by creating replicas on the same or different nodes. Covers the dashboard flow, the phala CLI, Cloud KMS, and Onchain KMS approval workflows.
+---
+
+Replication creates a new CVM instance with the same configuration as an existing source CVM. Use it to scale out, move a workload to a different node, or split traffic across replicas in different regions.
+
+This guide focuses on the two workflows most users should use: the Phala Cloud dashboard and the `phala` CLI. An API reference is included at the end for programmatic integrations.
+
+## Mental Model
+
+A replica is a **new, independent CVM instance** provisioned from a **source CVM**. It gets its own `vm_uuid`, endpoint, and attestation. It does not share storage, memory, or running state with the source.
+
+**Copied from source (you do not re-specify these):**
+- The compose file and pre-launch script (same `compose_hash`)
+- The application ID (all replicas of an app share one `app_id`)
+- The KMS type and chain
+- The encrypted environment variables
+
+**Things you can change at replication time:**
+- The target node (pin the replica to a specific node, or let the platform pick one)
+- The encrypted environment variables (optional override — pass a new encrypted blob to replace the inherited one)
+
+**Not carried over:**
+- Running processes, in-memory state
+- Contents of persistent volumes and any data written at runtime
+- Active network connections
+
+### The three replication workflows
+
+Which workflow applies depends on the source CVM's KMS setup. You do not pick it — it is determined by how the source was deployed.
+
+| Workflow | Applies when | What you do |
+| --- | --- | --- |
+| **Simple** | Source uses Cloud KMS or no KMS | One step: request the replica and wait for it to come up. |
+| **Onchain, auto-registered** | Source uses Onchain KMS and the DstackApp owner is an EOA with an accessible private key | The CLI detects missing on-chain registrations, signs `addComposeHash` / `addDevice` with the provided key, then creates the replica. |
+| **Onchain, manual approval** | Source uses Onchain KMS and the DstackApp owner is a Safe, timelock, or DAO | Prepare the replica, collect the on-chain payload, obtain multisig approval, then commit. |
+
+Onchain KMS adds gates because the DstackApp contract — not Phala Cloud — decides which compose hashes and devices are allowed to run under the app's identity. If the new replica lands on a node whose device is not already allowlisted, someone has to sign `addDevice` before the KMS will release keys. See [Deploying with Onchain KMS](/phala-cloud/key-management/deploying-with-onchain-kms) for the full model.
+
+## Prerequisites
+
+Before replicating:
+
+- The **source CVM** exists in your current workspace. It can be running or stopped.
+- The **target node** (if you pin one) is deployable for your workspace, has the required OS image, and has enough vCPU, memory, and disk.
+- Your **workspace has enough credit** to cover the new instance. Replicas are billed like any other CVM.
+
+For Onchain KMS workflows only:
+
+- **Gas** in your signing wallet on the chain the DstackApp lives on (typically Base — 0.001 ETH is plenty for the first few transactions).
+- **An RPC URL** for that chain. The CLI uses a default public endpoint, or you can pass `--rpc-url` / set `ETH_RPC_URL`.
+- **Either a private key** (for auto-registration) **or a multisig wallet** (for manual approval).
+
+## Replicate from the Dashboard
+
+The dashboard wraps replication in a single "Scale" action on the app detail page. It is the fastest way to add replicas interactively.
+
+### Steps
+
+1. Open the app detail page for the application you want to scale.
+2. Click **Scale** to open the Scale dialog. The dialog shows the current list of replicas and lets you add or remove instances.
+3. **Pick a source configuration.** If your app has multiple live configurations (different compose hashes), choose the one you want to replicate. If there is only one, the dashboard selects it automatically.
+4. **Choose a target node.** Leave it on **Auto** to let the platform schedule the replica, or pick a specific node from the list. The list is filtered to nodes your workspace can deploy to.
+5. **Set the number of replicas to add.** Use the `+` / `−` controls, then click the add button.
+6. The dashboard creates replicas one by one and shows their status as they provision.
+
+### When the dashboard prompts for on-chain action
+
+If the source uses Onchain KMS and the target node's device is not yet allowlisted, the Scale dialog warns you before creating the replica. You then have two options:
+
+- **Approve from the dashboard with a connected wallet.** The dashboard walks you through signing `addDevice` (and `addComposeHash` if needed) using the wallet connected to the app. This is the recommended path when the DstackApp owner is an EOA you control.
+- **Approve externally.** If the owner is a Safe or another contract, use the CLI's manual workflow described below to obtain a commit token, sign on-chain through your multisig, then commit the replica.
+
+## Replicate with the CLI
+
+The CLI is the right tool for scripting, CI/CD, and any workflow that does not start from the dashboard. It also exposes the lower-level prepare / commit flow used for multisig-gated apps.
+
+Install or update the CLI before starting:
+
+```bash
+npm install -g phala
+phala login
+```
+
+### Basic replication
+
+Replicate a CVM to a specific node:
+
+```bash
+phala cvms replicate --node-id prod6
+```
+
+`--node-id` accepts either a numeric node ID or a node name as shown in `phala nodes ls` — for example `prod6`, `prod7`, or `use2`. Ambiguous names are rejected rather than guessed, so if a name matches more than one node the CLI asks you to use the numeric ID instead.
+
+Omit `--node-id` to let the backend schedule the replica on any node with capacity and a compatible image:
+
+```bash
+phala cvms replicate
+```
+
+`` can be a CVM UUID, an `app_id`, or a unique CVM name. Prefer UUIDs in scripts — names can collide and `app_id` is ambiguous when an app has more than one live configuration (see [Multi-instance apps](#multi-instance-apps) below).
+
+On success the CLI prints the new replica's UUID, node, and dashboard URL:
+
+```
+Source CVM ID: app_abc123
+Team: my-workspace
+CVM UUID: 550e8400-e29b-41d4-a716-446655440000
+App ID: 0x1234...
+Name: my-app-replica-1
+Status: provisioning
+Node: prod6 (ID: 5)
+vCPUs: 2
+Memory: 4096 MB
+Disk Size: 40 GB
+App URL: https://cloud.phala.com/my-workspace/apps/0x1234.../instances/550e8400...
+```
+
+### Replicating with new environment variables
+
+By default the replica inherits the source CVM's encrypted environment. See [Environment Variables](/phala-cloud/cvm/set-secure-environment-variables) for the full encryption model and the rules around `allowed_envs`. To change an env var on the replica — for example, to give a staging replica a different database URL — pass `--env-file`:
+
+```bash
+phala cvms replicate --node-id prod6 --env-file .env.prod
+```
+
+The CLI:
+
+1. Parses the env file locally.
+2. Fetches the CVM's per-application encryption public key from the backend.
+3. Encrypts each env var with that key.
+4. Sends only the ciphertext to the replicate endpoint.
+
+Plaintext env values never leave your machine. Only the running CVM, after the KMS releases its keys, can decrypt them. Env var names you pass with `--env-file` must already appear in the app's `allowed_envs` list — the list is fixed by the compose hash, so replication cannot introduce new names.
+
+### Multi-instance apps
+
+When an app has multiple live CVMs with different compose hashes — for example, a canary deployment alongside the stable version — the CLI needs to know which configuration you want to replicate. Running the basic command with just the `app_id` fails:
+
+```
+$ phala cvms replicate app_abc123
+Error: ERR-03-009 — This app has multiple live CVM instances.
+ Please specify compose_hash to choose which revision to replicate.
+```
+
+Two ways to fix it:
+
+- **Point at a specific source instance** by UUID. The UUID uniquely identifies one compose hash.
+
+ ```bash
+ phala cvms replicate 550e8400-e29b-41d4-a716-446655440000 --node-id prod6
+ ```
+
+- **Pass `--compose-hash` explicitly.** Look up the compose hash of the configuration you want (from the dashboard or `phala cvms list`) and pass it on the command line:
+
+ ```bash
+ phala cvms replicate app_abc123 --compose-hash 0xabcd... --node-id prod6
+ ```
+
+### Onchain KMS: auto-registration
+
+When the source CVM uses Onchain KMS and you own the DstackApp through an EOA, you can register and replicate in one command by passing a private key. The CLI detects whether the target node's device and the compose hash are already allowlisted and only writes the transactions that are missing.
+
+```bash
+export PRIVATE_KEY=0x...
+export ETH_RPC_URL=https://base-mainnet.example.com
+
+phala cvms replicate \
+ --node-id prod6 \
+ --private-key $PRIVATE_KEY \
+ --rpc-url $ETH_RPC_URL
+```
+
+The sequence is:
+
+1. The backend prepares the replica and computes the `compose_hash` and the target node's `device_id`.
+2. The CLI reads the DstackApp contract to see which registrations are missing.
+3. If the device is not allowlisted, the CLI calls `addDevice` and waits for confirmation.
+4. If the compose hash is not allowlisted, the CLI calls `addComposeHash` and waits for confirmation.
+5. The CLI commits the prepared replica, the backend creates the instance, and the replica boots.
+
+If everything is already registered, the CLI skips straight to step 5 and the replica comes up immediately.
+
+### Onchain KMS: manual approval (multisig)
+
+When the DstackApp owner is a Safe, a timelock, or any other contract that cannot sign from the CLI, use the prepare + commit flow.
+
+**Step 1. Prepare.** Ask the backend to reserve the replica and emit the on-chain payload:
+
+```bash
+phala cvms replicate --node-id prod6 --prepare-only
+```
+
+The command does not create the replica. Instead it prints a commit token and the values that need to be authorized on-chain:
+
+```
+CVM replica prepared successfully (pending on-chain approval).
+
+Compose Hash: 0xabcd1234...
+App ID: 0x1234...
+Device ID: 0xdevice...
+Commit Token: prep_token_xyz...
+
+On-chain Status:
+ Compose Hash: NOT registered
+ Device ID: NOT registered
+
+To complete the replica after on-chain approval:
+ phala cvms replicate \
+ --commit \
+ --token prep_token_xyz... \
+ --compose-hash 0xabcd1234... \
+ --transaction-hash
+```
+
+The commit token is valid for 14 days. Save it somewhere your approvers can reach.
+
+**Step 2. Sign on-chain.** From your Safe (or whichever contract owns the DstackApp), call the missing writes on the DstackApp address printed above. Typically:
+
+- `addComposeHash(0xabcd1234...)` — if the `Compose Hash` line above shows `NOT registered`.
+- `addDevice(0xdevice...)` — if the `Device ID` line shows `NOT registered`.
+
+Submit the transaction(s) through the Safe UI and wait for execution. Record the final transaction hash.
+
+**Step 3. Commit.** Pass the commit token, compose hash, and transaction hash back to the CLI:
+
+```bash
+phala cvms replicate \
+ --commit \
+ --token prep_token_xyz... \
+ --compose-hash 0xabcd1234... \
+ --transaction-hash 0x5678...
+```
+
+If both writes are already on-chain and you just want to finalize, pass `--transaction-hash already-registered`. The backend re-reads the contract, verifies the state, and creates the replica.
+
+See [Multisig and Governance](/phala-cloud/key-management/multisig-governance) for guidance on structuring the Safe ownership itself.
+
+## API Reference
+
+For programmatic integrations — custom portals, CI systems, or orchestration that cannot shell out to the CLI — Phala Cloud exposes the replicate endpoints directly. Prefer the dashboard or CLI for interactive use; the raw API gives you no convenience on top of what the CLI already does.
+
+### POST `/cvms/{cvm_id}/replicas`
+
+Create a replica from a source CVM.
+
+| Field | Location | Required | Description |
+| --- | --- | --- | --- |
+| `cvm_id` | path | yes | Source CVM identifier. UUID, `app_id`, or unique name. |
+| `node_id` | body | no | Numeric target node ID. Omit to let the backend pick. The API only accepts numeric IDs; name resolution (e.g. `prod6`) is a CLI feature. |
+| `compose_hash` | body | no | Explicit compose hash. Required when the source app has more than one live configuration. |
+| `encrypted_env` | body | no | Hex-encoded encrypted env blob. Omit to inherit the source's env. |
+| `X-Prepare-Only` | header | no | Set to `true` to prepare the replica without creating it, for the manual approval flow. |
+
+On success the response is a `VM` object representing the new replica. On Onchain KMS prerequisites failure the response is HTTP 465 with a structured body containing `commit_token`, `compose_hash`, `device_id`, and `onchain_status`; use those fields as inputs to the commit endpoint after completing on-chain approval.
+
+### POST `/apps/{app_id}/cvms/{vm_uuid}/replicas`
+
+Alternative form that takes the app and source instance UUIDs in the URL. Same request body and response shape as the CVM-scoped endpoint. Use this when your integration already tracks `(app_id, vm_uuid)` pairs.
+
+### POST `/cvms/{vm_uuid}/commit-replica`
+
+Finalize a replica prepared with `X-Prepare-Only: true`.
+
+| Field | Required | Description |
+| --- | --- | --- |
+| `token` | yes | Commit token from the prepare response. |
+| `compose_hash` | yes | The same compose hash returned during prepare. |
+| `transaction_hash` | yes | On-chain transaction hash for the registration write, or `"already-registered"` when the prerequisites were met before commit. |
+
+The response is the same `VM` object you would get from a direct replicate call.
+
+## Errors
+
+Replicate calls share the same structured error envelope as the rest of the Phala Cloud API. Common codes you may hit during replication:
+
+| Code | Meaning |
+| --- | --- |
+| `ERR-01-005` | Onchain KMS requires compose hash or device registration (HTTP 465). Use auto-registration or the prepare + commit flow. |
+| `ERR-02-003` / `ERR-02-004` / `ERR-02-005` | Target node is out of vCPU, memory, or slots. Drop `--node-id` or pick a different node. |
+| `ERR-03-006` | The source CVM's OS image is not available on the target node. |
+| `ERR-03-008` | The source instance is not visible in the current workspace. |
+| `ERR-03-009` | The source app has multiple live instances; pass `--compose-hash` or use a specific source UUID. |
+| `ERR-04-001` | Workspace credit balance is too low. |
+
+See [Error Codes](/phala-cloud/references/error-codes) for the full catalog, HTTP status mappings, and the exception class behind each code.
+
+## Related Documentation
+
+- [Scale with Multiple Replicas](/phala-cloud/cvm/multi-replica-scaling) — When replicas help and when they do not.
+- [Environment Variables](/phala-cloud/cvm/set-secure-environment-variables) — Encrypted secrets model, `allowed_envs`, and update semantics.
+- [Deploying with Onchain KMS](/phala-cloud/key-management/deploying-with-onchain-kms) — First-deploy workflow for contract-governed CVMs.
+- [Multisig and Governance](/phala-cloud/key-management/multisig-governance) — Transferring DstackApp ownership to a Safe and running updates through it.
+- [Device Management](/phala-cloud/key-management/device-management) — How device IDs are derived and allowlisted.
+- [Error Codes](/phala-cloud/references/error-codes) — Full error reference.
diff --git a/phala-cloud/cvm/set-secure-environment-variables.mdx b/phala-cloud/cvm/set-secure-environment-variables.mdx
index a92d26b..9ecdf43 100644
--- a/phala-cloud/cvm/set-secure-environment-variables.mdx
+++ b/phala-cloud/cvm/set-secure-environment-variables.mdx
@@ -13,7 +13,7 @@ CVM's TEE can decrypt them at boot time.
Encrypted secrets are a list of key-value pairs that are passed into the docker compose file in the
same way as the variables defined in `.env` files. You should first define the encrypted secrets in
-the Phala Cloud UI (or CLI), and then reference them in the docker compose file using the `${KEY}`
+the dashboard (or CLI), and then reference them in the docker compose file using the `${KEY}`
syntax.
A typical use case is passing secrets to your containers via environment variables, using the
@@ -22,7 +22,7 @@ A typical use case is passing secrets to your containers via environment variabl
Updating encrypted secrets is a **full replacement** operation. You cannot update a single variable
— every update requires submitting the complete set of all variables. This applies to both the
-Dashboard UI and the CLI.
+dashboard and the CLI.
1. **Declare Environment Variables in Docker Compose**
@@ -41,7 +41,7 @@ Dashboard UI and the CLI.
> ❌ `OPENAI_API_KEY="${OPENAI_API_KEY_IN_ENV}"`
2. **Set Values in Encrypted Secrets**
- Configure the actual values in the **Encrypted Secrets** section of the Phala Cloud UI.
+ Configure the actual values in the **Encrypted Secrets** section of the dashboard.
@@ -62,7 +62,7 @@ We recommend using **Text** type for environment variables if you have many vari
## Set Secrets via CLI
-The CLI encrypts variables locally before sending them, just like the Dashboard does.
+The CLI encrypts variables locally before sending them, just like the dashboard does.
```bash
# Pass individual variables
@@ -174,7 +174,7 @@ Even in auto-encryption mode, plaintext values are stored in your Terraform stat
**Use a `.env` file locally for development.** Keep a `.env.example` in your repo with placeholder values and add `.env` to `.gitignore`. This makes it easy for teammates to set up their own credentials.
-**Rotate secrets by redeploying.** When you update encrypted secrets, the CVM restarts to pick up the new values. Plan for brief downtime or use replicas to maintain availability during rotation.
+**Rotate secrets by redeploying.** When you update encrypted secrets, the CVM restarts to pick up the new values. Plan for brief downtime, or run [multiple replicas](/phala-cloud/cvm/replicating-cvms) and rotate them one at a time to maintain availability.
**Keep secret names consistent.** Use the same key names in your compose file and encrypted secrets to avoid confusion. While Phala Cloud allows different names, matching them makes your configuration easier to audit.
diff --git a/phala-cloud/key-management/cloud-vs-onchain-kms.mdx b/phala-cloud/key-management/cloud-vs-onchain-kms.mdx
index cc32524..bfd7719 100644
--- a/phala-cloud/key-management/cloud-vs-onchain-kms.mdx
+++ b/phala-cloud/key-management/cloud-vs-onchain-kms.mdx
@@ -82,12 +82,11 @@ Phala Cloud supports Onchain KMS on both Ethereum and Base. Ethereum has higher
- Parameters of the Ethereum KMS
+ Parameters of the Ethereum KMS
| | |
| ----- | ----- |
| Contract Address | [0xd343a3f5593b93D8056aB5D60c433622d7D65a80](https://etherscan.io/address/0xd343a3f5593b93D8056aB5D60c433622d7D65a80#code) |
- | RPC | https://kms.dstack-eth-prod5.phala.network
https://kms.dstack-eth-prod7.phala.network
https://kms.dstack-eth-prod9.phala.network |
| Gateway Address | [0xd343a3f5593b93D8056aB5D60c433622d7D65a80](https://etherscan.io/address/0xd343a3f5593b93D8056aB5D60c433622d7D65a80#code) |
@@ -98,7 +97,6 @@ Phala Cloud supports Onchain KMS on both Ethereum and Base. Ethereum has higher
| | |
| ----- | ----- |
| Contract Address | [0x2f83172A49584C017F2B256F0FB2Dca14126Ba9C](https://basescan.org/address/0x2f83172A49584C017F2B256F0FB2Dca14126Ba9C#code) |
- | RPC | https://kms.dstack-base-prod5.phala.network
https://kms.dstack-base-prod7.phala.network
https://kms.dstack-base-prod9.phala.network |
| Gateway Address | [0x2f83172A49584C017F2B256F0FB2Dca14126Ba9C](https://basescan.org/address/0x2f83172A49584C017F2B256F0FB2Dca14126Ba9C#code) |
@@ -106,9 +104,7 @@ Phala Cloud supports Onchain KMS on both Ethereum and Base. Ethereum has higher
## Updating CVMs with Onchain KMS
-Any change that affects the compose hash requires on-chain registration. The CLI handles this automatically when you have the private key, or supports a two-phase flow for multisig wallets.
-
-See the [On-Chain KMS Guide](/phala-cloud/references/cloud-js-sdk/on-chain-kms-guide) for detailed CLI and SDK workflows.
+Any change that affects the compose hash requires on-chain registration. When you hold the DstackApp owner key as an EOA, [Updating with Onchain KMS](/phala-cloud/key-management/updating-with-onchain-kms) walks through the single-command flow. When the owner is a Safe, timelock, or DAO contract, [Multisig and Governance](/phala-cloud/key-management/multisig-governance) covers the prepare-and-commit flow plus webhook automation for approvers.
## Security
diff --git a/phala-cloud/key-management/deploying-with-onchain-kms.mdx b/phala-cloud/key-management/deploying-with-onchain-kms.mdx
new file mode 100644
index 0000000..8ea5ae8
--- /dev/null
+++ b/phala-cloud/key-management/deploying-with-onchain-kms.mdx
@@ -0,0 +1,391 @@
+---
+title: Deploying with Onchain KMS
+description: Deploy a dstack CVM whose authorization is governed by a smart contract on Ethereum or Base, using either the Phala Cloud dashboard or the phala CLI.
+---
+
+This page walks through the **first deployment** of an Onchain KMS CVM end to end, in the dashboard and from the CLI, then shows how to verify the result on-chain and in Phala Cloud.
+
+Before you start, skim [Understanding Onchain KMS](/phala-cloud/key-management/understanding-onchain-kms) for the mental model — which contracts exist, who signs what, and how the KMS verifies your CVM at boot time. If you are weighing Cloud KMS versus Onchain KMS, see [Cloud vs Onchain KMS](/phala-cloud/key-management/cloud-vs-onchain-kms) first.
+
+Once your CVM is running, the follow-up guides are:
+
+- [Updating with Onchain KMS](/phala-cloud/key-management/updating-with-onchain-kms) — roll a new compose file when you hold the DstackApp owner key.
+- [Multisig and Governance](/phala-cloud/key-management/multisig-governance) — the same flow when the DstackApp owner is a Safe, timelock, or DAO contract.
+- [Device Management](/phala-cloud/key-management/device-management) — allowlist the nodes your app is allowed to run on.
+
+## Prerequisites
+
+You need the following before starting:
+
+- **The phala CLI** if you plan to deploy from the command line. Install it globally with npm, or run it on demand without installing:
+
+ ```bash
+ npm install -g phala
+ ```
+
+ After installing, authenticate with `phala login` (device flow) or `phala login --manual` (API key).
+
+- **A wallet and its private key.** The first deployment signs one transaction (`deployAndRegisterApp` on KmsAuth). Subsequent compose updates sign one more (`addComposeHash` on your DstackApp), plus possibly `addDevice` if the node changes. For production, you will typically transfer ownership to a Safe after the first deploy — see [Multisig and Governance](/phala-cloud/key-management/multisig-governance).
+
+- **A little gas.** The CLI enforces a minimum balance of 0.001 ETH by default before it will attempt to deploy. For first-time users, **Base is the recommended chain** because the same transactions cost a fraction of a cent. Ethereum mainnet is supported if you want maximum decentralization and are willing to pay L1 gas.
+
+- **An RPC URL for the chain you pick** (optional). The CLI and SDK use viem internally, which ships with a default public RPC endpoint for each supported chain, so you can often skip this. For production or anything that does more than a handful of calls, buy an RPC from a reliable provider (Alchemy, QuickNode, Infura, or equivalent) and pass it via `--rpc-url` or the `ETH_RPC_URL` environment variable (the foundry/cast convention). The dashboard uses a bundled RPC by default and also lets you override it per deployment.
+
+- **A `docker-compose.yml`** for the workload you want to run. This gets embedded into the `app-compose.json` manifest described above.
+
+- **Environment variables** (optional). Your workload may not need any beyond what the image already defaults to. If it does, pass them with a `.env` file (`-e .env`) or inline (`-e KEY=VALUE`). Env vars are **end-to-end encrypted** before they leave the CLI — the CLI fetches the CVM's per-application `app_env_encrypt_pubkey` (a public key derived inside the KMS), encrypts each value locally, and submits only the ciphertext to the backend. Neither Phala Cloud nor any node operator can read plaintext env vars; only the CVM can, after the KMS releases the matching private key.
+
+The KmsAuth contract addresses for Ethereum and Base are listed in [Cloud vs Onchain KMS](/phala-cloud/key-management/cloud-vs-onchain-kms#ethereum-vs-base). Do not hard-code them anywhere — the CLI and dashboard read them from the Phala Cloud backend.
+
+
+ You do not need to install anything dstack- or solidity-related. The CLI bundles the KmsAuth and DstackApp ABIs and handles the transaction construction for you.
+
+
+### Checklist
+
+Before running the CLI or opening the dashboard, make sure you can answer yes to all of the following:
+
+- I have an API key or an active `phala login` session for the workspace I want to deploy into.
+- I have a `docker-compose.yml` that runs my workload end to end locally.
+- I have a wallet and I know its private key (or I have a browser wallet extension ready).
+- My wallet holds at least a little native token on the chain I am targeting — 0.001 ETH on Base is plenty for the first deploy and the first few updates; on Ethereum mainnet you will want more depending on current gas.
+- I know which chain I want to govern this application: Base for fast and cheap, Ethereum for maximum decentralization.
+
+### A note on private key hygiene
+
+The CLI accepts the deploy private key via `--private-key` or the `PRIVATE_KEY` environment variable. Both leave a trail somewhere (shell history, environment export) and are fine for a dev wallet or a CI secret, but they are not a long-term production posture. After your first successful deploy, the recommended path is:
+
+1. Keep the deploy wallet as the initial DstackApp owner only long enough to verify the CVM is running.
+2. Transfer `owner` to a Safe or timelock that you actually use for production governance.
+3. From that point on, updates go through [Multisig and Governance](/phala-cloud/key-management/multisig-governance), not through `--private-key`.
+
+Do not put a mainnet hot wallet private key into a shared CI environment. If you need CI to update your CVM regularly, use a dedicated wallet that only holds enough gas for transactions and scope its permissions via a multisig or a restricted-update contract.
+
+## A Concrete Example Before We Start
+
+To make the rest of this page easier to follow, imagine you want to deploy the [Phala Cloud Gin starter](https://github.com/Phala-Network/phala-cloud-gin-starter) as a CVM on Base, with the DstackApp owned by your personal wallet for now. You will upgrade to a Safe after the first deploy is working.
+
+
+ The Gin starter is only one of several ready-to-run templates maintained by Phala Network. If Go is not your thing, you can swap it for any of the following — the compose file layout and deployment flow are the same, only the image changes:
+
+ - [phala-cloud-python-starter](https://github.com/Phala-Network/phala-cloud-python-starter) — FastAPI × dstack
+ - [phala-cloud-nextjs-starter](https://github.com/Phala-Network/phala-cloud-nextjs-starter) — Next.js
+ - [phala-cloud-bun-starter](https://github.com/Phala-Network/phala-cloud-bun-starter) — Bun
+
+
+Your `docker-compose.yml`:
+
+```yaml
+services:
+ app:
+ image: ghcr.io/phala-network/phala-cloud-gin-starter:v0.1.5-full
+ restart: unless-stopped
+ ports:
+ - "80:8080"
+ volumes:
+ - /var/run/dstack.sock:/var/run/dstack.sock
+ environment:
+ - FAILURE_THRESHOLD=10
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 || exit 1"]
+ interval: 10s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+```
+
+No `.env` file is needed for this example. If you later add env vars they will be end-to-end encrypted before leaving the CLI, as described in the prerequisites.
+
+In your shell, export the private key of the wallet you will deploy from:
+
+```bash
+export PRIVATE_KEY=0x...
+```
+
+You do not need to set `ETH_RPC_URL` for a first deploy — the CLI will fall back to viem's default public RPC for Base. If you hit rate limits, set `ETH_RPC_URL` to a paid RPC from Alchemy, QuickNode, or any other provider.
+
+After this page you will have:
+
+- A DstackApp contract on Base, owned by your wallet, with the initial compose hash in its allowlist and the scheduled node's device ID in its allowlist.
+- A running CVM serving the Gin starter on port 80, whose keys the KMS released after verifying the TDX quote and reading the DstackApp.
+- A clear command you can run any time you want to update the compose file.
+
+With that in mind:
+
+## Deploying via the Dashboard
+
+The dashboard flow is a single wizard at `https://cloud.phala.com//deploy`. It provisions the CVM first, then pops open a drawer asking your wallet to deploy the DstackApp contract, then commits the CVM to the backend.
+
+### Step 1: Start a new deployment
+
+From your workspace home, click **Deploy** (or pick a template from the template gallery, which pre-fills the compose file). The wizard opens with sections for Basic Info, Resources, Environment, SSH Access, Advanced Features, and a Deployment Summary.
+
+
+
+### Step 2: Pick your compose file and resources
+
+Enter a name, paste your `docker-compose.yml` (or keep the template's default), and choose an instance type. The wizard queries the backend for available nodes and surfaces three things that matter for onchain KMS:
+
+- **Node selection.** Only nodes that support onchain KMS will expose a `device_id` that the DstackApp can allowlist. The wizard filters the list for you.
+- **KMS Provider cards.** Below the resource section you will see a row of cards labeled "KMS Provider," one per available KMS. Pick the card for **Base** or **Ethereum** depending on which chain you want to govern this application. The default, if you do not pick one, is Phala Cloud's centralized KMS — which is the other page's topic.
+- **OS Image.** Only dstack OS images that the KmsAuth contract has allowlisted will show up once you select a chain-backed KMS. (This is the dstack operating system image that boots the CVM, not the Docker image your app runs as.)
+
+
+
+### Step 3: Fill in environment variables
+
+Any required variables from a template will be flagged. The wizard will refuse to submit until they are filled. These values never leave your browser in plaintext — they are encrypted to the CVM's per-app env-encryption key before being sent to the backend.
+
+### Step 4: Click "Deploy"
+
+When you submit the form, the dashboard calls the `/cvms/preflight` and provisioning endpoints. The backend computes the `compose_hash` and `device_id`, and returns them along with the selected KMS's `chain_id`, `kms_contract_address`, and RPC URL. At this point the CVM is **provisioned but not committed** — nothing is running yet, and no on-chain state exists.
+
+### Step 5: Configure the DstackApp contract
+
+The wizard opens a drawer titled **Configure DstackApp Contract** with two options:
+
+- **Deploy New DstackApp Contract (recommended).** This is the common case. The drawer shows you the selected KMS URL, the chain ID, and the KmsAuth contract address (truncated). Expand **Customize RPC Endpoint** if you want to use your own RPC provider; the dashboard ships with defaults you can test with the **Test RPC** button.
+- **Use Existing DstackApp Contract.** If you already have a DstackApp you want to reuse (for example, replicating an app to a new CVM), paste its address here and the wizard will skip the deployment transaction.
+
+
+
+Click **Deploy and Continue**. Your connected browser wallet will pop up a transaction request. This is the on-chain deployment of a new DstackApp contract through the KmsAuth factory. The call is `KmsAuth.deployAndRegisterApp(deployer, disableUpgrades, allowAnyDevice, deviceId, composeHash)`. In a single transaction it both deploys the DstackApp contract and registers the initial compose hash and device ID — so after it confirms, both allowlists are already populated for the CVM that is about to start.
+
+
+
+Approve it in your wallet. The dashboard watches for the `AppDeployedViaFactory` event and extracts the new `appId` (which equals the DstackApp contract address).
+
+### Step 6: Backend commits the CVM
+
+With the DstackApp address in hand, the dashboard calls the commit endpoint. The backend verifies the contract state on-chain, encrypts the env vars with the CVM's env-encryption key, and writes the CVM record. When this returns, you are redirected to the app overview page at `//apps/`.
+
+The first CVM boot then kicks off the attestation flow described in [Understanding Onchain KMS → Boot-Time Verification Flow](/phala-cloud/key-management/understanding-onchain-kms#boot-time-verification-flow). You can watch progress on the app overview page — under the **On-Chain KMS** sidebar you will see the DStack KMS address, the DStack App address, and the deployer address. All three link out to Basescan or Etherscan.
+
+
+
+### What to do if the wallet pop-up never appears
+
+A few common misconfigurations stop the pop-up before you see it:
+
+- **No wallet extension installed.** The dashboard uses the standard `window.ethereum` injection. Install MetaMask, Rabby, or a similar extension first.
+- **Wallet is unlocked on a different chain.** Open the extension and switch to Base (chain ID 8453) or Ethereum mainnet (chain ID 1). The drawer's **Test RPC** button will tell you whether the selected RPC is reachable and on the expected chain.
+- **Missing `device_id` or `compose_hash`.** This means the provisioning step did not return onchain-KMS metadata — usually because the selected node does not support Onchain KMS. Go back and pick a different node, or pick a different KMS provider card.
+
+### Using an existing DstackApp instead of deploying a new one
+
+If you already own a DstackApp from a previous deployment and you want to reuse it — for example, to run a second replica of the same application under the same identity — pick **Use Existing DstackApp Contract** in the drawer and paste the DstackApp address. The wizard will skip the `deployAndRegisterApp` transaction. You still need to make sure the new CVM's compose hash and device ID are allowlisted on that contract, either before starting or by running a follow-up `addComposeHash` / `addDevice` transaction.
+
+## Deploying via the CLI
+
+The CLI handles everything in a single command. This is the fastest way to deploy for developers who already have a wallet configured in their shell.
+
+```bash
+export PRIVATE_KEY=0x...
+
+phala deploy \
+ --kms base \
+ -c docker-compose.yml \
+ --private-key "$PRIVATE_KEY"
+```
+
+`--kms` accepts `phala` (default), `ethereum`, `eth`, or `base`. The `--private-key` flag falls back to the `PRIVATE_KEY` environment variable if you omit it. `--rpc-url` is optional — when omitted, the CLI uses viem's default public RPC for the selected chain. Pass `--rpc-url` (or set `ETH_RPC_URL`) if you want to use a paid provider for stability.
+
+If the deployment succeeds, the CLI prints a summary to stdout:
+
+```
+CVM created successfully!
+
+CVM ID: 01234567-89ab-cdef-0123-456789abcdef
+Name: phala-cloud-gin-starter
+App ID: app_ff22c67f5d3b4c8a9e1f0a2b3c4d5e6f7a8b9c0d1
+Dashboard URL: https://cloud.phala.com/dashboard/cvms/01234567-89ab-cdef-0123-456789abcdef
+```
+
+Pass `--json` to get a machine-readable version instead:
+
+```json
+{
+ "success": true,
+ "vm_uuid": "01234567-89ab-cdef-0123-456789abcdef",
+ "name": "phala-cloud-gin-starter",
+ "app_id": "ff22c67f5d3b4c8a9e1f0a2b3c4d5e6f7a8b9c0d1",
+ "dashboard_url": "https://cloud.phala.com/dashboard/cvms/01234567-89ab-cdef-0123-456789abcdef"
+}
+```
+
+
+ The `app_id` in these outputs is the DstackApp contract address. The CLI's human-readable view prepends `app_` for historical compatibility, and the JSON view strips the `0x` prefix — both forms refer to the same 20-byte Ethereum address. On Basescan, you will see it as `0xff22c67f5d3b4c8a9e1f0a2b3c4d5e6f7a8b9c0d1`.
+
+
+
+ The `dashboard_url` field in the CLI output currently points to `/dashboard/cvms/`, which is a redirect target. The canonical workspace-scoped URLs are:
+
+ - **App overview** — `https://cloud.phala.com//apps/`
+ - **CVM instance detail** — `https://cloud.phala.com//apps//instances/`
+
+ Both use your workspace slug (not `dashboard`) and require the app_id or CVM uuid in the path. After landing through the redirect you will see one of these in the browser address bar.
+
+
+### What happens under the hood
+
+The CLI command maps to the following steps, in order:
+
+1. **Read the compose file and encrypt env vars locally.** Size is capped at a few hundred kilobytes combined.
+2. **Provision the CVM** via the Phala Cloud backend. The backend picks a node, computes the `compose_hash` and `device_id`, and returns them together with the KMS metadata (`chain_id`, `kms_contract_address`, RPC URL, `chain` object for viem). At this point nothing is on-chain yet.
+3. **Deploy the DstackApp contract.** The CLI calls `KmsAuth.deployAndRegisterApp(deployer, disableUpgrades=false, allowAnyDevice=false, deviceId, composeHash)` from your `--private-key`. This is the single wallet-facing transaction. The resulting `appId` is read from the `AppDeployedViaFactory` event.
+4. **Fetch the CVM's env-encryption public key.** The backend has one per app, derived inside the KMS. The CLI uses it to encrypt your env vars for the commit call.
+5. **Commit the CVM provision.** The CLI sends the `app_id`, `compose_hash`, `contract_address`, and encrypted env to the backend, which then starts the CVM. The backend verifies on-chain that the DstackApp exists, the compose hash is registered, and the deployer address matches before flipping the CVM into the running state.
+
+If any of those steps fail, the CLI prints a structured error with the step that failed and a suggested recovery. The most common failure is insufficient gas — the CLI enforces a minimum balance of 0.001 ETH before it sends the deploy transaction, and will abort with a clear message if your wallet is too empty.
+
+### Using the CLI with Ethereum instead of Base
+
+Same command, `--kms ethereum` instead of `--kms base`:
+
+```bash
+phala deploy \
+ --kms ethereum \
+ -c docker-compose.yml \
+ --private-key "$PRIVATE_KEY" \
+ --rpc-url https://your-alchemy-endpoint
+```
+
+For Ethereum mainnet, using a paid RPC is strongly recommended — the default public endpoint may rate-limit during a deploy transaction, and rate limiting in the middle of a multi-step deploy is confusing to debug. Budget a few dollars of ETH for gas; the `deployAndRegisterApp` transaction is a contract deployment, so it is noticeably more expensive than a normal transfer.
+
+`--kms eth` is accepted as an alias for `--kms ethereum` if you prefer it.
+
+### Common CLI flag reference
+
+The flags you will reach for most often on top of the basic `--kms base -c docker-compose.yml --private-key`:
+
+| Flag | Purpose |
+| --- | --- |
+| `-e ` | Encrypt env vars for the CVM. Accepts a `.env` path or inline `KEY=VALUE`, repeatable. |
+| `--wait` | Block until the CVM reaches `running` before returning. Without it, the command exits as soon as the backend commits the provision and the CVM may still be pulling its image. |
+| `--instance-type tdx.medium` | Pick a specific size instead of letting the scheduler decide. Use when you care about exact vCPU / memory / disk. |
+| `--no-public-logs`, `--no-public-sysinfo` | Opt out of public log and system-info endpoints at deploy time. Private by default for audit-sensitive workloads. |
+| `--rpc-url ` | Override the default public RPC. Strongly recommended for production — pass an Alchemy, QuickNode, or equivalent endpoint. |
+| `--kms ethereum` / `--kms eth` | Deploy against Ethereum mainnet instead of Base. Budget real ETH for gas. |
+| `--json` | Emit machine-readable JSON instead of the human-readable summary. |
+
+A production-shaped deploy using several of these:
+
+```bash
+phala deploy \
+ --kms base \
+ --instance-type tdx.medium \
+ -c docker-compose.yml \
+ -e .env \
+ --no-public-logs --no-public-sysinfo \
+ --private-key "$PRIVATE_KEY" \
+ --rpc-url https://base-mainnet.g.alchemy.com/v2/ \
+ --wait
+```
+
+## Verifying Your Deployment
+
+Once the CLI or dashboard has finished, three independent checks will confirm that the DstackApp contract is set up correctly.
+
+### 1. Read the contract from a block explorer
+
+Open the DstackApp contract on the appropriate explorer. The address is the `app_id` returned by the CLI (prepend `0x` to get the Basescan/Etherscan form), or the "DStack App" row in the dashboard's On-Chain KMS sidebar.
+
+On Basescan or Etherscan, click **Contract** -> **Read Contract** and check:
+
+- `owner()` — should return your wallet address. After transferring ownership to a Safe, this is where you confirm the new owner address.
+- `allowedComposeHashes(0x)` — should return `true`. Copy the compose hash from the CLI output or the app overview page.
+- `allowedDeviceIds(0x)` — should return `true` for the device your CVM was scheduled on. Alternatively, `allowAnyDevice()` may return `true` if you explicitly chose that mode (not recommended for production).
+
+You can sanity-check which wallet deployed the contract by looking at the transaction log for `AppDeployedViaFactory(appId, deployer)` on the KmsAuth contract.
+
+### 2. Check the CVM status in the dashboard
+
+Navigate to the app overview page at `https://cloud.phala.com//apps/`. It has an **On-Chain KMS** sidebar that shows:
+
+- The DStack KMS (KmsAuth) contract address with a link to the explorer
+- The DStack App (DstackApp) contract address with a link to the explorer
+- The deployer address — and a **Connect** button that, after connecting your wallet, tells you whether your connected address is the contract owner
+
+If the sidebar does not appear, the CVM is not using Onchain KMS — double-check that you picked the Base or Ethereum KMS card during deployment.
+
+To drill into a specific CVM — for logs, operations, compose content, or observability — use `https://cloud.phala.com//apps//instances/`.
+
+### 3. Check the CVM status from the CLI
+
+```bash
+phala cvms get
+```
+
+The CLI accepts any of: the CVM name (if unique in your workspace), the CVM UUID, or the app_id. It prints Name, App ID, Status, vCPU, Memory, Disk Size, Dstack Image, and App URL. If the CVM reached `Status: running`, the KMS has already released the application keys, which means all three contract checks in step 1 passed at boot time.
+
+Note that `phala cvms get` does not currently print KMS metadata (chain, DstackApp address, compose hash). For those, use the dashboard sidebar from step 2.
+
+If the CVM is stuck in a pre-running state, check the logs:
+
+```bash
+phala logs
+```
+
+In a healthy boot you should see lines indicating that the dstack guest reached the KMS and received keys. In a failed boot the logs will typically call out the specific check that failed (compose hash, device ID, or attestation).
+
+### What a healthy deployment looks like
+
+To summarize: after a successful first deploy, all of the following should be true.
+
+- `phala cvms get ` reports `Status: running`.
+- On the explorer, `DstackApp.owner()` returns your wallet address.
+- On the explorer, `DstackApp.allowedComposeHashes(0x)` returns `true` for the hash the CLI or dashboard reported.
+- On the explorer, `DstackApp.allowedDeviceIds(0x)` returns `true` for the device the scheduler picked, or `DstackApp.allowAnyDevice()` returns `true`.
+- In the dashboard, the **On-Chain KMS** sidebar on the app overview page shows the three addresses and, after you connect your wallet, identifies you as the deployer.
+
+If any of those is off, do not move on to production traffic until it is fixed.
+
+## What's Next
+
+Your CVM is running. From here:
+
+- **To change the compose file.** See [Updating with Onchain KMS](/phala-cloud/key-management/updating-with-onchain-kms) for the single-command EOA path, or [Multisig and Governance](/phala-cloud/key-management/multisig-governance) if you plan to transfer ownership to a Safe or timelock.
+- **To allowlist more nodes** for HA or migration. See [Device Management](/phala-cloud/key-management/device-management).
+- **To transfer DstackApp ownership** to a Safe, timelock, or DAO. Read [Multisig and Governance](/phala-cloud/key-management/multisig-governance) **before** you transfer — an irreversible mistake leaves the CVM frozen on its current compose.
+
+## Troubleshooting
+
+
+
+ The CLI enforces a minimum wallet balance of 0.001 ETH before it will send `deployAndRegisterApp`. Top up the wallet on the chain you are targeting and retry. On Base, the Base bridge or any mainstream exchange withdrawal works. On Ethereum mainnet, account for the L1 gas cost of a contract deployment — empty wallets will not do.
+
+
+
+ This usually means `--rpc-url` was not set and `ETH_RPC_URL` is not in your environment either. Set one of them and retry. You can verify with `echo $ETH_RPC_URL`.
+
+
+
+ Check the CVM logs first, from the dashboard or with `phala logs`. If the KMS rejected the attestation, the logs will say so. The most common root causes are:
+
+ - The compose hash computed on the node does not match what the contract stores. This normally only happens if something mutates the compose file after provisioning — unusual, but worth re-running the deploy if you see it.
+ - The device ID of the node the scheduler picked is not in `allowedDeviceIds`. On a fresh deploy this should not happen because `deployAndRegisterApp` registers the current device ID in the same transaction. If it does, cross-check the DstackApp on the explorer: `allowedDeviceIds()` should be `true`.
+ - The DstackApp `owner` is not who you think it is. If the contract was created by someone else's wallet, `owner()` on the explorer will reveal it.
+
+
+
+ Onchain KMS is not portable across chains — the DstackApp lives on one chain, and the KMS on that chain is the one that holds the keys. You cannot migrate a deployed CVM from Ethereum to Base (or vice versa) while keeping the same keys. The practical workflow is:
+
+ 1. Deploy a fresh CVM on the new chain with `phala deploy --kms base` or `--kms ethereum`.
+ 2. Point traffic and any persistent storage at the new CVM.
+ 3. Delete the old CVM.
+
+ Plan the chain choice up front.
+
+
+
+ Your browser wallet is still on a different network. Open the wallet extension and switch to Base (chain ID 8453) or Ethereum Mainnet (chain ID 1), then click **Deploy and Continue** again. The dashboard will reject the transaction if the chain ID from your wallet does not match the KMS you selected.
+
+
+
+## Next Steps
+
+- [Updating with Onchain KMS](/phala-cloud/key-management/updating-with-onchain-kms) — roll a new compose file when you hold the DstackApp owner key as an EOA.
+- [Multisig and Governance](/phala-cloud/key-management/multisig-governance) — the prepare-and-commit flow for compose updates when the DstackApp owner is a Safe, timelock, DAO, or any custom contract. Also covers webhook notifications for pending approvals.
+- [Device Management](/phala-cloud/key-management/device-management) — how `device_id` is derived, how to allow new nodes, and how to recover when a node's device ID drifts after a hardware change.
+- [Understanding Onchain KMS](/phala-cloud/key-management/understanding-onchain-kms) — the conceptual model if you want to revisit the contracts and boot-time flow.
+- [Error Codes reference](/phala-cloud/references/error-codes) — the full catalog of `ERR-01-*` codes returned by the CVM API.
diff --git a/phala-cloud/key-management/device-management.mdx b/phala-cloud/key-management/device-management.mdx
new file mode 100644
index 0000000..f87e81d
--- /dev/null
+++ b/phala-cloud/key-management/device-management.mdx
@@ -0,0 +1,306 @@
+---
+title: Device Management
+description: Manage which physical nodes can run your Onchain KMS app by controlling the on-chain device allowlist.
+---
+
+When you use Onchain KMS, your `DstackApp` contract keeps a list of the physical nodes that are allowed to run your application. If a node is not on that list, the KMS will refuse to hand it keys — and your CVM cannot boot there.
+
+This page is for operators of an already-deployed Onchain KMS app. It covers how device identity works, how to view the allowlist, how to add and remove nodes from the CLI and the dashboard, and how to recover when a node's identity changes after a hardware upgrade.
+
+If you have not deployed an Onchain KMS app yet, start with [Deploying with Onchain KMS](/phala-cloud/key-management/deploying-with-onchain-kms). For the conceptual model behind `compose_hash` and `device_id`, see [Understanding Onchain KMS](/phala-cloud/key-management/understanding-onchain-kms).
+
+## Why Device Identity Matters
+
+Onchain KMS decides whether to release keys based on three things: the TEE attestation quote, the code that is running (`compose_hash`), and the physical node the code is running on (`device_id`). All three must match values that your `DstackApp` contract considers valid.
+
+The device check is the one most operators overlook. When your CVM is created on node A, the deployment flow registers node A's `device_id` in the allowlist for you. But if you later replicate the CVM to node B for high availability, migrate it to node C after a hardware failure, or the same node's underlying identity changes after a BIOS update, the KMS will look up the new `device_id` on-chain, find it missing, and deny the key request. The CVM will fail to boot and you will see a device-not-allowed rejection in the boot log.
+
+Managing the allowlist is therefore a routine operational task whenever you change where your app runs. It is also the main knob for auditability: the allowlist, together with the on-chain events emitted when it changes, gives you a cryptographic record of exactly which machines were ever authorized to run your code.
+
+## What a `device_id` Is
+
+Every Intel TDX host exposes a **Platform Provisioning ID** (PPID) — a hardware-level identifier derived from the CPU and the platform's provisioning keys. Two different machines have different PPIDs. The same machine, under normal operation, keeps the same PPID across reboots.
+
+Phala Cloud surfaces each node's PPID to the workspaces that are allowed to deploy to it. PPID is not a global public directory — you can see the PPID and `device_id` of the nodes listed in your own workspace's deployable node list (for example, in the node picker inside the create-CVM wizard). Nodes you cannot deploy to are not exposed.
+
+Phala Cloud and the `DstackApp` contract never store the raw PPID on-chain. Instead they store the SHA-256 hash of the PPID bytes — `device_id = sha256(ppid)`. This derivation is implemented in dstack's attestation library ([`dstack-attest/src/attestation.rs`](https://github.com/Dstack-TEE/dstack/blob/master/dstack-attest/src/attestation.rs)) and is the same value that Phala Cloud surfaces in the node list, writes to the `DstackApp.allowedDeviceIds` mapping, and checks at boot time.
+
+This derivation gives you:
+
+- A fixed-length 32-byte value, represented as a 64-character hex string off-chain and as `bytes32` on-chain.
+- A stable one-to-one mapping: one physical node corresponds to one `device_id` under normal operation.
+- No reversibility: the on-chain value does not leak the PPID to third parties.
+
+### When `device_id` Changes
+
+The PPID is not completely immutable. It can change when the platform's provisioning state changes — most commonly after:
+
+- A BIOS firmware update
+- A CPU microcode update
+- Some motherboard replacements or TDX re-provisioning events
+
+When the PPID changes, `sha256(new_ppid)` changes too, so the node presents a new `device_id` the next time it asks KMS for keys. Phala Cloud keeps an internal history of per-node device identity changes so operators can audit when a node's identity changed, but the on-chain contract does not know about the old-to-new mapping. Any CVMs whose allowlist still points at the stale `device_id` will need the new one added before they can boot again.
+
+
+ A `device_id` change is not a security incident on its own — it is an expected consequence of firmware maintenance. It only becomes a problem because an allowlist entry that used to match the node no longer matches it.
+
+
+## The On-Chain Allowlist Model
+
+The `DstackApp` contract stores two pieces of state that control device access:
+
+- `allowedDeviceIds(bytes32) → bool` — a mapping from `device_id` to whether that device is allowed.
+- `allowAnyDevice() → bool` — a bypass flag. When `true`, the per-device mapping is ignored and any node in the KMS's scope is allowed.
+
+All writes are gated by the contract's `owner`. The following functions all revert if called by anyone other than the owner:
+
+```solidity
+function addDevice(bytes32 deviceId) external;
+function removeDevice(bytes32 deviceId) external;
+function setAllowAnyDevice(bool _allowAnyDevice) external;
+```
+
+And the contract emits one event per change:
+
+```solidity
+event DeviceAdded(bytes32 deviceId);
+event DeviceRemoved(bytes32 deviceId);
+event AllowAnyDeviceSet(bool allowAny);
+```
+
+Those events are what auditors and monitoring tools should watch. They give you an immutable, timestamped log of every allowlist mutation on your app.
+
+## Viewing the Current Allowlist
+
+You have three independent ways to look at the state of the allowlist. They should always agree; if they do not, trust the on-chain view.
+
+### Dashboard
+
+Open your app in the Phala Cloud dashboard at `https://cloud.phala.com//apps/` and scroll the app overview page to the **Device Allowlist** section in the right sidebar.
+
+
+
+Every time you open this page, the dashboard reads the current on-chain allowlist **and** cross-checks it against the `device_id` of every node your CVMs are actually running on. If any deployed node is missing from the allowlist — including the common case where a BIOS or microcode update silently drifted a node's PPID — a warning banner appears at the top of the section, and the offending rows are highlighted so you can act on them without having to hunt for the mismatch.
+
+The panel shows:
+
+- An **Allow any device** row at the top. When on, the contract's `allowAnyDevice` flag is `true` and the per-device toggles below are effectively ignored.
+- One row per known device. The list is merged from two sources — devices your CVMs are currently running on (whether or not they are allowed) and devices on nodes your workspace can deploy to. Each row shows the 32-byte `device_id` (click to copy), the node name with a region flag where available, and a status icon: green check for allowed, amber triangle for deployed-but-not-allowed, grey X for known-but-not-deployed.
+
+If your connected wallet is the contract owner, each row has a toggle that sends the matching on-chain transaction through your wallet — that is how you remediate a PPID drift without leaving the dashboard. If the owner is not an EOA you control, the toggles are hidden; see the [Non-EOA Owner](#non-eoa-owner) callout below.
+
+### CLI
+
+```bash
+phala allow-devices list
+```
+
+`` can be a CVM UUID, a CVM name, an `instance_id`, or an `app_id` (raw hex or `app_` prefixed). The command resolves it to the underlying `DstackApp` contract and queries the chain directly.
+
+Example output:
+
+```
+Contract: 0x1234…abcd
+Chain: Base
+Owner: 0xabcd…1234
+Allow Any Device: no
+DEVICE_ID NODE
+0xaabbccdd… prod6
+0x11223344… use2
+```
+
+
+ To discover the `device_id` of every node your workspace can deploy to, run `phala nodes ls`. The output includes `ID`, `NAME`, `REGION`, `PPID`, `DEVICE_ID`, and `VERSION` columns, so you can pick the node name or paste its `device_id` directly into the write commands below.
+
+
+When `allowAnyDevice` is on, the per-device table is not printed (the underlying read would just echo every queried ID back without meaning).
+
+You can pass `--rpc-url ` (or set `ETH_RPC_URL`) to use a specific RPC endpoint. `list` never signs anything, so `--private-key` is not required.
+
+For machine-readable output, pass `--json`.
+
+### On-Chain (Independent Verification)
+
+Because the contract is public, anyone can read the allowlist directly without going through Phala Cloud. On Basescan or Etherscan, open your app contract, switch to **Contract → Read Contract**, and call:
+
+- `allowAnyDevice()` — returns `true` or `false`.
+- `allowedDeviceIds(bytes32)` — paste the 32-byte `device_id` (with the `0x` prefix) and read the boolean result.
+
+This is the most trustworthy view. If the dashboard or the CLI ever disagrees with the contract, the contract is right.
+
+## Adding and Removing Devices from the CLI
+
+All write commands need two things:
+
+- A signing key with enough gas on the target chain. Pass `--private-key 0x…` or set `PRIVATE_KEY` in your environment.
+- An RPC URL for the chain. Pass `--rpc-url ` or set `ETH_RPC_URL`. If omitted, the CLI falls back to the chain's public default RPC, which is frequently rate-limited; use a dedicated RPC for anything beyond a quick experiment.
+
+Every write command accepts an optional `--wait` flag. Without `--wait`, the command returns as soon as the transaction is submitted. With `--wait`, it polls the RPC until the on-chain state reflects the change (or times out after roughly one minute).
+
+
+ The signing wallet you pass with `--private-key` must be the `DstackApp` contract owner. If ownership has been transferred to a Safe, a timelock, or any other contract, the transaction will revert and the CLI will report a failure. See the [Non-EOA Owner](#non-eoa-owner) section.
+
+
+### Add a Device
+
+```bash
+phala allow-devices add \
+ --private-key 0x...
+```
+
+`` can be either a node name (as shown by `phala nodes ls`, for example `prod6` or `use2`) or a 32-byte hex string (with or without `0x`). When you pass a node name, the CLI looks it up against your workspace's deployable-node list and resolves it to the node's current `device_id`. Ambiguous names are rejected rather than guessed.
+
+```bash
+# By node name (recommended — survives device_id drift if you rerun it)
+phala allow-devices add my-cvm prod6 --private-key 0x...
+
+# By raw device_id
+phala allow-devices add my-cvm 0xaabbccdd... --private-key 0x...
+```
+
+Interactive mode selects from nodes you have access to but which are not yet on the allowlist:
+
+```bash
+phala allow-devices add -i --private-key 0x...
+```
+
+The CLI prints a checkbox list of candidate nodes (` `), lets you multi-select, then submits one `addDevice(bytes32)` transaction per selection.
+
+Successful output looks like:
+
+```
+Submitting add-device transaction for 0xaabbccdd...
+RPC URL: https://base-mainnet.example.com
+Add-device transaction for 0xaabbccdd... submitted: 0xdeadbeef...
+Explorer: https://basescan.org/tx/0xdeadbeef...
+Waiting for 1 confirmation...
+Added 0xaabbccdd...
+Transaction: 0xdeadbeef...
+Explorer: https://basescan.org/tx/0xdeadbeef...
+Backend allowlist API may lag behind chain. Use --wait to verify via RPC.
+```
+
+Underlying contract call: `addDevice(bytes32 deviceId)`. The contract emits `DeviceAdded(deviceId)` on success.
+
+### Remove a Device
+
+```bash
+phala allow-devices remove \
+ --private-key 0x... \
+ --rpc-url https://base-mainnet.example.com
+```
+
+Interactive removal lists the devices currently marked `allowed` by the backend:
+
+```bash
+phala allow-devices remove -i --private-key 0x...
+```
+
+Underlying contract call: `removeDevice(bytes32 deviceId)`. The contract emits `DeviceRemoved(deviceId)`.
+
+
+ Removing a device while `allowAnyDevice` is `true` has no practical effect — the bypass flag still lets the node boot. The CLI warns about this when you use `--wait`:
+
+ ```
+ Warning: allowAnyDevice is enabled — removed devices still appear as allowed. Disable allow-any-device first if you want per-device enforcement.
+ ```
+
+
+### Enable or Disable the "Allow Any Device" Bypass
+
+```bash
+phala allow-devices allow-any --enable --private-key 0x...
+phala allow-devices allow-any --disable --private-key 0x...
+```
+
+You must pass exactly one of `--enable` or `--disable`; the command refuses to run otherwise. Underlying call: `setAllowAnyDevice(bool)`. The contract emits `AllowAnyDeviceSet(allowAny)`.
+
+`disallow-any` is a shortcut that only turns the flag off:
+
+```bash
+phala allow-devices disallow-any --private-key 0x...
+```
+
+It is equivalent to `allow-any --disable`. Use whichever reads better in your runbooks.
+
+`toggle-allow-any` flips the current state (or forces it with `--enable` / `--disable`):
+
+```bash
+phala allow-devices toggle-allow-any --private-key 0x...
+```
+
+Without any flag, it reads the current `allowAnyDevice` value from the backend and sends a transaction to set the opposite. Pass `--enable` or `--disable` if you want to assert a specific end state instead of toggling.
+
+## Multi-Replica and HA Patterns
+
+When an app runs on more than one node — whether for geographical redundancy, scaling, or active failover — you have two broad strategies:
+
+### Option A: Precise Allowlist
+
+Maintain a per-node allowlist using `addDevice` and `removeDevice`. Every node the app is allowed to run on has an explicit on-chain entry.
+
+- **Pros.** You get a cryptographic audit trail of exactly which physical machines were ever authorized to execute your app. Removing a node is a provable, on-chain event. This is the strongest model for compliance-sensitive workloads and for auditors that want to reason about the blast radius of a compromise.
+- **Cons.** Operational overhead. Adding capacity is a signed transaction; so is rotating out a failed node; so is recovering from a BIOS-driven `device_id` change.
+
+### Option B: `allowAnyDevice = true`
+
+Flip the bypass flag. The contract stops checking `allowedDeviceIds` and permits **any Phala Cloud node attached to this KMS** to host the app. The "any" is scoped to hosts that the KMS already considers trusted — hosts that have passed dstack-KMS onboarding and are part of the Phala Cloud fleet — not any random TEE machine on the internet.
+
+- **Pros.** You can scale freely across any Phala Cloud node without touching the allowlist. No per-migration overhead.
+- **Cons.** Weaker audit guarantees. Any Phala Cloud host the KMS considers trusted can attempt to run your app, and your on-chain record no longer pins execution to a specific set of machines.
+
+
+ `allowAnyDevice = true` trades cryptographic per-node enforcement for operational convenience. For production workloads where execution locality is part of your threat model or compliance story, prefer Option A and accept the operational cost.
+
+
+For production and audit-sensitive deployments we recommend Option A. Use Option B only when the simplicity is genuinely worth giving up the per-node audit trail.
+
+## Recovering from a Hardware Upgrade
+
+When a node's PPID changes after a BIOS, microcode, or platform update, any CVM whose allowlist entry still references the old `device_id` will stop booting there.
+
+**Symptoms.**
+
+- CVMs on the affected node fail to start or fail to obtain keys on the next reboot.
+- The boot log or the dashboard warning banner reports that the node's `device_id` is not on the allowlist.
+- `phala allow-devices list ` does not include the node's current `device_id`.
+
+**Recovery.**
+
+1. Look up the node's **new** `device_id` by running `phala nodes ls`. The output shows one row per deployable node, including the current `device_id` after any drift. The quickest path is to either remember the node name (for example `prod6`) or copy the new 32-byte hex value.
+2. Add the node back to the app's allowlist. You can pass either the node name or the raw `device_id`:
+ ```bash
+ phala allow-devices add prod6 \
+ --private-key 0x... \
+ --wait
+ ```
+ The CLI resolves `prod6` against your workspace's node list and submits `addDevice(bytes32)` with the node's current `device_id`. If you prefer to be explicit, pass the 32-byte hex value instead:
+ ```bash
+ phala allow-devices add 0x \
+ --private-key 0x... \
+ --wait
+ ```
+3. The dashboard's **Device Allowlist** section is the fastest way to check the result — open `https://cloud.phala.com//apps/`, and the warning banner for this node should be gone.
+4. Restart the CVM on the affected node. It should now complete the KMS handshake and boot successfully.
+5. Optionally, remove the old `device_id` with `phala allow-devices remove` once you are sure nothing else needs it.
+
+If the CVM was actively running on the node at the moment the platform update took effect, expect a brief window of downtime between the old identity being invalidated and the new one being authorized. You can shorten that window by preparing and signing the `addDevice` transaction in advance of the maintenance.
+
+## Non-EOA Owner
+
+
+ The `phala allow-devices` commands assume the `DstackApp` owner is an externally-owned account whose private key you hold. If your app's owner is a Safe, a timelock, a DAO, or any other contract, the `--private-key` path will not work — the transaction will be sent from an EOA that the contract considers unauthorized, and it will revert.
+
+ The CLI does **not** currently support a `--prepare-only` mode for device operations. For non-EOA owners you must call the contract directly through your governance tooling:
+
+ - **Safe.** Open the Safe Transaction Builder, point it at your `DstackApp` contract address, and call `addDevice(bytes32)`, `removeDevice(bytes32)`, or `setAllowAnyDevice(bool)` with the 32-byte `device_id` you want to change.
+ - **Timelock.** Queue the same call through the timelock's `schedule` / `execute` flow.
+ - **Custom governance contract.** Use whatever path your contract exposes for executing arbitrary calls against external contracts.
+
+ See [Multisig and Governance](/phala-cloud/key-management/multisig-governance) for the full walkthrough of managing an Onchain KMS app whose owner is a multisig or timelock.
+
+
+## Next Steps
+
+- [Deploying with Onchain KMS](/phala-cloud/key-management/deploying-with-onchain-kms) — the prerequisite for everything on this page. Covers the first-time deploy of a `DstackApp` contract and its initial `device_id` / `compose_hash` registration.
+- [Multisig and Governance](/phala-cloud/key-management/multisig-governance) — how to operate an app whose owner is a Safe, a timelock, or a DAO, including the manual path for device operations when the CLI's `--private-key` flow cannot be used.
diff --git a/phala-cloud/key-management/multisig-governance.mdx b/phala-cloud/key-management/multisig-governance.mdx
new file mode 100644
index 0000000..3fe555b
--- /dev/null
+++ b/phala-cloud/key-management/multisig-governance.mdx
@@ -0,0 +1,695 @@
+---
+title: Multisig and Governance
+description: Run production Onchain KMS apps with Safe, Timelock, or DAO ownership using the prepare and commit flow.
+---
+
+Production apps often need more than a single hot wallet for code updates. This page walks through transferring DstackApp ownership to a Safe or governance contract, then using the CLI prepare and commit flow (and webhooks) to push compose updates without the CVM operator ever holding the owner key.
+
+For a primer on which contracts govern what, see [Understanding Onchain KMS](/phala-cloud/key-management/understanding-onchain-kms). For the EOA-owned update path that this page replaces once ownership is transferred, see [Updating with Onchain KMS](/phala-cloud/key-management/updating-with-onchain-kms). For device-level operations, see [Device Management](/phala-cloud/key-management/device-management).
+
+## When to Use Multisig
+
+Use multisig ownership when no single person on your team should be able to push a compose update on their own. Typical triggers:
+
+- Production apps where an unauthorized code change has material impact on users or funds.
+- Compliance programs that require separation of duties for production changes.
+- DAO-governed apps where token holders vote on what code runs.
+- Insurance or audit requirements that ask for m-of-n approval on sensitive deployments.
+
+The trade-off is speed. Every compose change has to be proposed, signed by enough owners, mined, and then committed back to Phala Cloud. Hot-patching a broken build takes minutes to hours instead of seconds. Plan release cycles accordingly, and keep an EOA-owned staging app for fast iteration.
+
+Phala Cloud does not host a multisig for you. You bring your own Safe, Timelock, DAO contract, or any other contract that exposes an owner role. Phala Cloud only cares about one thing: the `owner()` of your DstackApp contract must be able to authorize `addComposeHash(bytes32)` calls. Whatever contract shape makes that happen is up to you.
+
+## Ownership Model Recap
+
+Your DstackApp contract stores:
+
+- `owner` — the address allowed to call `addComposeHash`, `addDevice`, `removeDevice`, `setAllowAnyDevice`, and `transferOwnership`.
+- `allowedComposeHashes` — the set of compose hashes approved to boot under this app identity.
+- `allowedDeviceIds` — the set of nodes allowed to host this app (see [Device Management](/phala-cloud/key-management/device-management)).
+
+By default, `owner` is the deployer EOA that created the app. The CLI `phala deploy --cvm-id ... --private-key ...` path works as long as that EOA still holds ownership. To move to multisig, you transfer ownership to a contract address, after which direct EOA updates fail and all future updates go through the prepare and commit flow.
+
+
+ `owner` on DstackApp governs your application identity only. It does not control the KmsContract that Phala operates, and it cannot mint or rotate keys by itself. Keys are still derived inside the TEE after KMS verifies attestation against whatever compose hashes `owner` has approved.
+
+
+## Transferring Ownership to a Safe
+
+
+ Ownership transfer is a one-way on-chain action. If you transfer to an address that cannot sign (a contract that does not implement `addComposeHash` forwarding, a Safe with the wrong threshold, a mistyped address), you lose the ability to update the app forever. Always test the full flow on a throwaway DstackApp first.
+
+
+The transfer itself is a plain `transferOwnership(address)` call on the DstackApp contract. You do it from the current owner EOA using any Ethereum or Base tool you prefer.
+
+1. Deploy your Safe first, or have its address handy. On Base, use [safe.global](https://app.safe.global) with the network set to Base. Record the Safe address (for example `0x1234...abcd`) and the signer threshold (for example 3-of-5).
+2. Open your DstackApp contract on [Basescan](https://basescan.org) or [Etherscan](https://etherscan.io). The address is the same as your app ID with a `0x` prefix. You can also find it on the Phala Cloud dashboard under the app overview.
+3. DstackApp is deployed as a **proxy contract**, so its write methods live on the proxy page, not the implementation. Go to the **Contract** tab and choose **Write as Proxy** (not **Write Contract**). If the **Write as Proxy** tab is missing or shows no functions, click the **Verify** link on the proxy first — Basescan/Etherscan needs the implementation address resolved before it can expose the proxy's write methods.
+4. Click **Connect to Web3** and connect the EOA that currently owns the app.
+5. Find `transferOwnership` in the write methods. Enter the Safe address. Submit and wait for confirmation.
+6. Verify on-chain. Switch to **Read as Proxy** and call `owner()`. It should return the Safe address.
+
+From this moment on, `phala deploy --cvm-id --private-key ` will fail with an owner-mismatch error on the `addComposeHash` step. Do not panic — this is expected. All future compose updates go through the prepare and commit flow described below.
+
+If your governance contract is a Timelock, DAO voting contract, or any other shape, the steps are the same. You are simply calling `transferOwnership(address)` from the EOA that currently holds the role. Use whichever UI you prefer — Basescan, Etherscan, Safe Transaction Builder, `cast send`, Foundry scripts — the on-chain effect is identical.
+
+## The Prepare, Approve, Commit Model
+
+With EOA ownership, the CLI does everything in one shot: it reads your compose file, computes the hash, sends `addComposeHash` to the chain, and tells Phala Cloud to roll the update forward. With multisig ownership, that one step splits into three phases across two different actors.
+
+
+ **The CVM does not restart until Phase 3 completes.** Prepare and approve are both no-ops from the running CVM's point of view — the live workload keeps serving traffic under the old compose hash throughout. Only when commit succeeds does Phala Cloud save the new compose, re-encrypt env vars, and trigger the restart. If you abandon a flow halfway, there is nothing to roll back.
+
+
+**Phase 1 — Prepare.** The CVM operator runs `phala deploy --cvm-id ... --prepare-only -c new-compose.yml`. Phala Cloud computes the compose hash, checks current on-chain state, and mints a one-time commit token (a UUID) valid for 14 days. The CLI prints the compose hash, the exact on-chain action required, and the commit URLs. Nothing on-chain has changed yet, and the CVM keeps running its existing compose hash.
+
+**Phase 2 — Approve.** A human (or a governance contract) takes the compose hash from Phase 1 and submits `addComposeHash(bytes32)` to the DstackApp contract through the Safe, Timelock, or DAO. This is where signatures are collected and the transaction is mined. Phala Cloud is not involved in Phase 2 at all — the approvers only need the compose hash and the DstackApp contract address. The CVM is still running the old compose during this phase.
+
+**Phase 3 — Commit.** Once the on-chain transaction is mined, someone (the original operator, a CI job, or an automation bot) tells Phala Cloud the transaction landed. The backend verifies that the compose hash is now registered on-chain and that the provided transaction hash contains the expected state change, then rolls the CVM update forward — this is the point where the CVM actually restarts onto the new compose.
+
+A few properties of this design worth internalizing:
+
+- **Commit tokens expire after 14 days.** If approval takes longer, re-run prepare. The expiry is enforced by the Phala Cloud backend. For a simple Safe with a few signers this is rarely a problem, but see the Timelock note below.
+- **Re-preparing invalidates the previous token.** If you run `--prepare-only` twice for the same CVM, only the second token works. This is intentional: it prevents stale updates from racing with newer ones. If two people are preparing in parallel, coordinate offline first.
+- **Prepare is idempotent with respect to the compose file.** If the compose file has not changed, re-running prepare gives you a fresh token pointing at the same compose hash.
+- **Prepare does not touch the CVM.** The CVM keeps running the old compose hash until Phase 3 completes. If you cancel mid-flow, nothing rolls back — just discard the token.
+
+### Long-delay Timelocks and the 14-day window
+
+If your governance contract is a Timelock with a delay approaching or exceeding 14 days, the straightforward "prepare, propose, wait, execute, commit" sequence can fail at commit time: by the time the Timelock delay elapses, the commit token has already expired.
+
+The fix is to decouple the Phase 2 on-chain action from the Phase 1 token's lifetime. Phases 2 and 3 only need to agree on the **compose hash**, not the token, because the backend verifies the chain state by hash. Concretely:
+
+1. Run `phala deploy --cvm-id ... --prepare-only` once to discover the compose hash. Record it and discard the token — you will not use it.
+2. Encode `addComposeHash(bytes32)` with that hash and queue it through your Timelock immediately.
+3. Wait for the Timelock delay to elapse and execute the transaction on-chain. Record the resulting transaction hash.
+4. Run `phala deploy --cvm-id ... --prepare-only` again with the same compose file. This produces a fresh 14-day token pointing at the same compose hash.
+5. Immediately commit with the fresh token and the real transaction hash.
+
+The second prepare is cheap (no on-chain writes) and exists solely to give you a current token. Keep the compose file identical across both prepare calls — any edit between them changes the hash and invalidates the on-chain approval you already waited days for.
+
+## CLI: Prepare Phase
+
+### Compose updates
+
+For a normal compose file update:
+
+```bash
+phala deploy --cvm-id \
+ --prepare-only \
+ -c new-compose.yml \
+ -e .env
+```
+
+The `-e .env` is optional; include it if you are changing environment variables alongside the compose file. Environment variables are encrypted with the app's public key and bound to the commit token — they land atomically when Phase 3 runs.
+
+Human-readable output (exactly what the CLI prints with `--no-json` or when running interactively):
+
+```
+CVM update prepared successfully (pending on-chain approval).
+
+Compose Hash: 0xff22c67f1c8d5e4a9b3d2c1e0f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9
+App ID: 0x09cef33ca9c6e4f2b1d5a7c3e8f0b2a4d6e8f1c3
+Device ID: 0x7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8
+Chain: Base (ID: 8453)
+Contract: https://basescan.org/address/0x09cef33ca9c6e4f2b1d5a7c3e8f0b2a4d6e8f1c3
+Commit Token: a5732e36-8f4b-4c1d-9e7a-2b5c8d9e0f1a
+Commit URL: https://cloud.phala.com/my-team/cvms/01234567-89ab-cdef-0123-456789abcdef/confirm-update?token=a5732e36-8f4b-4c1d-9e7a-2b5c8d9e0f1a&compose_hash=ff22c67f1c8d5e4a9b3d2c1e0f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9
+API Commit URL: https://cloud-api.phala.com/api/v1/cvms/01234567-89ab-cdef-0123-456789abcdef/commit-update?token=a5732e36-8f4b-4c1d-9e7a-2b5c8d9e0f1a (POST)
+
+On-chain Status:
+ Compose Hash: NOT registered
+ Device ID: registered
+
+To complete the update after on-chain approval:
+ phala deploy --cvm-id 01234567-89ab-cdef-0123-456789abcdef \
+ --commit \
+ --token a5732e36-8f4b-4c1d-9e7a-2b5c8d9e0f1a \
+ --compose-hash 0xff22c67f1c8d5e4a9b3d2c1e0f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9 \
+ --transaction-hash
+```
+
+Every field carries information the approver or an automation needs:
+
+- **Compose Hash** — the 32-byte keccak256 hash of your compose document, prefixed with `0x`. This is the exact `bytes32` argument to pass to `addComposeHash` on-chain. It also serves as the idempotency key for the update.
+- **App ID** — the DstackApp contract address (same as the app ID with `0x` prefix). This is the `to` address for the on-chain transaction.
+- **Device ID** — the device identifier for the node currently hosting this CVM. It is shown so you can confirm whether the device is already allowed. If you see `Device ID: NOT registered`, you also need to call `addDevice(bytes32)` as part of the multisig flow (see section below).
+- **Chain** — human-readable chain name and EIP-155 chain ID. Double-check that your Safe and your CLI are pointed at the same chain.
+- **Contract** — a block explorer link to the DstackApp contract. Use this to verify `owner()` before approving, and to review the on-chain state after.
+- **Commit Token** — the UUID that unlocks Phase 3. It expires 14 days from prepare time. Treat it like a one-time API key.
+- **Commit URL** — the dashboard Confirm Update page. Share this with approvers who want a guided UI for Phase 3.
+- **API Commit URL** — the raw `POST` endpoint for Phase 3. Use this from CI or scripts.
+- **On-chain Status** — a live snapshot read from the chain at prepare time. It tells you whether `addComposeHash` and `addDevice` are strictly required, or whether the state is already good (for example, if the compose hash is already registered from a previous aborted flow).
+
+The same data is available as JSON for automation:
+
+```bash
+phala deploy --cvm-id \
+ --prepare-only \
+ -c new-compose.yml \
+ --json
+```
+
+```json
+{
+ "success": true,
+ "prepare_only": true,
+ "compose_hash": "0xff22c67f1c8d5e4a9b3d2c1e0f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9",
+ "app_id": "0x09cef33ca9c6e4f2b1d5a7c3e8f0b2a4d6e8f1c3",
+ "device_id": "0x7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8",
+ "kms_info": { "chain_id": 8453, "kms_contract_address": "0x2f83172A49584C017F2B256F0FB2Dca14126Ba9C" },
+ "chain_id": 8453,
+ "contract_explorer_url": "https://basescan.org/address/0x09cef33ca9c6e4f2b1d5a7c3e8f0b2a4d6e8f1c3",
+ "onchain_status": {
+ "compose_hash_allowed": false,
+ "device_id_allowed": true,
+ "is_allowed": false
+ },
+ "commit_token": "a5732e36-8f4b-4c1d-9e7a-2b5c8d9e0f1a",
+ "commit_url": "https://cloud.phala.com/my-team/cvms/01234567-89ab-cdef-0123-456789abcdef/confirm-update?token=a5732e36-8f4b-4c1d-9e7a-2b5c8d9e0f1a&compose_hash=ff22c67f1c8d5e4a9b3d2c1e0f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9",
+ "api_commit_url": "https://cloud-api.phala.com/api/v1/cvms/01234567-89ab-cdef-0123-456789abcdef/commit-update?token=a5732e36-8f4b-4c1d-9e7a-2b5c8d9e0f1a"
+}
+```
+
+### Replicating to a new node
+
+Replication to an additional node (for HA or migration) uses the same prepare mechanism:
+
+```bash
+phala cvms replicate \
+ --node-id \
+ --prepare-only
+```
+
+The replicate prepare path prints the same fields as the deploy prepare path. If the target node's `device_id` is not already on the allowlist, the on-chain status will show `Device ID: NOT registered` — in that case you need to run two separate on-chain actions as part of Phase 2: `addDevice(bytes32)` for the new device, then `addComposeHash(bytes32)` if the compose hash is also missing. Both can be batched into a single Safe transaction (see the Transaction Builder section below).
+
+See [Device Management](/phala-cloud/key-management/device-management) for how to look up `device_id` for a given node.
+
+### What is on-chain Status telling me?
+
+`onchain_status` reflects the actual DstackApp state at prepare time. There are three useful combinations:
+
+- `compose_hash_allowed: false` and `device_id_allowed: true` — standard case for a compose file change on an existing node. Approve `addComposeHash` only.
+- `compose_hash_allowed: false` and `device_id_allowed: false` — new node and new compose hash. Approve `addDevice` and `addComposeHash`, ideally in a single Safe batch.
+- `compose_hash_allowed: true` — the compose hash was already registered, perhaps by a previous aborted prepare. You can skip Phase 2 entirely and go straight to Phase 3 with `--transaction-hash already-registered`.
+
+## Approve Phase — Three Paths
+
+The approve phase is off the Phala Cloud backend. Whoever holds signing power on the owner contract runs an on-chain transaction that calls `addComposeHash(bytes32)` (and possibly `addDevice(bytes32)`) on the DstackApp contract. How they do it depends on your governance setup.
+
+### Path A: Dashboard Confirm Update page
+
+The simplest path is for a single human approver who personally holds a signing wallet. Open the `Commit URL` from the prepare output. It takes you to the Confirm Update page, which shows:
+
+- Workspace name and slug
+- CVM name and App ID
+- The compose hash to register (with a copy button)
+- Who initiated the prepare and when
+- An input for the transaction hash
+- A large **Commit Update** button
+
+
+
+The page does not sign the on-chain transaction for you. You still need to run `addComposeHash` from your wallet (via Basescan, Etherscan, Safe, or any other tool), then paste the resulting transaction hash into the form. Once you click **Commit Update**, the dashboard calls the same `POST /cvms/{id}/commit-update` endpoint that the CLI calls in Phase 3, and the backend verifies the transaction receipt before rolling the update forward.
+
+If the compose hash is already registered on-chain (see `onchain_status.compose_hash_allowed: true`), leave the transaction hash field empty. The backend will accept `already-registered` as a sentinel and only re-verify state.
+
+### Path B: Gnosis Safe Transaction Builder
+
+For a Safe-owned app, most teams use the official Transaction Builder app.
+
+1. Open your Safe at [app.safe.global](https://app.safe.global). Make sure the network matches the chain from the prepare output (Base or Ethereum).
+2. Go to **Apps** and open **Transaction Builder**.
+3. Paste the DstackApp contract address (the **App ID** field from prepare output) into the address field. The Transaction Builder will fetch the ABI from Basescan/Etherscan if the contract is verified. If auto-fetch fails, paste the ABI for `addComposeHash(bytes32)` manually — you can copy it from any existing DstackApp contract page on the explorer.
+4. Select `addComposeHash` from the method dropdown.
+5. Enter the compose hash from the prepare output as the `bytes32` parameter. Triple-check the value — it is the single most important field and it is irreversible once executed. A mistyped hash means you authorized the wrong code to run.
+6. Click **Add Transaction**. If you also need to allow a new device, click **Add Transaction** again, select `addDevice`, and enter the `device_id` from the prepare output. Both actions end up in the same Safe batch.
+7. Click **Create Batch**, then **Send Batch**. This creates a Safe transaction pending signatures.
+8. Have the other signers approve through Safe's Transactions tab until the threshold is met.
+9. Execute the transaction. Copy the resulting transaction hash — you will need it for Phase 3.
+
+
+ The Transaction Builder does not verify that the compose hash matches anything meaningful. You are trusting the prepare output. Before signing, cross-check the compose hash against the git commit or release artifact that the hash was computed from. The backend re-hashes the compose document at commit time, so a mismatch between the file you prepared and the file currently saved on Phala Cloud will surface as `HASH_INVALID_OR_EXPIRED`.
+
+
+### Path C: Custom governance contract (Timelock, DAO)
+
+If your owner is a Timelock or a DAO voting contract, you typically need to encode a generic proposal targeting the DstackApp contract. Two pieces of information are required:
+
+- **Target** — the DstackApp contract address (the `app_id` field from prepare output, with `0x` prefix).
+- **Calldata** — the ABI-encoded call to `addComposeHash(bytes32)` with the compose hash as the argument.
+
+
+ The `onchain_action.calldata` field in the webhook payload (see the next section) contains only the raw compose hash, **not** the fully ABI-encoded calldata that most governance contracts expect. You must encode the function call yourself before submitting to a Timelock or DAO proposer. The field name is kept generic because different governance frameworks expect different input shapes.
+
+
+To encode the calldata, use any Ethereum tooling. With `cast` (Foundry):
+
+```bash
+cast calldata "addComposeHash(bytes32)" \
+ 0xff22c67f1c8d5e4a9b3d2c1e0f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9
+```
+
+Output:
+
+```
+0x... (4-byte selector) || 32-byte compose hash
+```
+
+Or in TypeScript with `viem`:
+
+```typescript
+import { encodeFunctionData } from "viem";
+
+const calldata = encodeFunctionData({
+ abi: [
+ {
+ type: "function",
+ name: "addComposeHash",
+ inputs: [{ name: "composeHash", type: "bytes32" }],
+ stateMutability: "nonpayable",
+ },
+ ],
+ functionName: "addComposeHash",
+ args: [
+ "0xff22c67f1c8d5e4a9b3d2c1e0f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9",
+ ],
+});
+```
+
+Feed the encoded bytes into your governance contract's proposal method alongside the DstackApp address. Once the proposal passes the Timelock delay or DAO vote, execute it. The resulting transaction hash goes into Phase 3.
+
+Before submitting, decode the calldata one more time to sanity-check:
+
+```bash
+cast calldata-decode "addComposeHash(bytes32)"
+```
+
+It should echo back the same compose hash you started with.
+
+## CLI: Commit Phase
+
+Once the on-chain transaction is mined, tell Phala Cloud to roll the CVM update forward.
+
+```bash
+phala deploy --cvm-id \
+ --commit \
+ --token a5732e36-8f4b-4c1d-9e7a-2b5c8d9e0f1a \
+ --compose-hash 0xff22c67f1c8d5e4a9b3d2c1e0f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9 \
+ --transaction-hash 0x1234abcd5678ef90...
+```
+
+You can also POST directly to `api_commit_url`:
+
+```bash
+curl -X POST "https://cloud-api.phala.com/api/v1/cvms//commit-update" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "token": "a5732e36-8f4b-4c1d-9e7a-2b5c8d9e0f1a",
+ "compose_hash": "0xff22c67f1c8d5e4a9b3d2c1e0f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9",
+ "transaction_hash": "0x1234abcd5678ef90..."
+ }'
+```
+
+The commit endpoint is token-based and does not require an API key. Only someone holding the commit token can finalize the update. This is intentional — it lets you hand off Phase 3 to a CI job or an automation bot without provisioning a full API token.
+
+When the backend receives the commit request it:
+
+1. Looks up the token and checks that it has not expired or been superseded.
+2. Verifies that the compose hash in the request matches the compose hash bound to the token at prepare time. If they differ, it rejects with `HASH_INVALID_OR_EXPIRED` — usually because the compose file was edited between prepare and commit.
+3. Reads `allowedComposeHashes` from the DstackApp contract. If the compose hash is not present on-chain, it rejects with `HASH_REGISTRATION_REQUIRED`.
+4. If a transaction hash other than `already-registered` was provided, it fetches the receipt and checks that the transaction interacted with the DstackApp contract and produced the expected state change. Mismatches surface as `TX_VERIFICATION_FAILED`.
+5. On success, proceeds with the regular update workflow: saves the new compose file, re-encrypts environment variables, and triggers the CVM restart.
+
+Successful commit output:
+
+```json
+{
+ "success": true,
+ "vm_uuid": "01234567-89ab-cdef-0123-456789abcdef",
+ "correlation_id": "corr_01HXYZ...",
+ "status": "pending"
+}
+```
+
+`correlation_id` lets you trace the update in the workspace audit log. `status` reflects the CVM state machine at the moment of response — follow up with `phala cvms get ` or the dashboard to watch the actual update complete.
+
+## Automating Approvals with Webhooks
+
+The whole flow above assumes a human notices when a prepare happens. For production teams, that is too fragile. Phala Cloud emits a `cvm.update.pending_approval` webhook every time a prepare-only update is created, which lets you wire prepare into Slack bots, approval dashboards, or automated Safe proposers.
+
+### Subscribing
+
+In the dashboard, go to **Workspace → Webhooks** and click **Add Webhook**. Enter:
+
+- **Endpoint URL** — an HTTPS URL you control. Plain `http://` is rejected, and private IPs, cloud metadata endpoints, `localhost`, and `.local` hostnames are all blocked at delivery time by the URL validator.
+- **Events** — check **Update Pending Approval** (event type `cvm.update.pending_approval`).
+- **Name** (optional) — a label for your own bookkeeping.
+
+When you save, the dashboard shows the signing secret exactly once in a dialog. The secret is prefixed with `whsec_` followed by 32 base64url bytes. Copy it into your secrets manager right away. If you lose it, you can reveal it again or rotate it, but both actions require a second-factor challenge — the reveal and rotate endpoints are gated by 2FA step-up verification.
+
+
+
+
+
+The same operations are available via the API:
+
+```bash
+curl -X POST "https://cloud-api.phala.com/api/v1/workspace/webhooks" \
+ -H "Authorization: Bearer $PHALA_API_KEY" \
+ -H "X-Workspace-Id: my-team" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "url": "https://bot.example.com/phala/webhook",
+ "events": ["cvm.update.pending_approval"],
+ "name": "Approval bot"
+ }'
+```
+
+The response body includes the generated `secret` on create. Subsequent `GET /workspace/webhooks` and `GET /workspace/webhooks/{id}` responses return a masked version — you have to hit `POST /workspace/webhooks/{id}/reveal-secret` (2FA required) to see the full value again, or `POST /workspace/webhooks/{id}/rotate-secret` (also 2FA required) to generate a new one.
+
+### Payload
+
+When a prepare-only update happens, Phala Cloud sends a JSON POST to your endpoint:
+
+```json
+{
+ "id": "evt_0a1b2c3d4e5f6789",
+ "event": "cvm.update.pending_approval",
+ "version": "1",
+ "created_at": "2026-04-10T12:34:56.789Z",
+ "workspace": {
+ "id": "my-team",
+ "name": "My Team"
+ },
+ "data": {
+ "cvm_id": "01234567-89ab-cdef-0123-456789abcdef",
+ "cvm_name": "payments-api",
+ "compose_hash": "0xff22c67f1c8d5e4a9b3d2c1e0f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9",
+ "app_id": "0x09cef33ca9c6e4f2b1d5a7c3e8f0b2a4d6e8f1c3",
+ "device_id": "0x7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8",
+ "kms": {
+ "chain_id": 8453,
+ "contract_address": "0x2f83172A49584C017F2B256F0FB2Dca14126Ba9C"
+ },
+ "onchain_action": {
+ "to": "0x09cef33ca9c6e4f2b1d5a7c3e8f0b2a4d6e8f1c3",
+ "method": "addComposeHash(bytes32)",
+ "calldata": "0xff22c67f1c8d5e4a9b3d2c1e0f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9"
+ },
+ "commit_token": "a5732e36-8f4b-4c1d-9e7a-2b5c8d9e0f1a",
+ "commit_url": "https://cloud.phala.com/my-team/cvms/01234567-89ab-cdef-0123-456789abcdef/confirm-update?token=a5732e36-8f4b-4c1d-9e7a-2b5c8d9e0f1a&compose_hash=ff22c67f1c8d5e4a9b3d2c1e0f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9",
+ "api_commit_url": "https://cloud-api.phala.com/api/v1/cvms/01234567-89ab-cdef-0123-456789abcdef/commit-update?token=a5732e36-8f4b-4c1d-9e7a-2b5c8d9e0f1a",
+ "expires_at": "2026-04-24T12:34:56.789Z",
+ "initiated_by": {
+ "user_id": "usr_01HXYZ",
+ "username": "alice"
+ }
+ }
+}
+```
+
+Envelope fields (present on every webhook, not just this event):
+
+- `id` — unique event ID in the form `evt_<16 hex chars>`. Use this as an idempotency key on your side.
+- `event` — the event type. For this flow it is always `cvm.update.pending_approval`.
+- `version` — schema version, currently `"1"`.
+- `created_at` — ISO 8601 UTC timestamp of dispatch.
+- `workspace.id` — the workspace slug (not the numeric team ID).
+- `workspace.name` — human-readable workspace name.
+
+Event-specific `data` fields:
+
+- `cvm_id` — the CVM UUID that is being updated.
+- `cvm_name` — user-provided CVM name.
+- `compose_hash` — the 32-byte compose hash with `0x` prefix. This is the argument to `addComposeHash`.
+- `app_id` — the DstackApp contract address. Same value as `onchain_action.to`.
+- `device_id` — the node device ID currently hosting the CVM.
+- `kms.chain_id` — EIP-155 chain ID (1 for Ethereum, 8453 for Base).
+- `kms.contract_address` — the KmsContract address (the Phala-operated KMS root, not your DstackApp).
+- `onchain_action.to` — target address for the on-chain transaction (your DstackApp address).
+- `onchain_action.method` — the method signature, currently always `addComposeHash(bytes32)`.
+- `onchain_action.calldata` — the raw compose hash. Note again: this is **not** ABI-encoded calldata. Encode it yourself if your governance contract needs a full function call payload.
+- `commit_token` — the commit token. Combined with `api_commit_url`, enough to complete Phase 3.
+- `commit_url` — the dashboard Confirm Update page.
+- `api_commit_url` — the raw `POST` endpoint.
+- `expires_at` — ISO 8601 timestamp, exactly 14 days after dispatch. Your automation should refuse to act on expired tokens.
+- `initiated_by.user_id` — hashed user ID of the person who ran `--prepare-only`.
+- `initiated_by.username` — their username.
+
+### Headers
+
+Every webhook delivery carries these HTTP headers:
+
+- `Content-Type: application/json`
+- `User-Agent: PhalaCloud-Webhook/1.0`
+- `X-Webhook-Id` — same as `payload.id`.
+- `X-Webhook-Event` — same as `payload.event`.
+- `X-Webhook-Timestamp` — Unix epoch seconds as a decimal string. This is the timestamp used in the HMAC message.
+- `X-Webhook-Signature` — the HMAC-SHA256 signature, in the form `sha256=<64 hex chars>`.
+
+### HMAC verification
+
+The signature is computed from the raw request body (as sent on the wire) and the timestamp header. The exact formula:
+
+```
+message = f"{X-Webhook-Timestamp}.{raw_body_utf8}"
+signature = hex(HMAC_SHA256(key=secret, msg=message))
+header = f"sha256={signature}"
+```
+
+Two things matter for correctness:
+
+1. The body must be the **raw** POST body, not a re-serialized version. If your framework parses JSON and then re-encodes it, key order or whitespace may differ and the HMAC will not match. Grab the raw bytes before parsing.
+2. Use a **constant-time** comparison when matching the signature. A naive `==` on strings leaks timing information.
+
+#### Python (FastAPI)
+
+```python
+import hashlib
+import hmac
+import logging
+import os
+
+from fastapi import FastAPI, Header, HTTPException, Request
+
+app = FastAPI()
+logger = logging.getLogger("phala-webhook")
+
+WEBHOOK_SECRET = os.environ["PHALA_WEBHOOK_SECRET"] # whsec_...
+
+
+def verify_signature(
+ secret: str, timestamp: str, raw_body: bytes, signature_header: str
+) -> bool:
+ if not signature_header.startswith("sha256="):
+ return False
+ provided = signature_header.removeprefix("sha256=")
+ message = f"{timestamp}.{raw_body.decode('utf-8')}".encode("utf-8")
+ expected = hmac.new(
+ secret.encode("utf-8"), message, hashlib.sha256
+ ).hexdigest()
+ return hmac.compare_digest(expected, provided)
+
+
+@app.post("/phala/webhook")
+async def receive_webhook(
+ request: Request,
+ x_webhook_timestamp: str = Header(...),
+ x_webhook_signature: str = Header(...),
+ x_webhook_event: str = Header(...),
+):
+ raw_body = await request.body()
+
+ if not verify_signature(
+ WEBHOOK_SECRET, x_webhook_timestamp, raw_body, x_webhook_signature
+ ):
+ raise HTTPException(status_code=401, detail="Invalid signature")
+
+ if x_webhook_event != "cvm.update.pending_approval":
+ # Ignore other events for this endpoint
+ return {"ok": True}
+
+ payload = await request.json()
+ data = payload["data"]
+ logger.info(
+ "Pending approval: cvm=%s hash=%s commit_url=%s expires_at=%s",
+ data["cvm_id"],
+ data["compose_hash"],
+ data["commit_url"],
+ data["expires_at"],
+ )
+ # Hand off to your approval pipeline here (Slack, Safe API, etc.)
+ return {"ok": True}
+```
+
+#### Node.js (Express)
+
+```javascript
+import crypto from "node:crypto";
+import express from "express";
+
+const app = express();
+const WEBHOOK_SECRET = process.env.PHALA_WEBHOOK_SECRET; // whsec_...
+
+// IMPORTANT: capture the raw body before JSON parsing
+app.use("/phala/webhook", express.raw({ type: "application/json" }));
+
+function verifySignature(secret, timestamp, rawBody, signatureHeader) {
+ if (!signatureHeader?.startsWith("sha256=")) return false;
+ const provided = signatureHeader.slice("sha256=".length);
+ const message = `${timestamp}.${rawBody.toString("utf8")}`;
+ const expected = crypto
+ .createHmac("sha256", secret)
+ .update(message, "utf8")
+ .digest("hex");
+ const providedBuf = Buffer.from(provided, "hex");
+ const expectedBuf = Buffer.from(expected, "hex");
+ if (providedBuf.length !== expectedBuf.length) return false;
+ return crypto.timingSafeEqual(providedBuf, expectedBuf);
+}
+
+app.post("/phala/webhook", (req, res) => {
+ const timestamp = req.header("x-webhook-timestamp");
+ const signature = req.header("x-webhook-signature");
+ const event = req.header("x-webhook-event");
+
+ if (!verifySignature(WEBHOOK_SECRET, timestamp, req.body, signature)) {
+ return res.status(401).json({ error: "Invalid signature" });
+ }
+
+ const payload = JSON.parse(req.body.toString("utf8"));
+ if (event !== "cvm.update.pending_approval") {
+ return res.json({ ok: true });
+ }
+
+ const { cvm_id, compose_hash, commit_url, expires_at } = payload.data;
+ console.log("Pending approval", {
+ cvm_id,
+ compose_hash,
+ commit_url,
+ expires_at,
+ });
+ // Hand off to your approval pipeline here
+ return res.json({ ok: true });
+});
+
+app.listen(3000);
+```
+
+### Delivery semantics
+
+- **Success** — any HTTP 2xx response is treated as success. The delivery is recorded and no further attempts are made.
+- **Failure** — any non-2xx response, connection error, or timeout is treated as a failure and retried.
+- **Timeout** — 10 seconds per delivery attempt. Design your handler to return quickly (queue the work, return 200 immediately).
+- **Retries** — up to 3 additional attempts on failure, with delays of 1 minute, 10 minutes, and 1 hour. After the final attempt the delivery is logged as `failed` and no further retries happen.
+- **Idempotency** — retries reuse the same `event_id`. Use it as a dedup key on your side so retried deliveries do not double-approve.
+- **URL validation at delivery time** — every attempt re-validates the URL. HTTPS is mandatory; private IPs, link-local addresses, loopback, `169.254.169.254` (cloud metadata), `localhost`, and `.local` hostnames are all rejected. This guards against DNS rebinding attacks — a hostname that passes validation at save time but later resolves to an internal IP will be blocked on delivery.
+
+### Viewing deliveries
+
+The dashboard has a per-webhook deliveries page at **Workspace → Webhooks → (view icon)** that shows the last 50 attempts: status, attempt count, response code, latency, and any error message. From the same page you can resend a failed delivery — this generates a new `event_id` linked to the original via `original_event_id`, so your handler can dedupe either way.
+
+
+
+The same data is available programmatically:
+
+```bash
+curl "https://cloud-api.phala.com/api/v1/workspace/webhooks//deliveries?limit=50" \
+ -H "Authorization: Bearer $PHALA_API_KEY" \
+ -H "X-Workspace-Id: my-team"
+```
+
+And aggregate stats:
+
+```bash
+curl "https://cloud-api.phala.com/api/v1/workspace/webhooks//stats" \
+ -H "Authorization: Bearer $PHALA_API_KEY" \
+ -H "X-Workspace-Id: my-team"
+```
+
+### Automation patterns
+
+Once you have a verified receiver, the question is what to do with the event. A few common shapes:
+
+- **Slack bot approval** — post a message to a private channel with the CVM name, compose hash, and a link to the Confirm Update page. Require a specific reviewer to click an approval button, then call `api_commit_url` from the bot after the on-chain transaction is mined.
+- **Auto-propose to Safe** — use the [Safe API Kit](https://github.com/safe-global/safe-core-sdk) to create a pending Safe transaction calling `addComposeHash(bytes32)` the moment a webhook fires. Signers approve in the Safe UI. A second script watches the Safe for executed transactions and calls the commit endpoint when the transaction lands.
+- **CI/CD approval queue** — store the webhook payload in a queue table (Postgres, Redis, S3). Your release pipeline reads the queue, assigns a reviewer, and eventually commits through a service account. `expires_at` gates the queue: drop entries after 14 days.
+- **Timelock relay** — decode the webhook into a Timelock proposal, submit, wait for the delay to elapse, execute, and commit. This is the fully on-chain DAO shape.
+
+## Error Modes and Troubleshooting
+
+### Compose hash drift between prepare and commit
+
+Symptom: Phase 3 fails with `HASH_INVALID_OR_EXPIRED` even though the on-chain transaction was accepted.
+
+Cause: someone edited the compose file between prepare and commit, so the hash you registered on-chain no longer matches the compose currently saved for this CVM. The backend re-hashes the compose document at commit time and refuses to roll forward if the hashes disagree.
+
+Fix: rerun `phala deploy --cvm-id ... --prepare-only -c new-compose.yml` with the final compose file, repeat Phase 2 with the new hash, and commit again. The old on-chain `addComposeHash` is harmless — it stays in the allowlist but is never used.
+
+### Expired commit token
+
+Symptom: Phase 3 fails with `Failed to commit CVM update: ... expired ...` and the CLI suggests running `--prepare-only` again.
+
+Cause: either the 14-day TTL elapsed, or a newer prepare for the same CVM invalidated this token.
+
+Fix: rerun prepare. If your team is hitting the 14-day window regularly, shorten your review cycle or switch to webhook-driven automation. If a second operator also ran prepare, coordinate and pick which token to use — only the latest one is valid.
+
+### Owner mismatch (direct EOA update after transferring to Safe)
+
+Symptom: `phala deploy --cvm-id ... --private-key ... -c new-compose.yml` fails inside the `addComposeHash` or `addDevice` step with an EVM revert about ownership.
+
+Cause: you transferred ownership to a Safe (or any other contract) and then tried to update using an EOA. The EOA no longer holds the owner role, so the on-chain write reverts.
+
+Fix: check `owner()` on Basescan or Etherscan. If it returns a Safe address, use the prepare and commit flow. If it returns an EOA different from the one you signed with, you transferred to the wrong account — recover by signing with the correct EOA and calling `transferOwnership` back, or by using the current owner to push the update.
+
+### Transaction reverted on-chain
+
+Symptom: Your Safe or governance contract executes the proposal but the `addComposeHash` call reverts.
+
+Common causes:
+
+- The Safe is on a different chain than the DstackApp contract. Double-check the network selector before signing.
+- The calldata was hand-built with the wrong function selector or the wrong argument type. Use `cast calldata-decode` to sanity-check before proposing.
+- The `owner()` of the DstackApp contract is still the EOA, not the Safe. Verify ownership was transferred before preparing.
+- Gas ran out on a Timelock relay. Resubmit with a higher gas limit.
+
+### Error code reference
+
+The four error codes you will see most often in this flow all live under Module 01 in the [Error Codes reference](/phala-cloud/references/error-codes): `ERR-01-005` (hash registration required), `ERR-01-006` (hash invalid or expired), `ERR-01-007` (transaction verification failed), and `ERR-01-008` (hash not allowed). The reference page has the full list with HTTP status codes and exception class names; use it as the source of truth.
+
+A quick map of which phase you most commonly hit each one in:
+
+- **Phase 3 commit fails with `ERR-01-005`** — Phase 2 has not actually landed on-chain yet, or it landed on a different contract. Check `allowedComposeHashes` on the DstackApp you prepared against.
+- **Phase 3 commit fails with `ERR-01-006`** — the commit token is expired or superseded, or the compose file on the backend no longer matches what the token was bound to. Re-prepare.
+- **Phase 3 commit fails with `ERR-01-007`** — the transaction hash you passed does not verify against the expected state change. Double-check you pasted the correct hash and that the transaction is mined.
+- **Phase 3 commit fails with `ERR-01-008`** — the compose hash is simply not on the allowlist. Same remediation as `ERR-01-005`; most often it means the Safe batch executed but targeted the wrong address or wrong method selector.
+
+When the CLI hits `ERR-01-006`, it explicitly hints at re-running `--prepare-only` to get a new token. When it hits `ERR-01-007`, it prints the transaction hash so you can look it up on the explorer.
+
+## Device Operations Without CLI Prepare/Commit
+
+
+ At the time of writing, the CLI `phala allow-devices` subcommands (`list`, `add`, `remove`, `allow-any`, `disallow-any`, `toggle-allow-any`) all require `--private-key` and do not support `--prepare-only`. If your DstackApp owner is a Safe, Timelock, or DAO, the CLI cannot drive device operations for you. You must use the wallet UI path.
+
+
+The workaround is straightforward: use the same Safe Transaction Builder or governance proposal path from the approve phase, just targeting a different method on the same DstackApp contract.
+
+1. Look up the `device_id` you want to add or remove. For a new node, this is the `device_id` shown in the node picker when provisioning, and also in the prepare output when you run `phala cvms replicate ... --prepare-only`. See [Device Management](/phala-cloud/key-management/device-management) for the lookup paths.
+2. Open your DstackApp contract on Basescan or Etherscan, or open Safe Transaction Builder and paste the contract address.
+3. Call the relevant method:
+ - `addDevice(bytes32 deviceId)` — allow a specific device to host the app.
+ - `removeDevice(bytes32 deviceId)` — revoke a previously allowed device.
+ - `setAllowAnyDevice(bool allow)` — toggle the global bypass. When `true`, any node in the workspace can boot the CVM without the per-device check.
+4. Collect Safe signatures and execute, or push through the Timelock delay / DAO vote.
+5. There is no commit phase for device operations. The next time the CVM is provisioned on a new node, KMS reads the allowlist straight from the contract.
+
+See [Device Management](/phala-cloud/key-management/device-management) for the full conceptual model, how `device_id` is derived from TDX PPID, the one-node-to-one-device rule, and HA recommendations for `allowAnyDevice`.
+
+## Next Steps
+
+- [Deploying with Onchain KMS](/phala-cloud/key-management/deploying-with-onchain-kms) — the first-time deployment walkthrough.
+- [Updating with Onchain KMS](/phala-cloud/key-management/updating-with-onchain-kms) — the direct EOA update path when ownership has not been transferred to a multisig yet.
+- [Understanding Onchain KMS](/phala-cloud/key-management/understanding-onchain-kms) — the conceptual model (contracts, boot-time flow, compose hash).
+- [Device Management](/phala-cloud/key-management/device-management) — device allowlisting, PPID, HA, and the `allow-devices` CLI reference.
+- [Cloud vs Onchain KMS](/phala-cloud/key-management/cloud-vs-onchain-kms) — conceptual comparison and governance trade-offs.
diff --git a/phala-cloud/key-management/understanding-onchain-kms.mdx b/phala-cloud/key-management/understanding-onchain-kms.mdx
new file mode 100644
index 0000000..4722dfd
--- /dev/null
+++ b/phala-cloud/key-management/understanding-onchain-kms.mdx
@@ -0,0 +1,104 @@
+---
+title: Understanding Onchain KMS
+description: The mental model behind Onchain KMS — which contracts exist, who signs what, and how the KMS verifies your CVM at boot time.
+---
+
+Onchain KMS puts a smart contract in the critical path between your CVM and the keys it needs to run. The contract decides which code is allowed, which hardware is allowed, and who can change either list. Phala Cloud can read the contract, but it cannot write to it — every authorization change originates from your wallet.
+
+This page is the conceptual reference. Read it once before your first deploy so the rest of the Onchain KMS docs click into place. If you already have the mental model and want to ship, go straight to [Deploying with Onchain KMS](/phala-cloud/key-management/deploying-with-onchain-kms).
+
+For the higher-level "should I use Cloud KMS or Onchain KMS?" comparison, see [Cloud vs Onchain KMS](/phala-cloud/key-management/cloud-vs-onchain-kms). For the underlying protocol and threat model, see [What is KMS](/phala-cloud/key-management/key-management-protocol).
+
+## Two Contracts
+
+Two contracts cooperate to authorize your CVM.
+
+**KmsAuth** is a shared contract that Phala operates, one per chain. There is a single KmsAuth deployment on Ethereum and a separate one on Base. It knows the identity of the KMS nodes running inside TEE, keeps a list of approved dstack OS images, and exposes a factory method that mints a new per-application contract. You interact with KmsAuth exactly once per application, when you deploy a new app.
+
+**DstackApp** is a per-application contract that you deploy through the KmsAuth factory. Each CVM is bound to one DstackApp. It stores three things that matter for the rest of these docs:
+
+- `owner` — the address that can add or remove allowlisted code and devices. Initially this is the wallet that deployed the DstackApp. You can transfer it to a Safe, a timelock, or any contract.
+- `allowedComposeHashes` — the set of compose hashes that are allowed to run under this application identity. Detailed in [What the compose hash is](#what-the-compose-hash-is) below.
+- `allowedDeviceIds` — the set of TDX device identities that are allowed to host this application. Detailed in [Device Management](/phala-cloud/key-management/device-management).
+
+The critical property is that **Phala Cloud's backend does not send any transaction on your behalf.** Deploying the DstackApp, adding a compose hash, and adding a device are all writes initiated by either the CLI (which signs with `--private-key`) or a browser wallet connected through the dashboard. The backend only reads the chain afterwards to verify that the state it expects is actually there.
+
+Contract addresses for each chain are listed under the `Ethereum` and `Base` accordions in [Cloud vs Onchain KMS](/phala-cloud/key-management/cloud-vs-onchain-kms#ethereum-vs-base).
+
+## Who Writes What, and When
+
+It helps to keep a mental table of who signs which transaction:
+
+| Operation | Contract | Who signs |
+| --- | --- | --- |
+| First deploy (creates DstackApp + registers first compose hash + first device) | `KmsAuth.deployAndRegisterApp` | Your wallet (CLI `--private-key` or browser wallet) |
+| Add a new compose hash (update the running code) | `DstackApp.addComposeHash` | The DstackApp owner |
+| Add a new device (allow a new node to host the CVM) | `DstackApp.addDevice` | The DstackApp owner |
+| Transfer ownership to a Safe / timelock / DAO | `DstackApp.transferOwnership` | The current DstackApp owner |
+
+Phala Cloud's backend appears nowhere in that table. It offers to compute `compose_hash` and `device_id` for you during provisioning, but neither value is a secret — both are deterministic functions of inputs you control, so you can compute and verify them yourself if you want. The backend never writes to the chain.
+
+## What the Compose Hash Is
+
+The `compose_hash` is the SHA-256 of a canonicalized JSON document called **`app-compose.json`**, not a hash of `docker-compose.yml` by itself. `app-compose.json` is the dstack manifest for your application and includes all of the following fields — any one of which, if changed, changes the hash:
+
+- `manifest_version`
+- `name`
+- `runner` — `docker-compose`, `bash`, etc.
+- `docker_compose_file` — the full compose file contents embedded as a string (for `runner: docker-compose`)
+- `bash_script` — the script contents (for `runner: bash`)
+- `pre_launch_script` — optional
+- `kms_enabled`, `gateway_enabled`, `local_key_provider_enabled`, `key_provider`, `key_provider_id`
+- `allowed_envs` — whitelist of environment variable names your workload may read
+- `public_logs`, `public_sysinfo`, `public_tcbinfo`
+- `no_instance_id`, `secure_time`, `storage_fs`, `swap_size`
+
+See the [`AppCompose` definition in dstack-types](https://github.com/Dstack-TEE/dstack/blob/master/dstack-types/src/lib.rs) for the authoritative list of fields.
+
+Canonicalization sorts the object keys alphabetically and serializes with a specific JSON variant so that the hash is deterministic regardless of which tool produced it. The CLI exports [`getComposeHash()`](https://github.com/Phala-Network/phala-cloud/blob/main/js/src/utils/get_compose_hash.ts) from `@phala/cloud` if you want to compute the hash in your own code and compare it against what the backend or the contract reports.
+
+Two consequences worth keeping in mind:
+
+- **Any change to `app-compose.json` — not just the compose file inside it, but any field — produces a new compose hash.** Toggling `public_logs`, adding an entry to `allowed_envs`, or bumping the `runner` all change the hash. Each change requires a new on-chain `addComposeHash` call before the CVM can start.
+- **Bumping a Docker image tag inside `docker_compose_file` changes the embedded string, which changes the compose hash.** You cannot sneak a new image under an old allowlisted hash.
+
+## What the Device ID Is
+
+The `device_id` is a derivation of the TDX platform identifier (PPID) for the node where the CVM is scheduled. If the scheduler places your CVM on a new node, the device ID will be different, and the DstackApp has to allowlist it before the KMS will release keys on that node. [Device Management](/phala-cloud/key-management/device-management) covers the derivation, drift, and recovery scenarios in detail. For the rest of these onchain KMS guides, you only need to know that the CLI and dashboard add the current node's device ID to the allowlist for you during the first deploy.
+
+## Boot-Time Verification Flow
+
+When a CVM starts and asks the KMS for keys, the sequence is:
+
+```
+CVM boots inside TDX
+ |
+ v
+Worker generates a TDX quote bound to the running compose hash + device ID
+ |
+ v
+KMS verifies the TDX quote against Intel's attestation roots
+ |
+ v
+KMS reads the DstackApp contract:
+ - DstackApp.allowedComposeHashes(composeHash) == true ?
+ - DstackApp.allowedDeviceIds(deviceId) == true
+ (or DstackApp.allowAnyDevice() == true)
+ |
+ v
+Both checks pass
+ |
+ v
+KMS releases derived application keys over an attested channel
+```
+
+Every gate in this flow is either a hardware attestation check or a contract read. There is no Phala-operated allowlist in the middle. If the DstackApp rejects the CVM, the CVM never sees its keys, regardless of what the Phala Cloud backend believes. That is the chain of trust Onchain KMS buys you: the policy enforcement lives in code that thousands of blockchain nodes agree on, not on a server you have to trust.
+
+The implication for threat modeling is concrete. If Phala Cloud's backend were compromised and an attacker tried to push a new compose hash to your CVM without your consent, they could not do it — the KMS would check the DstackApp, see that the new hash is not in `allowedComposeHashes`, and refuse to release keys. The only way to get a new compose hash past the KMS is to convince your wallet (or your Safe, or your DAO) to sign `addComposeHash`. See [What is KMS](/phala-cloud/key-management/key-management-protocol) for the full threat model, including why "every operation involving key authorization changes must be initiated onchain" is a design principle.
+
+## Next Steps
+
+- [Deploying with Onchain KMS](/phala-cloud/key-management/deploying-with-onchain-kms) — the first-deploy walkthrough via the dashboard or the CLI, with a worked example and verification steps.
+- [Updating with Onchain KMS](/phala-cloud/key-management/updating-with-onchain-kms) — how to roll a new compose file when the DstackApp owner is a plain wallet you control.
+- [Device Management](/phala-cloud/key-management/device-management) — PPID, device_id derivation, HA allowlist patterns, and recovering from hardware drift.
+- [Multisig and Governance](/phala-cloud/key-management/multisig-governance) — the prepare-and-commit flow when the DstackApp owner is a Safe, timelock, or DAO contract.
diff --git a/phala-cloud/key-management/updating-with-onchain-kms.mdx b/phala-cloud/key-management/updating-with-onchain-kms.mdx
new file mode 100644
index 0000000..efc7da2
--- /dev/null
+++ b/phala-cloud/key-management/updating-with-onchain-kms.mdx
@@ -0,0 +1,135 @@
+---
+title: Updating with Onchain KMS
+description: Roll a new compose file to a running Onchain KMS CVM when the DstackApp owner is a plain wallet you control.
+---
+
+This page covers the happy path for changing the compose file of a running Onchain KMS CVM when you still hold the DstackApp `owner` key as a plain EOA. The update is a single CLI command that handles the on-chain transactions for you.
+
+If the owner is a Safe, timelock, or DAO contract, the single-command path will not work — jump to [Multisig and Governance](/phala-cloud/key-management/multisig-governance). For the first-deploy flow and conceptual background, see [Deploying with Onchain KMS](/phala-cloud/key-management/deploying-with-onchain-kms) and [Understanding Onchain KMS](/phala-cloud/key-management/understanding-onchain-kms).
+
+## What Triggers a New Compose Hash
+
+Any change that modifies `app-compose.json` produces a new `compose_hash` and requires a new on-chain `addComposeHash` call before the CVM can start running the new version. Common triggers:
+
+- Editing `docker-compose.yml` (bumping an image tag, adding a service, changing a port, whitespace changes — all count)
+- Changing `allowed_envs`, `public_logs`, `public_sysinfo`, or `public_tcbinfo`
+- Changing the `runner` or `pre_launch_script`
+
+Env var value changes alone (without touching `allowed_envs`) do not change the compose hash — they are encrypted and stored separately. But they still go through this same update command.
+
+See [What the compose hash is](/phala-cloud/key-management/understanding-onchain-kms#what-the-compose-hash-is) for the full list of fields that participate in hashing.
+
+## The Single-Command Update
+
+When the DstackApp owner is still a plain wallet, updating is one command:
+
+```bash
+phala deploy \
+ --cvm-id \
+ -c new-compose.yml \
+ --private-key "$PRIVATE_KEY"
+```
+
+The `--cvm-id` flag accepts the CVM name (if unique in your workspace), the CVM UUID, or the app_id — whichever is most convenient. The CLI detects that you passed `--cvm-id`, so it goes down the update path instead of the new-deployment path.
+
+Add `-e .env` or inline `-e KEY=VALUE` if you are changing environment variables alongside the compose file. Env var values are encrypted with the CVM's per-application env-encryption public key before leaving the CLI, same as on first deploy.
+
+Expect two wallet signatures in the worst case (one for `addDevice`, one for `addComposeHash`). In the common case where the scheduler kept your CVM on the same node, just one.
+
+## What Happens Under the Hood
+
+The single command maps to the following sequence, in order:
+
+1. **Reads the CVM metadata** from the backend, including the existing DstackApp contract address and the chain info.
+2. **Patches the CVM** with the new compose file (and optional new env vars). The backend computes the new `compose_hash` and responds with `requiresOnChainHash: true`, along with the device ID of the current node.
+3. **Checks on-chain prerequisites** by calling a read-only helper that asks the DstackApp contract whether the compose hash and device ID are already allowed.
+4. **If the device is not yet allowed**, sends `addDevice(deviceId)` from `--private-key`. This happens automatically when the scheduler puts your CVM on a node it has not used before.
+5. **Sends `addComposeHash(newHash)`** from `--private-key`. This is the main update transaction. It is idempotent at the contract level — the CLI sends it even if a state read says the hash is already allowed, so the backend can confirm against a real transaction hash.
+6. **Confirms the patch** with the backend. The backend reads the receipt, checks the `ComposeHashAdded` event, re-reads the contract state, and rolls the CVM over to the new compose file.
+
+For reference when reading CI/CD logs, the flow maps to these backend and on-chain calls in order:
+
+1. `GET /api/v1/cvms/` — read existing CVM metadata.
+2. `POST /api/v1/cvms/` (patch) — send new compose file and encrypted env. Backend returns new `composeHash`, `deviceId`, and `requiresOnChainHash: true`.
+3. Read-only chain call — `DstackApp.allowedDeviceIds(deviceId)` and `DstackApp.allowedComposeHashes(composeHash)` batched through a single RPC.
+4. If needed, on-chain `DstackApp.addDevice(deviceId)` signed by `--private-key`.
+5. On-chain `DstackApp.addComposeHash(composeHash)` signed by `--private-key`. The CLI waits for one confirmation.
+6. `POST /api/v1/cvms//confirm-patch` — the backend verifies the receipt, re-reads the contract state, and rolls the CVM onto the new compose.
+
+## What Changes and What Does Not
+
+When you run an update like this, the following stay the same:
+
+- **The DstackApp contract address.** It is still your `app_id`. Clients already talking to your service do not need to change anything.
+- **The CVM's `vm_uuid`.** The dashboard URL for the CVM does not change.
+- **The KMS-derived application keys.** Key derivation is tied to the `app_id`, not to the compose hash, so your persistent encrypted storage continues to decrypt correctly across updates.
+
+The following change:
+
+- **`compose_hash`** — a new hash is now in `allowedComposeHashes`.
+- **The container image and env var ciphertext.** The CLI re-encrypts new env values for the CVM's env-encryption key.
+- **The running processes inside the CVM.** The old compose is replaced by the new one.
+
+The old compose hash is not automatically removed from the allowlist. If you want to strictly block rollbacks, call `removeComposeHash(oldHash)` on the DstackApp from your wallet after the update is live.
+
+## When This Simple Flow Does Not Work
+
+The single-command EOA path assumes `--private-key` can sign transactions that the DstackApp will accept. Two situations break that assumption:
+
+- **The owner is no longer an EOA.** If you transferred ownership to a Safe, a timelock, or any other contract, `addComposeHash` called from a bare wallet will revert on-chain. You need the prepare-and-commit flow so the transaction can be routed through your governance contract. See [Multisig and Governance](/phala-cloud/key-management/multisig-governance).
+- **You want to add or remove a node without changing the compose file.** Device allowlisting is a separate flow from compose-hash updates. See [Device Management](/phala-cloud/key-management/device-management).
+
+
+ Do not transfer DstackApp ownership on a production CVM before reading [Multisig and Governance](/phala-cloud/key-management/multisig-governance). An irreversible mistake (pointing `owner` at an address you do not control) will leave you unable to update the compose hash, and the CVM will be frozen on its current version.
+
+
+## Troubleshooting
+
+
+
+ The backend reads the receipt for the `addComposeHash` transaction and looks for the `ComposeHashAdded(bytes32)` event. This can fail briefly right after sending due to RPC propagation delay. The backend retries a few times with short gaps before giving up. If you consistently see this error:
+
+ - Double-check that `--rpc-url` points to a healthy endpoint and is consistent between your signing RPC and the backend's read RPC.
+ - Verify the transaction actually succeeded on the block explorer — a reverted transaction will also produce this error.
+ - If everything looks fine on-chain but the backend still rejects, retry the update from the start. The CLI will re-send `addComposeHash`, which is idempotent at the contract level.
+
+
+
+ The wallet behind `--private-key` is not the current DstackApp owner. Open the DstackApp contract on Basescan or Etherscan and read `owner()`:
+
+ - If `owner()` returns a different EOA, you are signing with the wrong key. Switch keys and retry.
+ - If `owner()` returns a contract address (Safe, Timelock, or other), the EOA path will never work. Switch to [Multisig and Governance](/phala-cloud/key-management/multisig-governance).
+
+
+
+ You edited something that does not affect `app-compose.json`. Env var values, the `--private-key`, and the `--rpc-url` do not participate in the hash. Only the fields listed in [What the compose hash is](/phala-cloud/key-management/understanding-onchain-kms#what-the-compose-hash-is) do.
+
+ Run the update anyway — if the CLI sees no hash change, it skips `addComposeHash` entirely and only pushes the new env var ciphertext through the patch endpoint.
+
+
+
+ Commit succeeded but the CVM restart is still rolling. Check the CVM logs with `phala logs ` or in the dashboard. The common slow paths are image pull time (large images on a fresh node) and the workload's own startup time. If the CVM keeps reverting to the old compose, open a support ticket with the correlation_id from the CLI output.
+
+
+
+ The CLI could not resolve the chain from the CVM metadata and no `--rpc-url` / `ETH_RPC_URL` fallback was provided. Set `--rpc-url` explicitly and retry, or set `ETH_RPC_URL` in your environment. You can verify with `echo $ETH_RPC_URL`.
+
+
+
+### Error codes
+
+Errors from the confirm step map to structured error codes from Module 01 of the [Error Codes reference](/phala-cloud/references/error-codes). The four that matter for this flow are `ERR-01-005` through `ERR-01-008`. Each one tells you something specific:
+
+- **`ERR-01-005` (HTTP 465)** — the compose hash is not yet on `allowedComposeHashes`. Usually a transient state during the confirm step if you bailed out before `addComposeHash` landed; retry.
+- **`ERR-01-006` (HTTP 466)** — the provision cache for this compose hash has expired (14-day TTL) or the compose file changed since you started the update. Re-run the update command.
+- **`ERR-01-007` (HTTP 467)** — the transaction hash you (or the CLI) passed does not verify against the expected `addComposeHash` state change. Usually RPC propagation delay or a reverted tx; see the accordion above.
+- **`ERR-01-008` (HTTP 468)** — the compose hash is genuinely not in `allowedComposeHashes` from the contract's point of view. If this appears after `addComposeHash` supposedly succeeded, you probably called it on the wrong contract — check the DstackApp address on Basescan.
+
+[Multisig and Governance](/phala-cloud/key-management/multisig-governance#error-code-reference) discusses which phase of a multisig update each error typically appears in.
+
+## Next Steps
+
+- [Multisig and Governance](/phala-cloud/key-management/multisig-governance) — the prepare-and-commit flow for compose updates when the DstackApp owner is a Safe, a timelock, a DAO, or any custom contract. Also covers webhook notifications for pending approvals.
+- [Device Management](/phala-cloud/key-management/device-management) — how to add and remove nodes from the allowlist without touching the compose file, and how to recover from hardware-driven `device_id` drift.
+- [Understanding Onchain KMS](/phala-cloud/key-management/understanding-onchain-kms) — the conceptual model behind compose hashes and the boot-time verification flow.
+- [Error Codes reference](/phala-cloud/references/error-codes) — the full catalog of `ERR-01-*` codes returned by the CVM API.
diff --git a/phala-cloud/phala-cloud-cli/allow-devices.mdx b/phala-cloud/phala-cloud-cli/allow-devices.mdx
index 8fad869..3122eac 100644
--- a/phala-cloud/phala-cloud-cli/allow-devices.mdx
+++ b/phala-cloud/phala-cloud-cli/allow-devices.mdx
@@ -1,6 +1,6 @@
---
title: "allow-devices"
-description: "Manage on-chain device allowlist for a CVM's app contract"
+description: "Manage on-chain device allowlist for an app contract"
---
@@ -17,7 +17,7 @@ phala allow-devices [options]
### Description
-Manage on-chain device allowlist for a CVM's app contract
+Manage on-chain device allowlist for an app contract
### Global Options
@@ -25,19 +25,22 @@ Manage on-chain device allowlist for a CVM's app contract
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `--interactive` | Enable interactive mode for commands that support it |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
+| `--profile PROFILE` | Temporarily use a different auth profile for this command |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Subcommands
| Command | Description |
| ------- | ----------- |
-| `list` | (ls) List allowed devices from the on-chain contract |
-| `add` | Add device(s) to the on-chain allowlist |
-| `remove` | (rm) Remove device(s) from the on-chain allowlist |
-| `allow-any` | Set allow-any-device flag on the contract |
-| `disallow-any` | Disable allow-any-device on the contract |
+| `list` | List allowed devices from the on-chain contract |
+| `add` | Add devices to the on-chain allowlist |
+| `remove` | Remove devices from the on-chain allowlist |
+| `allow-any` | Set the allow-any-device flag on the contract. Requires --enable or --disable. |
+| `disallow-any` | Disable allow-any-device on the contract. Equivalent to `allow-any --disable`. |
| `toggle-allow-any` | Toggle allow-any-device on the contract (or force via --enable/--disable) |
### Examples
diff --git a/phala-cloud/phala-cloud-cli/api.mdx b/phala-cloud/phala-cloud-cli/api.mdx
index bd81200..7ab496b 100644
--- a/phala-cloud/phala-cloud-cli/api.mdx
+++ b/phala-cloud/phala-cloud-cli/api.mdx
@@ -45,8 +45,10 @@ Make an authenticated HTTP request to Phala Cloud API.
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `--interactive` | Enable interactive mode for commands that support it |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Examples
diff --git a/phala-cloud/phala-cloud-cli/apps.mdx b/phala-cloud/phala-cloud-cli/apps.mdx
index 7cfaeeb..97e918a 100644
--- a/phala-cloud/phala-cloud-cli/apps.mdx
+++ b/phala-cloud/phala-cloud-cli/apps.mdx
@@ -40,8 +40,11 @@ List dstack apps
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `--interactive` | Enable interactive mode for commands that support it |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
+| `--profile PROFILE` | Temporarily use a different auth profile for this command |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Examples
diff --git a/phala-cloud/phala-cloud-cli/auth.mdx b/phala-cloud/phala-cloud-cli/auth.mdx
index 3afb920..c0f6dcb 100644
--- a/phala-cloud/phala-cloud-cli/auth.mdx
+++ b/phala-cloud/phala-cloud-cli/auth.mdx
@@ -25,8 +25,11 @@ Authenticate with Phala Cloud
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `--interactive` | Enable interactive mode for commands that support it |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
+| `--profile PROFILE` | Temporarily use a different auth profile for this command |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Deprecated Subcommands
diff --git a/phala-cloud/phala-cloud-cli/changelog.mdx b/phala-cloud/phala-cloud-cli/changelog.mdx
index 51ca932..9df21a9 100644
--- a/phala-cloud/phala-cloud-cli/changelog.mdx
+++ b/phala-cloud/phala-cloud-cli/changelog.mdx
@@ -3,6 +3,41 @@ title: Changelog
description: Release history for the Phala Cloud CLI
---
+## [1.1.15](https://github.com/Phala-Network/phala-cloud/compare/cli-v1.1.14...cli-v1.1.15) (2026-04-10)
+
+### feat
+
+* **cli:** add profiles command aliases (list, rm, mv) ([b46dbc7](https://github.com/Phala-Network/phala-cloud/commit/b46dbc7c46ef30e5829edaee1e72ddaec9041b2b))
+* **cli:** add profiles use, rename, delete subcommands ([3a7ab22](https://github.com/Phala-Network/phala-cloud/commit/3a7ab22e5635aa4906cf0e22d41d07141ffdea86))
+* **cli:** add renameProfile utility to credentials ([1e07a24](https://github.com/Phala-Network/phala-cloud/commit/1e07a24f847d86b7c5c08050f4d91a7bc447e5fe))
+* **cli:** unify global option handling ([8dc77c1](https://github.com/Phala-Network/phala-cloud/commit/8dc77c19c5ddd241e4baeb568981563f670b857f))
+
+### fix
+
+* **cli:** add two-phase replicate flow with on-chain approval ([a77a011](https://github.com/Phala-Network/phala-cloud/commit/a77a011e47e34129c3a5b17c9adcd7f60ee3bec8))
+* **cli:** improve replicate output formatting and error display ([87cd7c8](https://github.com/Phala-Network/phala-cloud/commit/87cd7c8bff9c963219da4f03201f26ca2b57e966))
+* **cli:** resolve app_id ambiguity with --compose-hash in replicate ([15ff65d](https://github.com/Phala-Network/phala-cloud/commit/15ff65d20a86702c519df63df4b86989832152f1))
+* **cli:** show full values in on-chain registration error ([600cb66](https://github.com/Phala-Network/phala-cloud/commit/600cb66b9c8732a9e89fe296fc01d117a6d3f012))
+* **cli:** show on-chain status before requiring private key in replicate ([986f1ac](https://github.com/Phala-Network/phala-cloud/commit/986f1acd6b925e452a0608c8fc91717822363d0f))
+* **cli:** strip app_ prefix when matching cvm list by app_id ([fcfed6e](https://github.com/Phala-Network/phala-cloud/commit/fcfed6e7dab1a609ff09e52c2a1448ea1c3f4fc4))
+* **cli:** support universal cvm ids for replicate ([5fa84ba](https://github.com/Phala-Network/phala-cloud/commit/5fa84ba486cb3329e6b222bbc8a4edf03e1a41a3))
+* **cli:** use app CVMs endpoint for compose-hash disambiguation ([2f2266a](https://github.com/Phala-Network/phala-cloud/commit/2f2266a7ab0ffb93c94d458298ec5082111e402c))
+* **cli:** use correct pubkey source for CVM replicate env encryption ([2b3b98b](https://github.com/Phala-Network/phala-cloud/commit/2b3b98b9fdcd369f94887a05cea1827978cddeb1))
+* **cli:** use instance-level cvm replication ([fd0cb03](https://github.com/Phala-Network/phala-cloud/commit/fd0cb0352c35987606d1efe901fb6a6b305b6319))
+* **cli:** use plaintext output for cvm replicate ([1a9ba68](https://github.com/Phala-Network/phala-cloud/commit/1a9ba688895a3334cae4a69defc3f4d429062841))
+* **cli:** use sdk private key flow in allow-devices ([d697761](https://github.com/Phala-Network/phala-cloud/commit/d697761a30a7b19cbbefff61ed0b74d82ae94ae4))
+* **sdk:** chain resolution, owner pre-check, and ABI error definitions ([0c6a50c](https://github.com/Phala-Network/phala-cloud/commit/0c6a50c7fc57370ecb61359f34f04210c645ca18))
+
+### refactor
+
+* add `phala help ` subcommand with bundled topics ([8880334](https://github.com/Phala-Network/phala-cloud/commit/88803342ad4a5506642d3d517be55361659b1b0e))
+* add RPC timeout + retry hint for allow-devices ([7a20577](https://github.com/Phala-Network/phala-cloud/commit/7a20577545e3da42bd37017574db962f5fc953f6))
+* add transaction progress logging and RPC transport timeouts ([29849a2](https://github.com/Phala-Network/phala-cloud/commit/29849a283025fe74543254e75afae888ab29f848))
+* allow direct app identifier in allow-devices to avoid CVM ambiguity ([a440bed](https://github.com/Phala-Network/phala-cloud/commit/a440bedb6334c87ed3b2bf82a6813421ee5204a4))
+* log RPC URL before blockchain operations ([c9956b2](https://github.com/Phala-Network/phala-cloud/commit/c9956b22b0fe1c7a66b13f9b7bc04051600fb1f4))
+* skip device list in allow-devices ls when allowAnyDevice is on ([597bbb4](https://github.com/Phala-Network/phala-cloud/commit/597bbb4be1e69d1832093ee167bf69eda801e3e2))
+* unify --private-key / --rpc-url and add ETH_RPC_URL fallback ([162ff8a](https://github.com/Phala-Network/phala-cloud/commit/162ff8a0451ca799061534d3eb9711a85aebd1af))
+
## [1.1.14](https://github.com/Phala-Network/phala-cloud/compare/cli-v1.1.13...cli-v1.1.14) (2026-03-27)
### feat
diff --git a/phala-cloud/phala-cloud-cli/config.mdx b/phala-cloud/phala-cloud-cli/config.mdx
index 000029b..bb357b2 100644
--- a/phala-cloud/phala-cloud-cli/config.mdx
+++ b/phala-cloud/phala-cloud-cli/config.mdx
@@ -25,8 +25,11 @@ Manage local CLI state
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `--interactive` | Enable interactive mode for commands that support it |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
+| `--profile PROFILE` | Temporarily use a different auth profile for this command |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Subcommands
@@ -34,7 +37,7 @@ Manage local CLI state
| Command | Description |
| ------- | ----------- |
| `get` | Get a configuration value |
-| `list` | (ls) List config values |
+| `list` | List config values |
| `set` | Set a configuration value |
### Examples
diff --git a/phala-cloud/phala-cloud-cli/cp.mdx b/phala-cloud/phala-cloud-cli/cp.mdx
index 89a7b08..a4a8ab1 100644
--- a/phala-cloud/phala-cloud-cli/cp.mdx
+++ b/phala-cloud/phala-cloud-cli/cp.mdx
@@ -43,8 +43,11 @@ Copy files to/from a CVM via SCP
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `--version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `--interactive` | Enable interactive mode for commands that support it |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
+| `--profile PROFILE` | Temporarily use a different auth profile for this command |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Examples
diff --git a/phala-cloud/phala-cloud-cli/cvms.mdx b/phala-cloud/phala-cloud-cli/cvms.mdx
index c26cce4..d31e9c3 100644
--- a/phala-cloud/phala-cloud-cli/cvms.mdx
+++ b/phala-cloud/phala-cloud-cli/cvms.mdx
@@ -25,8 +25,11 @@ Manage CVMs
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `--interactive` | Enable interactive mode for commands that support it |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
+| `--profile PROFILE` | Temporarily use a different auth profile for this command |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Subcommands
@@ -37,7 +40,7 @@ Manage CVMs
| `delete` | Delete a CVM |
| `device-allowlist` | Show device allowlist status for a CVM's app |
| `get` | Get details of a CVM |
-| `list` | (ls) List CVMs |
+| `list` | List CVMs |
| `list-nodes` | List worker nodes |
| `replicate` | Create a replica of an existing CVM |
| `resize` | Resize resources for a CVM |
diff --git a/phala-cloud/phala-cloud-cli/deploy.mdx b/phala-cloud/phala-cloud-cli/deploy.mdx
index 6ec5ae4..fd17d01 100644
--- a/phala-cloud/phala-cloud-cli/deploy.mdx
+++ b/phala-cloud/phala-cloud-cli/deploy.mdx
@@ -40,11 +40,11 @@ For on-chain KMS (Ethereum/Base) with multisig contract owners. See [Multisig Up
| Option | Description |
| ------ | ----------- |
-| `--prepare-only` | Prepare update without on-chain operations. Outputs compose hash and commit token for multisig approval. |
-| `--commit` | Commit a previously prepared update using a commit token. |
-| `--token ` | Commit token from `--prepare-only` output |
-| `--compose-hash ` | Compose hash (optional, auto-read from token) |
-| `--transaction-hash ` | Transaction hash proving on-chain registration (optional, defaults to state-only check) |
+| `--prepare-only` | Prepare the update and generate a commit token. Skips all on-chain operations. Intended for multisig workflows. |
+| `--commit` | Commit a previously prepared update using a commit token. Requires `--token`; `--compose-hash` and `--transaction-hash` are read from the token when omitted. |
+| `--token ` | Commit token from a prepare-only update. |
+| `--compose-hash ` | Compose hash from a prepare-only update. Optional when the token can provide it. |
+| `--transaction-hash ` | Transaction hash proving on-chain compose hash registration. Pass `already-registered` to skip the proof and rely on state-only verification. |
### Advanced Options
@@ -58,8 +58,8 @@ For on-chain KMS (Ethereum/Base) with multisig contract owners. See [Multisig Up
| `--custom-app-id ` | Custom App ID (requires --nonce for PHALA KMS) |
| `--nonce ` | Nonce for deterministic app_id (requires --custom-app-id, PHALA KMS only) |
| `--pre-launch-script ` | Path to pre-launch script |
-| `--private-key ` | Private key for signing transactions. |
-| `--rpc-url ` | RPC URL for the blockchain. |
+| `--private-key ` | Private key for signing on-chain transactions (or set `PRIVATE_KEY` env var) |
+| `--rpc-url ` | RPC URL for on-chain KMS transactions (or set `ETH_RPC_URL` env var) |
### Deprecated Options
@@ -77,7 +77,7 @@ For on-chain KMS (Ethereum/Base) with multisig contract owners. See [Multisig Up
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
@@ -171,7 +171,7 @@ When the on-chain KMS contract owner is a multisig wallet, the CLI cannot comple
phala deploy --cvm-id app_abc123 --prepare-only -c docker-compose.yml -e .env
```
-This outputs the compose hash, on-chain status, and a commit token. The token is valid for 7 days.
+This outputs the compose hash, on-chain status, and a commit token. The token is valid for 14 days.
* **Phase 2**: After multisig approval and on-chain registration, commit the update
diff --git a/phala-cloud/phala-cloud-cli/docker.mdx b/phala-cloud/phala-cloud-cli/docker.mdx
index 52681bc..6eebd42 100644
--- a/phala-cloud/phala-cloud-cli/docker.mdx
+++ b/phala-cloud/phala-cloud-cli/docker.mdx
@@ -25,8 +25,11 @@ Docker Hub login and image management
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `--interactive` | Enable interactive mode for commands that support it |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
+| `--profile PROFILE` | Temporarily use a different auth profile for this command |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Subcommands
diff --git a/phala-cloud/phala-cloud-cli/envs.mdx b/phala-cloud/phala-cloud-cli/envs.mdx
index d6e80c4..f923b09 100644
--- a/phala-cloud/phala-cloud-cli/envs.mdx
+++ b/phala-cloud/phala-cloud-cli/envs.mdx
@@ -21,8 +21,11 @@ Encrypt and update CVM sealed environment variables
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `--interactive` | Enable interactive mode for commands that support it |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
+| `--profile PROFILE` | Temporarily use a different auth profile for this command |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Subcommands
diff --git a/phala-cloud/phala-cloud-cli/instance-types.mdx b/phala-cloud/phala-cloud-cli/instance-types.mdx
index e09328b..45e284a 100644
--- a/phala-cloud/phala-cloud-cli/instance-types.mdx
+++ b/phala-cloud/phala-cloud-cli/instance-types.mdx
@@ -31,8 +31,11 @@ List available instance types
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `--interactive` | Enable interactive mode for commands that support it |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
+| `--profile PROFILE` | Temporarily use a different auth profile for this command |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Examples
diff --git a/phala-cloud/phala-cloud-cli/kms.mdx b/phala-cloud/phala-cloud-cli/kms.mdx
index 28b5151..b917edc 100644
--- a/phala-cloud/phala-cloud-cli/kms.mdx
+++ b/phala-cloud/phala-cloud-cli/kms.mdx
@@ -25,15 +25,18 @@ Manage on-chain KMS contracts
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `--interactive` | Enable interactive mode for commands that support it |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
+| `--profile PROFILE` | Temporarily use a different auth profile for this command |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Subcommands
| Command | Description |
| ------- | ----------- |
-| `list` | (ls) List on-chain KMS contracts |
+| `list` | List on-chain KMS contracts |
| `ethereum` | Show on-chain KMS details for ethereum |
| `base` | Show on-chain KMS details for base |
diff --git a/phala-cloud/phala-cloud-cli/link.mdx b/phala-cloud/phala-cloud-cli/link.mdx
index 2a1a67d..2418a7a 100644
--- a/phala-cloud/phala-cloud-cli/link.mdx
+++ b/phala-cloud/phala-cloud-cli/link.mdx
@@ -31,8 +31,11 @@ Link a local directory to a CVM
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `--interactive` | Enable interactive mode for commands that support it |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
+| `--profile PROFILE` | Temporarily use a different auth profile for this command |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Examples
diff --git a/phala-cloud/phala-cloud-cli/login.mdx b/phala-cloud/phala-cloud-cli/login.mdx
index 53ecabc..452af5a 100644
--- a/phala-cloud/phala-cloud-cli/login.mdx
+++ b/phala-cloud/phala-cloud-cli/login.mdx
@@ -37,8 +37,10 @@ Authenticate with Phala Cloud
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `--interactive` | Enable interactive mode for commands that support it |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Examples
diff --git a/phala-cloud/phala-cloud-cli/logout.mdx b/phala-cloud/phala-cloud-cli/logout.mdx
index 651c3e4..f6c73f5 100644
--- a/phala-cloud/phala-cloud-cli/logout.mdx
+++ b/phala-cloud/phala-cloud-cli/logout.mdx
@@ -21,8 +21,11 @@ Remove stored API key
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `--interactive` | Enable interactive mode for commands that support it |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
+| `--profile PROFILE` | Temporarily use a different auth profile for this command |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Examples
diff --git a/phala-cloud/phala-cloud-cli/logs.mdx b/phala-cloud/phala-cloud-cli/logs.mdx
index ad2ece0..ce4076f 100644
--- a/phala-cloud/phala-cloud-cli/logs.mdx
+++ b/phala-cloud/phala-cloud-cli/logs.mdx
@@ -38,8 +38,6 @@ Fetch logs from a CVM
| `-t, --timestamps` | Show timestamps |
| `--since SINCE` | Start time (RFC3339 or relative, e.g. 42m) |
| `--until UNTIL` | End time (RFC3339 or relative) |
-| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
-| `-i, --interactive` | Enable interactive mode |
### Global Options
@@ -47,8 +45,11 @@ Fetch logs from a CVM
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `-i, --interactive` | Enable interactive mode |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
+| `--profile PROFILE` | Temporarily use a different auth profile for this command |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Examples
diff --git a/phala-cloud/phala-cloud-cli/nodes.mdx b/phala-cloud/phala-cloud-cli/nodes.mdx
index 51da9a6..ebdc437 100644
--- a/phala-cloud/phala-cloud-cli/nodes.mdx
+++ b/phala-cloud/phala-cloud-cli/nodes.mdx
@@ -25,15 +25,18 @@ Manage TEE nodes
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `--interactive` | Enable interactive mode for commands that support it |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
+| `--profile PROFILE` | Temporarily use a different auth profile for this command |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Subcommands
| Command | Description |
| ------- | ----------- |
-| `list` | (ls) List workspace nodes |
+| `list` | List workspace nodes |
### Examples
diff --git a/phala-cloud/phala-cloud-cli/os-images.mdx b/phala-cloud/phala-cloud-cli/os-images.mdx
index 2c2a4af..c1ad220 100644
--- a/phala-cloud/phala-cloud-cli/os-images.mdx
+++ b/phala-cloud/phala-cloud-cli/os-images.mdx
@@ -35,8 +35,11 @@ List available OS images
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `--interactive` | Enable interactive mode for commands that support it |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
+| `--profile PROFILE` | Temporarily use a different auth profile for this command |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Examples
diff --git a/phala-cloud/phala-cloud-cli/profiles.mdx b/phala-cloud/phala-cloud-cli/profiles.mdx
index 70454a0..a02f588 100644
--- a/phala-cloud/phala-cloud-cli/profiles.mdx
+++ b/phala-cloud/phala-cloud-cli/profiles.mdx
@@ -21,10 +21,21 @@ List auth profiles
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `--interactive` | Enable interactive mode for commands that support it |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
+| `--profile PROFILE` | Temporarily use a different auth profile for this command |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
+### Subcommands
+
+| Command | Description |
+| ------- | ----------- |
+| `use` | Switch to an auth profile |
+| `rename` | Rename an auth profile |
+| `delete` | Delete an auth profile |
+
### Examples
* Display help:
diff --git a/phala-cloud/phala-cloud-cli/ps.mdx b/phala-cloud/phala-cloud-cli/ps.mdx
index e9f1421..157a8ba 100644
--- a/phala-cloud/phala-cloud-cli/ps.mdx
+++ b/phala-cloud/phala-cloud-cli/ps.mdx
@@ -21,21 +21,17 @@ List containers of a CVM
| -------- | ----------- |
| `?` | CVM identifier (UUID, app_id, instance_id, or name) |
-### Options
-
-| Option | Description |
-| ------ | ----------- |
-| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
-| `-i, --interactive` | Enable interactive mode |
-
### Global Options
| Option | Description |
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `-i, --interactive` | Enable interactive mode |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
+| `--profile PROFILE` | Temporarily use a different auth profile for this command |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Examples
diff --git a/phala-cloud/phala-cloud-cli/runtime-config.mdx b/phala-cloud/phala-cloud-cli/runtime-config.mdx
index 025fbff..be35187 100644
--- a/phala-cloud/phala-cloud-cli/runtime-config.mdx
+++ b/phala-cloud/phala-cloud-cli/runtime-config.mdx
@@ -21,20 +21,17 @@ Show the runtime configuration of a CVM
| -------- | ----------- |
| `?` | CVM identifier (UUID, app_id, instance_id, or name) |
-### Options
-
-| Option | Description |
-| ------ | ----------- |
-| `-i, --interactive` | Enable interactive mode |
-
### Global Options
| Option | Description |
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `-i, --interactive` | Enable interactive mode |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
+| `--profile PROFILE` | Temporarily use a different auth profile for this command |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Examples
diff --git a/phala-cloud/phala-cloud-cli/self.mdx b/phala-cloud/phala-cloud-cli/self.mdx
index 9a03686..40ab137 100644
--- a/phala-cloud/phala-cloud-cli/self.mdx
+++ b/phala-cloud/phala-cloud-cli/self.mdx
@@ -25,8 +25,11 @@ CLI self-management
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `--interactive` | Enable interactive mode for commands that support it |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
+| `--profile PROFILE` | Temporarily use a different auth profile for this command |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Subcommands
diff --git a/phala-cloud/phala-cloud-cli/simulator.mdx b/phala-cloud/phala-cloud-cli/simulator.mdx
index d3774a6..2f8f369 100644
--- a/phala-cloud/phala-cloud-cli/simulator.mdx
+++ b/phala-cloud/phala-cloud-cli/simulator.mdx
@@ -25,8 +25,11 @@ TEE simulator commands
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `--interactive` | Enable interactive mode for commands that support it |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
+| `--profile PROFILE` | Temporarily use a different auth profile for this command |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Subcommands
diff --git a/phala-cloud/phala-cloud-cli/ssh-keys.mdx b/phala-cloud/phala-cloud-cli/ssh-keys.mdx
index 8da1d40..b710190 100644
--- a/phala-cloud/phala-cloud-cli/ssh-keys.mdx
+++ b/phala-cloud/phala-cloud-cli/ssh-keys.mdx
@@ -21,17 +21,20 @@ Manage SSH keys
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `--interactive` | Enable interactive mode for commands that support it |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
+| `--profile PROFILE` | Temporarily use a different auth profile for this command |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Subcommands
| Command | Description |
| ------- | ----------- |
-| `list` | (ls) List SSH keys for the current user |
+| `list` | List SSH keys for the current user |
| `add` | Add a local SSH public key to your account |
-| `remove` | (rm) Remove an SSH key from your account |
+| `remove` | Remove an SSH key from your account |
| `import-github` | Import SSH keys from a GitHub user's public profile |
### Examples
diff --git a/phala-cloud/phala-cloud-cli/ssh.mdx b/phala-cloud/phala-cloud-cli/ssh.mdx
index 5ac8e2b..678d3be 100644
--- a/phala-cloud/phala-cloud-cli/ssh.mdx
+++ b/phala-cloud/phala-cloud-cli/ssh.mdx
@@ -29,7 +29,6 @@ Connect to a CVM via SSH
| Option | Description |
| ------ | ----------- |
-| `-i, --interactive` | Enable interactive mode |
| `-p, --port ` | Gateway port (priority: CLI > phala.toml > 443) |
| `-g, --gateway ` | Gateway domain (priority: CLI > phala.toml > API) |
| `-t, --timeout ` | Connection timeout in seconds (default: 30) |
@@ -42,8 +41,11 @@ Connect to a CVM via SSH
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `--version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `-i, --interactive` | Enable interactive mode |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
+| `--profile PROFILE` | Temporarily use a different auth profile for this command |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Pass-through Arguments
diff --git a/phala-cloud/phala-cloud-cli/status.mdx b/phala-cloud/phala-cloud-cli/status.mdx
index c7984c1..8425176 100644
--- a/phala-cloud/phala-cloud-cli/status.mdx
+++ b/phala-cloud/phala-cloud-cli/status.mdx
@@ -27,8 +27,11 @@ Check authentication status
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `--interactive` | Enable interactive mode for commands that support it |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
+| `--profile PROFILE` | Temporarily use a different auth profile for this command |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Examples
diff --git a/phala-cloud/phala-cloud-cli/switch.mdx b/phala-cloud/phala-cloud-cli/switch.mdx
index 2e991ee..5d8997e 100644
--- a/phala-cloud/phala-cloud-cli/switch.mdx
+++ b/phala-cloud/phala-cloud-cli/switch.mdx
@@ -25,20 +25,17 @@ Switch auth profiles
| -------- | ----------- |
| `` | Profile name |
-### Options
-
-| Option | Description |
-| ------ | ----------- |
-| `-i, --interactive` | Select profile interactively |
-
### Global Options
| Option | Description |
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `-i, --interactive` | Select profile interactively |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
+| `--profile PROFILE` | Temporarily use a different auth profile for this command |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Examples
diff --git a/phala-cloud/phala-cloud-cli/whoami.mdx b/phala-cloud/phala-cloud-cli/whoami.mdx
index ac0ff4a..7a1dcb2 100644
--- a/phala-cloud/phala-cloud-cli/whoami.mdx
+++ b/phala-cloud/phala-cloud-cli/whoami.mdx
@@ -21,8 +21,11 @@ Print the current user
| ------ | ----------- |
| `-h, --help` | Show help information for the current command |
| `-v, --version` | Show CLI version |
-| `--api-token TOKEN, --api-key TOKEN` | API token used for authentication |
+| `--api-token TOKEN, --api-key TOKEN` | API token for authenticating with Phala Cloud |
| `-j, --json, --no-json` | Output in JSON format |
+| `--interactive` | Enable interactive mode for commands that support it |
+| `--cvm-id ` | CVM identifier (UUID, app_id, instance_id, or name) |
+| `--profile PROFILE` | Temporarily use a different auth profile for this command |
| `--api-version ` | API version to use (e.g. 2025-10-28, 2026-01-21) |
### Examples
diff --git a/phala-cloud/references/cloud-js-sdk/add-compose-hash.mdx b/phala-cloud/references/cloud-js-sdk/add-compose-hash.mdx
index 0d29564..fee5826 100644
--- a/phala-cloud/references/cloud-js-sdk/add-compose-hash.mdx
+++ b/phala-cloud/references/cloud-js-sdk/add-compose-hash.mdx
@@ -9,7 +9,14 @@ Registers a new compose hash on the AppAuth smart contract. This is required whe
This function interacts directly with the blockchain (not the Phala Cloud API).
-**When to use:** Call this function when [`updateDockerCompose`](/phala-cloud/references/cloud-js-sdk/update-docker-compose), [`updateCvmEnvs`](/phala-cloud/references/cloud-js-sdk/update-cvm-envs), or [`updatePreLaunchScript`](/phala-cloud/references/cloud-js-sdk/cvm-configuration) returns `{ status: "precondition_required" }`.
+**When to use.** Call this function in either of these cases:
+
+- [`updateDockerCompose`](/phala-cloud/references/cloud-js-sdk/update-docker-compose), [`updateCvmEnvs`](/phala-cloud/references/cloud-js-sdk/update-cvm-envs), or [`updatePreLaunchScript`](/phala-cloud/references/cloud-js-sdk/cvm-configuration) returns `{ status: "precondition_required" }`.
+- [`provisionCvmComposeFileUpdate`](/phala-cloud/references/cloud-js-sdk/cvm-configuration#provisioncvmcomposefileupdate) returns `compose_hash_registered: false`, and you need to register the hash before calling `commitCvmComposeFileUpdate`.
+
+
+**Contract owner pre-check.** Before submitting the transaction, the function reads `owner()` from the AppAuth contract and throws if the sender address does not match. This fails fast so you don't waste gas on a transaction that would revert on-chain. Sign with the contract owner's private key, or pass a wallet client whose account is the contract owner.
+
**Parameters:**
diff --git a/phala-cloud/references/cloud-js-sdk/changelog.mdx b/phala-cloud/references/cloud-js-sdk/changelog.mdx
index 5bb3855..0b0e34e 100644
--- a/phala-cloud/references/cloud-js-sdk/changelog.mdx
+++ b/phala-cloud/references/cloud-js-sdk/changelog.mdx
@@ -3,6 +3,23 @@ title: Changelog
description: Release history for the @phala/cloud JavaScript SDK
---
+## [0.2.7](https://github.com/Phala-Network/phala-cloud/compare/js-v0.2.6...js-v0.2.7) (2026-04-10)
+
+### feat
+
+* add compose_hash_registered to provision response schema ([be02cf9](https://github.com/Phala-Network/phala-cloud/commit/be02cf903d1219c63693d2e355ea86114bdf4841))
+
+### fix
+
+* **js:** update addComposeHash test mock for owner pre-check ([456ce00](https://github.com/Phala-Network/phala-cloud/commit/456ce0022f23e315e6e0b06341026fa1ec1a73c0))
+* parse StructuredError details array in 465 error handlers ([50129dc](https://github.com/Phala-Network/phala-cloud/commit/50129dcc87803e979fde0d624f3187e0547a5aca))
+* preserve StructuredError response body in error conversion ([ae7eecc](https://github.com/Phala-Network/phala-cloud/commit/ae7eecc487ea1c6f0892b383e2a22e41e0e6593d))
+* **sdk:** chain resolution, owner pre-check, and ABI error definitions ([0c6a50c](https://github.com/Phala-Network/phala-cloud/commit/0c6a50c7fc57370ecb61359f34f04210c645ca18))
+
+### refactor
+
+* add transaction progress logging and RPC transport timeouts ([29849a2](https://github.com/Phala-Network/phala-cloud/commit/29849a283025fe74543254e75afae888ab29f848))
+
## [0.2.6](https://github.com/Phala-Network/phala-cloud/compare/js-v0.2.5...js-v0.2.6) (2026-03-27)
### feat
diff --git a/phala-cloud/references/cloud-js-sdk/cvm-configuration.mdx b/phala-cloud/references/cloud-js-sdk/cvm-configuration.mdx
index 4791759..5345966 100644
--- a/phala-cloud/references/cloud-js-sdk/cvm-configuration.mdx
+++ b/phala-cloud/references/cloud-js-sdk/cvm-configuration.mdx
@@ -171,11 +171,21 @@ Provisions a compose file update — the first phase of a two-phase compose file
| `id` | `string` | Yes | CVM identifier (also accepts `uuid`) |
| `app_compose` | `object` | Yes | Updated compose file object |
-**Returns:** `ProvisionCvmComposeFileUpdateResult` with `compose_hash`.
+**Returns:** `ProvisionCvmComposeFileUpdateResult`
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `compose_hash` | `string` | Hash of the provisioned compose file. Pass it to `commitCvmComposeFileUpdate`. |
+| `compose_hash_registered` | `boolean` | On-chain KMS only: `true` if the hash is already registered on the AppAuth contract. When `true`, skip [`addComposeHash`](/phala-cloud/references/cloud-js-sdk/add-compose-hash) and call `commitCvmComposeFileUpdate` directly. |
+| `app_id` | `string \| null` | App identifier of the target CVM. |
+| `device_id` | `string \| null` | Device identifier of the target CVM. |
+| `kms_info` | `object \| null` | KMS metadata for the target CVM. Present for on-chain KMS CVMs. |
**Example:**
```typescript
+import { addComposeHash } from "@phala/cloud";
+
const compose = await client.getCvmComposeFile({ id: "my-app" });
compose.docker_compose_file = newYaml;
@@ -183,7 +193,23 @@ const provision = await client.provisionCvmComposeFileUpdate({
uuid: "my-app",
app_compose: compose,
});
-console.log(provision.compose_hash);
+
+if (provision.kms_info && !provision.compose_hash_registered) {
+ // On-chain KMS: register the new hash before commit.
+ // See addComposeHash docs for the full parameter set.
+ await addComposeHash({
+ chain: provision.kms_info.chain,
+ kmsContractAddress: provision.kms_info.kms_contract_address,
+ appId: provision.app_id as `0x${string}`,
+ composeHash: provision.compose_hash,
+ privateKey: process.env.ETH_PRIVATE_KEY as `0x${string}`,
+ });
+}
+
+await client.commitCvmComposeFileUpdate({
+ id: "my-app",
+ compose_hash: provision.compose_hash,
+});
```
---
diff --git a/phala-cloud/references/cloud-js-sdk/error-handling.mdx b/phala-cloud/references/cloud-js-sdk/error-handling.mdx
index 838812c..7618e32 100644
--- a/phala-cloud/references/cloud-js-sdk/error-handling.mdx
+++ b/phala-cloud/references/cloud-js-sdk/error-handling.mdx
@@ -248,7 +248,7 @@ if (error instanceof ResourceError) {
console.error(formatStructuredError(error));
}
// Output:
-// Error [ERR-01-001]: The requested instance type does not exist
+// Error [ERR-02-001]: The requested instance type does not exist
//
// Details:
// - Instance type 'invalid' is not recognized
diff --git a/phala-cloud/references/cloud-js-sdk/on-chain-kms-guide.mdx b/phala-cloud/references/cloud-js-sdk/on-chain-kms-guide.mdx
deleted file mode 100644
index 6c5d691..0000000
--- a/phala-cloud/references/cloud-js-sdk/on-chain-kms-guide.mdx
+++ /dev/null
@@ -1,248 +0,0 @@
----
-title: On-Chain KMS Guide
-description: Deploy and update CVMs with on-chain KMS (Ethereum/Base) using the CLI and SDK
----
-
-On-chain KMS uses a smart contract on Ethereum or Base as the key management layer. Only the contract owner can authorize compose file changes — enabling multisig, timelock, or DAO governance.
-
-For a conceptual overview, see [Cloud vs On-Chain KMS](/phala-cloud/key-management/cloud-vs-onchain-kms).
-
-## PHALA vs On-Chain KMS
-
-| Feature | PHALA KMS | On-Chain KMS |
-|---------|-----------|--------------|
-| Key management | Centralized Phala service | Smart contract on-chain |
-| Compose updates | Direct API call | Two-phase: register on-chain, then API |
-| Cost | Free | Gas fees |
-| Chain | None | Ethereum or Base |
-
-## Deploy with CLI
-
-The CLI handles the full on-chain flow automatically when you provide a private key:
-
-```bash
-phala deploy \
- -c docker-compose.yml \
- -e .env \
- --kms base \
- --private-key \
- --rpc-url
-```
-
-This provisions the CVM, deploys the DstackApp contract, registers the compose hash and device on-chain, and starts the CVM.
-
-## Update with CLI
-
-### Single Owner
-
-If you hold the contract owner's private key:
-
-```bash
-phala deploy --cvm-id app_abc123 \
- -c docker-compose.yml \
- --private-key \
- --rpc-url
-```
-
-The CLI detects the compose hash change, registers it on-chain, and completes the update.
-
-### Multisig Owner (Two-Phase)
-
-When the contract owner is a multisig wallet (e.g., Gnosis Safe), the on-chain transaction requires multiple signatures. The CLI supports a prepare → approve → commit flow:
-
-**Phase 1 — Prepare:**
-
-```bash
-phala deploy --cvm-id app_abc123 \
- --prepare-only \
- -c docker-compose.yml \
- -e .env
-```
-
-Output:
-```
-Compose Hash: 0xff22c67f...
-App ID: 09cef33ca9...
-Chain: Base (ID: 8453)
-Contract: https://basescan.org/address/0x09cef33c...
-Commit Token: a5732e36-...
-Commit URL: https://cloud.phala.com/my-team/cvms/.../confirm-update?token=...
-API Commit URL: https://cloud-api.phala.com/api/v1/cvms/.../commit-update?token=... (POST)
-
-On-chain Status:
- Compose Hash: NOT registered
- Device ID: registered
-```
-
-**Multisig Approval:**
-
-Use the compose hash to propose `addComposeHash(bytes32)` on the DstackApp contract through your multisig wallet.
-
-**Phase 2 — Commit:**
-
-```bash
-phala deploy --cvm-id app_abc123 \
- --commit \
- --token
-```
-
-Or commit via the API directly:
-
-```bash
-curl -X POST 'https://cloud-api.phala.com/api/v1/cvms//commit-update?token='
-```
-
-Or use the **Confirm Update** page in the dashboard (link in `--prepare-only` output).
-
-
- The commit token is valid for 7 days. A new `--prepare-only` request invalidates any previous token for the same CVM.
-
-
-### Webhook Notifications
-
-Configure [webhooks](/phala-cloud/references/webhooks) to receive `cvm.update.pending_approval` events when a prepare-only update is created. The payload includes the compose hash, commit token, and commit URL for CI/CD or notification integration.
-
-## Deploy with SDK
-
-For programmatic deployments, use the `@phala/cloud` SDK:
-
-### Step 1: Provision
-
-```typescript
-import { createClient } from "@phala/cloud";
-
-const client = createClient();
-
-const provision = await client.provisionCvm({
- name: "my-app",
- compose_file: {
- docker_compose_file: composeYaml,
- allowed_envs: ["API_KEY"],
- },
- kms: "BASE",
-});
-```
-
-### Step 2: Deploy Contract
-
-```typescript
-import { deployAppAuth } from "@phala/cloud";
-
-const deployed = await deployAppAuth({
- chain: provision.kms_info.chain,
- rpcUrl: "https://...",
- kmsContractAddress: provision.kms_info.kms_contract_address,
- privateKey: "0x...",
- deviceId: provision.device_id,
- composeHash: provision.compose_hash,
-});
-```
-
-### Step 3: Commit
-
-```typescript
-import { encryptEnvVars, parseEnvVars } from "@phala/cloud";
-
-const { public_key } = await client.getAppEnvEncryptPubKey({
- kms: provision.kms_info.slug,
- app_id: deployed.appId,
-});
-
-const encrypted = await encryptEnvVars(parseEnvVars("API_KEY=secret"), public_key);
-
-await client.commitCvmProvision({
- app_id: deployed.appId,
- compose_hash: provision.compose_hash,
- encrypted_env: encrypted,
- kms_id: provision.kms_info.slug,
- contract_address: deployed.appAuthAddress,
- deployer_address: deployed.deployer,
-});
-```
-
-## Update with SDK
-
-### Two-Phase Update
-
-```typescript
-import { patchCvm, confirmCvmPatch, addComposeHash } from "@phala/cloud";
-
-// Phase 1
-const result = await patchCvm(client, {
- id: "my-app",
- docker_compose_file: newYaml,
-});
-
-if (result.requiresOnChainHash) {
- // Register on-chain
- const tx = await addComposeHash({
- chain: result.kmsInfo.chain,
- appId: result.appId as `0x${string}`,
- composeHash: result.composeHash,
- privateKey: "0x...",
- });
-
- // Phase 2
- await confirmCvmPatch(client, {
- id: "my-app",
- composeHash: result.composeHash,
- transactionHash: tx.transactionHash,
- });
-}
-```
-
-### Prepare-Only (for Multisig)
-
-```typescript
-const result = await patchCvm(client, {
- id: "my-app",
- docker_compose_file: newYaml,
- prepareOnly: true,
-});
-
-if (result.requiresOnChainHash) {
- console.log("Compose hash:", result.composeHash);
- console.log("Commit token:", result.commitToken);
- console.log("On-chain status:", result.onchainStatus);
- // → Hand off to multisig for approval
-}
-```
-
-## Device Management
-
-On-chain KMS uses a device allowlist in the smart contract. The CLI handles device registration automatically. For programmatic control:
-
-```typescript
-import { addDevice, removeDevice, setAllowAnyDevice, checkDeviceAllowed } from "@phala/cloud";
-
-// Add a device
-await addDevice({ chain: base, appAddress: "0x...", deviceId: "0x...", privateKey: "0x..." });
-
-// Check if allowed
-const allowed = await checkDeviceAllowed({ chain: base, appAddress: "0x...", deviceId: "0x..." });
-
-// Allow any device (bypass allowlist)
-await setAllowAnyDevice({ chain: base, appAddress: "0x...", allow: true, privateKey: "0x..." });
-```
-
-### checkOnChainPrerequisites
-
-Batch-check device and compose hash registration in a single RPC call:
-
-```typescript
-import { checkOnChainPrerequisites } from "@phala/cloud";
-
-const prereqs = await checkOnChainPrerequisites({
- chain: base,
- appAddress: "0x...",
- deviceId: "0x...",
- composeHash: "0x...",
-});
-// prereqs.deviceAllowed, prereqs.composeHashAllowed
-```
-
-## Related
-
-- [Cloud vs On-Chain KMS](/phala-cloud/key-management/cloud-vs-onchain-kms) — Conceptual comparison
-- [Webhooks](/phala-cloud/references/webhooks) — Event notifications including `cvm.update.pending_approval`
-- [`deploy` CLI command](/phala-cloud/phala-cloud-cli/deploy) — CLI reference with multisig options
diff --git a/phala-cloud/references/cloud-js-sdk/provision-cvm.mdx b/phala-cloud/references/cloud-js-sdk/provision-cvm.mdx
index 7585feb..2d5a043 100644
--- a/phala-cloud/references/cloud-js-sdk/provision-cvm.mdx
+++ b/phala-cloud/references/cloud-js-sdk/provision-cvm.mdx
@@ -113,7 +113,7 @@ await client.commitCvmProvision({
});
```
-For the full on-chain KMS flow including environment encryption and wallet setup, see the [On-chain KMS Guide](/phala-cloud/references/cloud-js-sdk/on-chain-kms-guide).
+For the full on-chain KMS flow including environment encryption and wallet setup, see [Deploying with Onchain KMS](/phala-cloud/key-management/deploying-with-onchain-kms).
The combined size of `docker_compose_file` and `pre_launch_script` must not exceed 200KB.
diff --git a/phala-cloud/references/cloud-python-sdk/error-handling.mdx b/phala-cloud/references/cloud-python-sdk/error-handling.mdx
index b3d465c..d1cb689 100644
--- a/phala-cloud/references/cloud-python-sdk/error-handling.mdx
+++ b/phala-cloud/references/cloud-python-sdk/error-handling.mdx
@@ -195,7 +195,7 @@ except ResourceError as e:
| 03 | `ERR-03-xxx` | CVM operations |
| 04 | `ERR-04-xxx` | Workspace and billing |
| 05 | `ERR-05-xxx` | Credentials and tokens |
-| 06 | `ERR-06-xxx` | Authentication and OAuth |
+| 06 | `ERR-06-xxx` | Authentication — returned as OAuth redirect responses, not JSON API errors |
## Practical Patterns
diff --git a/phala-cloud/references/error-codes.mdx b/phala-cloud/references/error-codes.mdx
index b3e5183..e86e648 100644
--- a/phala-cloud/references/error-codes.mdx
+++ b/phala-cloud/references/error-codes.mdx
@@ -16,6 +16,8 @@ Error codes follow the format `ERR-{MODULE}-{CODE}` where:
- **Module 02** - Inventory
- **Module 03** - CVM Operations
- **Module 04** - Workspace
+- **Module 05** - Credentials
+- **Module 06** - Authentication (OAuth redirect errors — not returned as JSON API responses)
---
@@ -28,10 +30,10 @@ Error codes follow the format `ERR-{MODULE}-{CODE}` where:
| `ERR-01-002` | `ComposeFileRequiredError` | The request contains invalid parameters
*Raised when compose_file is required but not provided* |
| `ERR-01-003` | `InvalidComposeFileError` | The Docker Compose file contains errors
*Raised when Docker Compose file is invalid* |
| `ERR-01-004` | `DuplicateCvmNameError` | (dynamic: A CVM with name '...)
*Raised when a CVM name already exists in the workspace (HTTP 409)* |
-| `ERR-01-005` | `HashRegistrationRequired` | Compose hash registration required on-chain
*Raised when compose hash needs to be registered on-chain (HTTP 465)* |
-| `ERR-01-006` | `HashInvalidOrExpired` | The provided compose hash is invalid or has expired
*Raised when compose hash is invalid or expired (HTTP 466)* |
-| `ERR-01-007` | `TxVerificationFailed` | Transaction verification failed
*Raised when transaction verification fails (HTTP 467)* |
-| `ERR-01-008` | `HashNotAllowed` | The compose hash is not allowed by the on-chain contract
*Raised when compose hash is not allowed by the contract (HTTP 468)* |
+| `ERR-01-005` | `HashRegistrationRequired` | Compose hash registration required on-chain
*Raised by an onchain KMS compose update when the new compose hash is not yet on DstackApp.allowedComposeHashes; the response carries commit_token, commit_url, and the on-chain action needed to complete the prepare-approve-commit flow (HTTP 465)* |
+| `ERR-01-006` | `HashInvalidOrExpired` | The provided compose hash is invalid or has expired
*Raised at commit time when the commit token is unknown, has expired (14-day TTL), has been superseded by a newer prepare for the same CVM, or the compose hash bound to the token no longer matches the compose file currently saved for this CVM — re-run prepare to recover (HTTP 466)* |
+| `ERR-01-007` | `TxVerificationFailed` | Transaction verification failed
*Raised at commit time when the supplied transaction_hash cannot be verified against the expected addComposeHash state change — the receipt is missing, the transaction reverted, or it did not target the expected DstackApp contract (HTTP 467)* |
+| `ERR-01-008` | `HashNotAllowed` | The compose hash is not allowed by the on-chain contract
*Raised at commit time when DstackApp.allowedComposeHashes(bytes32) returns false for the target hash — typically because the Phase 2 approval transaction has not actually landed on the expected contract, or landed on the wrong contract (HTTP 468)* |
## Module 02: Inventory
@@ -54,6 +56,7 @@ Error codes follow the format `ERR-{MODULE}-{CODE}` where:
| `ERR-02-013` | `OsImageNotCompatibleError` | The requested operating system image is not available
*Raised when no compatible OS image is found.* |
| `ERR-02-014` | `NodeCapacityNotConfiguredError` | The requested node is not available
*Raised when node capacity is not properly configured.* |
| `ERR-02-015` | `QuotaExceededError` | Your account has reached its resource quota
*Raised when team resource quota would be exceeded.* |
+| `ERR-02-016` | `GpuDeviceInUseError` | GPUs are available but currently occupied at device level
*Raised when GPUs are available in database but occupied at device level.* |
## Module 03: CVM Operations
@@ -61,9 +64,16 @@ Error codes follow the format `ERR-{MODULE}-{CODE}` where:
| Error Code | Exception Class | Message |
|------------|----------------|---------|
-| `ERR-03-001` | `CvmNotFoundError` | The requested CVM was not found
*Raised when a CVM is not found by the given identifier.* |
+| `ERR-03-001` | `CvmNotFoundError` | The requested CVM was not found
*Raised when a CVM is not found by the given identifier (HTTP 404).* |
| `ERR-03-002` | `MultipleCvmsWithSameNameError` | Multiple CVMs have the same name in this workspace
*Raised when multiple CVMs share the same name in a workspace.* |
-| `ERR-03-005` | `CvmAccessDeniedError` | The requested CVM was not found
*Raised when user lacks permission for CVM operation.* |
+| `ERR-03-003` | `CvmNotInWorkspaceError` | No CVM found in this workspace
*Raised in admin contexts when a CVM exists but belongs to a different workspace — reveals the workspace mismatch (HTTP 404).* |
+| `ERR-03-004` | `CvmNotInWorkspaceError` | The requested CVM was not found
*Raised in non-admin contexts when a CVM belongs to a different workspace — message matches ERR-03-001 to avoid leaking CVM existence (HTTP 404).* |
+| `ERR-03-005` | `CvmAccessDeniedError` | The requested CVM was not found
*Raised when user lacks permission for a CVM operation — message intentionally matches ERR-03-001 to avoid revealing whether the CVM exists (HTTP 404).* |
+| `ERR-03-006` | `ReplicaImageNotAvailableError` | The source CVM's OS image is not available on the target node
*Raised when the source CVM's OS image is not available on the target node.* |
+| `ERR-03-007` | `CvmAppIdConflictError` | This app_id already has an active CVM with a different configuration. Provision again to get a new app_id, or use the existing CVM.
*Raised when app_id is already used by a different configuration in the same workspace (HTTP 409).* |
+| `ERR-03-008` | `ReplicaSourceInstanceNotAccessibleError` | The source CVM instance was not found in this workspace or access is denied
*Raised when the replica source CVM instance is not visible in the current workspace (HTTP 404).* |
+| `ERR-03-009` | `ReplicaComposeHashRequiredError` | This app has multiple live CVM instances. Please specify compose_hash to choose which revision to replicate.
*Raised when replicate needs an explicit compose_hash due to multiple live instances.* |
+| `ERR-03-010` | `MultipleCvmsForIdentifierError` | Multiple CVMs match this identifier in the workspace
*Raised when an identifier (e.g. app_id) matches multiple CVMs in a workspace.* |
## Module 04: Workspace
@@ -71,10 +81,19 @@ Error codes follow the format `ERR-{MODULE}-{CODE}` where:
| Error Code | Exception Class | Message |
|------------|----------------|---------|
-| `ERR-04-001` | `InsufficientBalanceError` | Your account balance is too low to create new resources
*Raised when account balance is too low* |
+| `ERR-04-001` | `InsufficientBalanceError` | You need to top up your account before launching a CVM.
*Raised when account balance is too low* |
| `ERR-04-002` | `MaxCvmLimitError` | Your account has reached the maximum number of instances
*Raised when VM count limit is reached* |
| `ERR-04-003` | `ResourceLimitExceededError` | The requested resources exceed your account limits
*Raised when resource limits are exceeded* |
+## Module 05: Credentials
+
+
+| Error Code | Exception Class | Message |
+|------------|----------------|---------|
+| `ERR-05-001` | `TokenLimitExceededError` | (dynamic: You have reached the maximum of ...)
*Raised when workspace token limit is exceeded* |
+| `ERR-05-002` | `TokenRateLimitError` | (dynamic: Rate limit exceeded: maximum ...)
*Raised when token creation rate limit is exceeded* |
+
+
---
diff --git a/scripts/convert-error-codes.sh b/scripts/convert-error-codes.sh
index 229b386..b7ab3a3 100755
--- a/scripts/convert-error-codes.sh
+++ b/scripts/convert-error-codes.sh
@@ -1,12 +1,12 @@
#!/bin/bash
-# Convert error-codes.md from phala-cloud-monorepo to MDX format
+# Convert error-codes.md from the teehouse monorepo to MDX format
# Usage: ./scripts/convert-error-codes.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
-SOURCE_FILE="$REPO_ROOT/../phala-cloud-monorepo/docs/error-codes.md"
+SOURCE_FILE="$REPO_ROOT/../../docs/error-codes.md"
OUTPUT_FILE="$REPO_ROOT/phala-cloud/references/error-codes.mdx"
if [ ! -f "$SOURCE_FILE" ]; then
@@ -32,7 +32,13 @@ FRONTMATTER
# 2. Remove "Adding New Errors" section and everything after
# 3. Remove teehouse module path references (internal implementation details)
# 4. Remove -000 errors (base class exceptions, not user-facing)
- sed -n '1,/^## Adding New Errors/{ /^## Adding New Errors/d; /^\*\*Source:\*\*/d; p }' "$SOURCE_FILE" \
+ # Using awk for portability (BSD sed on macOS does not support the
+ # multi-command range form used previously).
+ awk '
+ /^## Adding New Errors/ { exit }
+ /^\*\*Source:\*\*/ { next }
+ { print }
+ ' "$SOURCE_FILE" \
| sed 's/ (`teehouse[^`]*`)//g' \
| grep -v 'ERR-[0-9][0-9]-000'
diff --git a/scripts/generate-cli-docs.js b/scripts/generate-cli-docs.js
index 4540a02..088ae31 100755
--- a/scripts/generate-cli-docs.js
+++ b/scripts/generate-cli-docs.js
@@ -114,7 +114,7 @@ function parseHelpOutput(output, commandPath) {
// Detect section headers
// Match both "Available commands:" and grouped headers like "Deploy:", "Manage:", etc.
- if (trimmedLine === "Available commands:") {
+ if (trimmedLine === "Available commands:" || trimmedLine === "Subcommands:") {
section = "commands";
continue;
}
@@ -131,6 +131,12 @@ function parseHelpOutput(output, commandPath) {
!trimmedLine.startsWith("Examples:") &&
!trimmedLine.startsWith("Pass-through")
) {
+ // "Help topics:" lists bundled help topics (e.g., `phala help envs`),
+ // not real commands — skip the whole section.
+ if (trimmedLine === "Help topics:") {
+ section = "helpTopics";
+ continue;
+ }
// Command group header (e.g., "Deploy:", "Manage:", "CVM operations:")
section = "commands";
continue;
@@ -180,14 +186,25 @@ function parseHelpOutput(output, commandPath) {
// Parse sections
if (section === "commands" && trimmedLine) {
+ // Subcommand entries are always indented. Lines at column 0
+ // (e.g. trailing prose like `Use "phala help " to read a topic.`)
+ // are not commands.
+ if (!/^\s{2,}/.test(line)) {
+ continue;
+ }
// Format: " command-name Description [DEPRECATED]"
+ // Also handles " command (alias) Description" by stripping the
+ // "(alias)" marker from the description after matching.
const match = trimmedLine.match(
/^(\S+)\s+(.+?)(?:\s+\[(DEPRECATED|UNSTABLE)\])?(?:\s+\[(DEPRECATED|UNSTABLE)\])?$/
);
if (match) {
+ let description = match[2].trim();
+ // Strip leading alias markers like "(list)" from descriptions
+ description = description.replace(/^\([^)]+\)\s+/, "");
const subCmd = {
name: match[1],
- description: match[2].trim(),
+ description,
deprecated: false,
unstable: false,
};