| title | KMS CVM Deployment | ||||||
|---|---|---|---|---|---|---|---|
| description | Deploy dstack KMS as a Confidential Virtual Machine for TDX attestation | ||||||
| section | KMS Deployment | ||||||
| stepNumber | 3 | ||||||
| totalSteps | 3 | ||||||
| lastUpdated | 2026-01-09 | ||||||
| prerequisites |
|
||||||
| tags |
|
||||||
| difficulty | advanced | ||||||
| estimatedTime | 20 minutes |
This tutorial guides you through deploying the dstack KMS as a Confidential Virtual Machine (CVM). Running KMS inside a CVM enables TDX attestation, providing cryptographic proof that the KMS keys were generated in a genuine Intel TDX environment.
Running KMS inside a CVM provides significant security benefits:
| Benefit | Description |
|---|---|
| TDX Attestation | Generate cryptographic quotes proving keys were created in genuine TDX |
| Memory Encryption | Root keys protected by TDX hardware encryption, not just file permissions |
| Verifiable Integrity | Anyone can verify KMS integrity via attestation quote |
| Consistent Model | KMS deployed the same way as other dstack applications |
Before starting, ensure you have:
- Completed KMS Build & Configuration
- Completed Gramine Key Provider - Required for CVM boot
- Completed Local Docker Registry - With KMS image cached
- Completed TDX & SGX Verification - SGX must be working for attestation
- KMS image pushed to local registry (
registry.yourdomain.com/dstack-kms:fixed) - dstack VMM running (
systemctl status dstack-vmm) - VMM web interface available at http://localhost:9080
Why SGX is required: The KMS uses Intel SGX to generate TDX attestation quotes via the
local_key_provider. SGX Auto MP Registration must be enabled in BIOS so your platform is registered with Intel's Provisioning Certification Service (PCS). Without this registration, KMS cannot generate valid attestation quotes, and bootstrap will fail.
Why local registry? The KMS Docker image is cached in your Local Docker Registry for reliable, fast access from CVMs. The auth-eth service inside the container requires
ETH_RPC_URLandKMS_CONTRACT_ADDRenvironment variables — these are passed via docker-compose, not baked into the image.
When you deploy KMS as a CVM, the following happens:
- CVM Creation - VMM creates a TDX-protected virtual machine
- Container Start - Docker container runs inside the CVM
- Onboard Mode - KMS starts a plain HTTP server, waiting for bootstrap
- Manual Bootstrap - You trigger key generation via an RPC call
- TDX Quote - KMS generates attestation quote proving TDX environment
- Service Ready - KMS transitions to TLS and starts accepting connections
Inside the CVM at /etc/kms/certs/:
| File | Purpose |
|---|---|
root-ca.crt |
Root Certificate Authority (self-signed) |
root-ca.key |
Root CA signing key (P256 ECDSA) |
rpc.crt |
TLS certificate for RPC server |
rpc.key |
RPC server private key |
tmp-ca.crt |
Temporary CA for mutual TLS |
tmp-ca.key |
Temporary CA private key |
root-k256.key |
Ethereum signing key (secp256k1) |
bootstrap-info.json |
Public keys and TDX attestation quote |
If you prefer to deploy manually, follow these steps.
Check that all required components are ready.
curl -sk https://registry.yourdomain.com/v2/dstack-kms/tags/listExpected output shows the :fixed tag:
{"name":"dstack-kms","tags":["fixed","latest"]}If missing, complete the Local Docker Registry tutorial first.
docker ps | grep gramine-sealing-key-providerShould show the container running. If not, complete the Gramine Key Provider tutorial.
systemctl status dstack-vmmThe VMM must be active and running.
mkdir -p ~/kms-deploy
cd ~/kms-deployReplace placeholders: If you haven't already personalized the tutorials with your domain names, see DNS Configuration: Personalize Tutorials. You must replace
registry.yourdomain.comandkms.yourdomain.comwith your actual domains.
Create the compose file with your registry domain and configuration:
cat > docker-compose.yaml << 'EOF'
services:
kms:
image: registry.yourdomain.com/dstack-kms:fixed
ports:
- "9100:9100"
volumes:
- /var/run/dstack.sock:/var/run/dstack.sock
- kms-certs:/etc/kms/certs
environment:
- RUST_LOG=info
- KMS_DOMAIN=kms.yourdomain.com
- PORT=9200
- ETH_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com
- KMS_CONTRACT_ADDR=YOUR_CONTRACT_ADDRESS
configs:
- source: kms_config
target: /etc/kms/kms.toml
restart: unless-stopped
volumes:
kms-certs:
configs:
kms_config:
content: |
[rpc]
address = "0.0.0.0"
port = 9100
[rpc.tls]
key = "/etc/kms/certs/rpc.key"
certs = "/etc/kms/certs/rpc.crt"
[rpc.tls.mutual]
ca_certs = "/etc/kms/certs/tmp-ca.crt"
mandatory = false
[core]
cert_dir = "/etc/kms/certs"
pccs_url = "https://pccs.phala.network/sgx/certification/v4"
[core.image]
verify = true
cache_dir = "/etc/kms/images"
download_url = "https://download.dstack.org/os-images/mr_{OS_IMAGE_HASH}.tar.gz"
download_timeout = "2m"
[core.auth_api]
type = "webhook"
[core.auth_api.webhook]
url = "http://127.0.0.1:9200"
[core.onboard]
enabled = true
auto_bootstrap_domain = ""
address = "0.0.0.0"
port = 9100
EOFReplace the placeholder values with your actual configuration:
# Registry domain (must match your local Docker registry)
sed -i 's|registry.yourdomain.com|registry.your-actual-domain.com|g' docker-compose.yaml
# KMS domain (for the KMS_DOMAIN env var)
sed -i 's|kms.yourdomain.com|kms.your-actual-domain.com|g' docker-compose.yaml
# KMS contract address (from contract deployment tutorial)
sed -i "s|YOUR_CONTRACT_ADDRESS|$(cat ~/.dstack/secrets/kms-contract-address)|g" docker-compose.yamlThis docker-compose uses a Docker configs section to inject a complete kms.toml into the container at /etc/kms/kms.toml, overriding the config baked into the image. This approach lets you change KMS configuration without rebuilding the Docker image.
Key configuration sections in kms.toml:
| Section | Purpose |
|---|---|
[rpc] |
RPC server address and port (9100) |
[rpc.tls] |
TLS certificate paths for HTTPS |
[core.image] |
OS image verification — downloads images from download.dstack.org to compute expected TDX measurements |
[core.auth_api] |
Authentication via auth-eth webhook on localhost:9200 |
[core.onboard] |
Bootstrap settings — auto_bootstrap_domain is empty so KMS enters onboard mode for manual bootstrap |
Why manual bootstrap? With
auto_bootstrap_domainleft empty, KMS starts in "onboard mode" — a plain HTTP server on port 9100 that waits for you to trigger bootstrap via an RPC call. This ensuresbootstrap-info.json(containing the TDX attestation quote and public keys) is written to disk. You'll need this file later to register the KMS on-chain.
Environment variables explained:
| Variable | Required | Description |
|---|---|---|
RUST_LOG |
Yes | KMS log level (info, debug, etc.) |
KMS_DOMAIN |
Yes | KMS domain name (used by start-kms.sh for reference) |
PORT |
Yes | auth-eth listen port — must be 9200 to match kms.toml webhook URL |
ETH_RPC_URL |
Yes | Ethereum Sepolia RPC endpoint |
KMS_CONTRACT_ADDR |
Yes | Your deployed KMS contract address |
Getting your values:
# Your KMS contract address (from contract deployment tutorial) cat ~/.dstack/secrets/kms-contract-addressFor
ETH_RPC_URL, the tutorials use the freehttps://ethereum-sepolia-rpc.publicnode.comendpoint. For production, consider a dedicated RPC provider.
Other important settings:
image: Must use your local registry with the:fixedtag/var/run/dstack.sock: Required for TDX attestationconfigs: Injectskms.tomlat runtime — thestart-kms.shentrypoint reads from/etc/kms/kms.toml
Use the VMM CLI tool to deploy the CVM:
# Navigate to dstack VMM directory
cd ~/dstack/vmm
# Set VMM auth from saved token
export DSTACK_VMM_AUTH_PASSWORD=$(cat ~/.dstack/secrets/vmm-auth-token)
# Generate app-compose.json with local key provider enabled
./src/vmm-cli.py --url http://127.0.0.1:9080 compose \
--name kms \
--docker-compose ~/kms-deploy/docker-compose.yaml \
--local-key-provider \
--output ~/kms-deploy/app-compose.json
# Deploy the CVM
./src/vmm-cli.py --url http://127.0.0.1:9080 deploy \
--name kms \
--image dstack-0.5.7 \
--compose ~/kms-deploy/app-compose.json \
--vcpu 2 \
--memory 4096 \
--disk 20 \
--port tcp:0.0.0.0:9100:9100Key flags explained:
--local-key-provider: Enables Gramine key provider for CVM boot--image dstack-0.5.7: Guest image from VMM images directory--port tcp:0.0.0.0:9100:9100: Maps host port 9100 to CVM port 9100 on all interfaces
Why
0.0.0.0and not127.0.0.1? Gateway CVMs use QEMU user-mode networking and reach the host via its public IP. If KMS is bound to localhost only, gateway CVMs cannot connect. KMS authentication uses TDX attestation, not network isolation, so public accessibility is safe.
Note: Do NOT use
--secure-timeflag - it causes CVM to hang during boot waiting for time sync.
List VMs to get the ID, then view the boot logs:
# List VMs to get the ID
./src/vmm-cli.py --url http://127.0.0.1:9080 lsvmView CVM boot logs using curl (replace VM_ID with the actual ID from lsvm):
# View recent logs
curl -s -H "Authorization: Bearer $(cat ~/.dstack/secrets/vmm-auth-token)" \
"http://127.0.0.1:9080/logs?id=VM_ID&follow=false&ansi=false&lines=100"
# Follow logs in real-time
curl -s -H "Authorization: Bearer $(cat ~/.dstack/secrets/vmm-auth-token)" \
"http://127.0.0.1:9080/logs?id=VM_ID&follow=true&ansi=false"Note: The VMM logs endpoint requires Bearer token authentication. The
vmm-cli.py logscommand may not work with token auth — use curl directly as shown above.
Look for these log messages indicating KMS entered onboard mode:
KMS CVM booting...
Docker container starting...
KMS initializing...
Onboarding
Important: KMS is now in onboard mode — a plain HTTP server waiting for bootstrap. It will not serve TLS or respond to
KMS.GetMetauntil you complete the next step.Critical prerequisite: before bootstrap can succeed, the KMS must already be authorized by your auth backend.
- For
auth-simple, add the KMSmrAggregatedtokms.mrAggregated- For
auth-eth, add the KMSmrAggregatedon-chain withaddKmsAggregatedMr(...)You can fetch the value before bootstrap with:
curl -s -X POST \ -H "Content-Type: application/json" \ -d '{}' \ "http://localhost:9100/prpc/Onboard.GetAttestationInfo?json" | jq .If you skip this step,
Onboard.Bootstrapwill fail with a KMS authorization error and the KMS will not enter normal service.Pre-bootstrap checklist:
Onboard.GetAttestationInforeturns the current KMS measurement- that
mrAggregatedhas been allowlisted in your auth backend- the auth backend is reachable from the KMS CVM
- you are still calling the onboard HTTP endpoint, not the post-bootstrap TLS endpoint
With KMS in onboard mode, trigger key generation by calling the Bootstrap RPC endpoint. This generates root keys, a TDX attestation quote, and writes bootstrap-info.json:
# Inspect the KMS measurement before bootstrap
curl -s -X POST \
-H "Content-Type: application/json" \
-d '{}' \
"http://localhost:9100/prpc/Onboard.GetAttestationInfo?json" | jq .
# Replace kms.yourdomain.com with your actual KMS domain
curl -s -X POST \
-H "Content-Type: application/json" \
-d '{"domain":"kms.yourdomain.com"}' \
"http://localhost:9100/prpc/Onboard.Bootstrap?json" | tee ~/kms-deploy/bootstrap-info.json | jq .Note: This uses plain
http://— KMS is still in onboard mode (no TLS yet). Theteecommand saves the response tobootstrap-info.jsonwhile also displaying it. You'll need this file later to register KMS on-chain. If this call fails with a KMS authorization error, allowlist themrAggregatedvalue first and retry.
Expected response:
{
"ca_pubkey": "3059301306072a8648ce3d0201...",
"k256_pubkey": "0304c6bfe0ecd9bfa8b8c3450c...",
"attestation": "04000200810000000..."
}Now signal KMS to exit onboard mode and start the main TLS service:
curl -s "http://localhost:9100/finish"Wait a few seconds for KMS to transition from onboard mode to the main TLS service:
sleep 5Test connectivity to the KMS RPC server (now using TLS):
curl -sk https://localhost:9100/prpc/KMS.GetMeta?json | jq .Important: Use https:// — KMS now serves TLS after exiting onboard mode.
Expected response:
{
"ca_cert": "-----BEGIN CERTIFICATE-----...",
"allow_any_upgrade": false,
"k256_pubkey": "0304c6bfe0ecd9bfa8b8c3450c8fb49f52d6234522bd4e42c0736db852da8c871e",
"bootstrap_info": {
"ca_pubkey": "3059301306072a8648ce3d0201...",
"k256_pubkey": "0304c6bfe0ecd9bfa8b8c3450c...",
"attestation": "04000200810000000..."
},
"is_dev": false,
"gateway_app_id": "",
"kms_contract_address": "0xe6c23bfE4686E28DcDA15A1996B1c0C549656E26",
"chain_id": 11155111,
"app_auth_implementation": "0xc308574F9A0c7d144d7AD887785D25C386D32B54"
}Key fields to verify:
bootstrap_info: Contains public keys and TDX attestation quote (not null)bootstrap_info.attestation: Non-empty — proves keys were generated in genuine TDXca_cert: Root CA certificate was generatedk256_pubkey: Ethereum signing key was generatedchain_id: 11155111 indicates Sepolia testnetkms_contract_address: Your deployed KMS contract address
Verify the RPC responds quickly (not hanging):
time curl -sk https://localhost:9100/prpc/KMS.GetMeta?json > /dev/nullExpected: Response in < 1 second. If it takes > 10 seconds or hangs, see Troubleshooting section below.
With KMS running in a CVM, the TDX quote provides cryptographic proof of integrity.
# Extract the attestation quote from bootstrap_info
curl -sk https://localhost:9100/prpc/KMS.GetMeta?json | jq -r '.bootstrap_info.attestation'This returns a hex-encoded TDX quote. A non-empty value confirms KMS generated a valid attestation during bootstrap.
The TDX quote contains:
- MRTD - Measurement of the TDX environment
- RTMR - Runtime measurements
- Report Data - KMS public keys bound to the quote
- Signature - Intel's attestation signature
The TDX quote can be verified by:
- Intel PCCS - Platform Configuration and Certification Service
- On-chain verification - Smart contract quote validation
- Third-party services - Independent attestation verification
┌─────────────────────────────────────────────────────────┐
│ TDX Host │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ dstack-vmm │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ KMS CVM (TDX Protected) │ │ │
│ │ │ │ │ │
│ │ │ ┌──────────────────────────────────┐ │ │ │
│ │ │ │ Docker Container │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ ┌─────────┐ ┌──────────────┐ │ │ │ │
│ │ │ │ │ KMS │◄──│ auth-eth │ │ │ │ │
│ │ │ │ └────┬────┘ └──────┬───────┘ │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ │ ▼ ▼ │ │ │ │
│ │ │ │ /etc/kms/certs Ethereum RPC │ │ │ │
│ │ │ └──────────────────────────────────┘ │ │ │
│ │ │ │ │ │
│ │ │ guest-agent (/var/run/dstack.sock) │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ Port 9100 ◄─── External connections │
└─────────────────────────────────────────────────────────┘
| Aspect | Host-based KMS | CVM-based KMS |
|---|---|---|
| TDX Attestation | Not available | Full attestation with quotes |
| Memory Protection | OS-level only | TDX hardware encryption |
| Key Security | File permissions | Hardware-protected memory |
| Verification | Physical security | Cryptographic proof |
| Deployment | systemd service | VMM-managed CVM |
For detailed solutions, see the KMS Deployment Troubleshooting Guide:
- CVM fails to start
- CVM Exits Immediately or Reboots in a Loop
- Bootstrap hangs
- Port 9100 not accessible
- TDX quote not generated
- CVM Fails with "QGS error code: 0x12001"
- GetMeta Returns "Connection refused" on Port 9200
- GetMeta Returns "missing field
status" - GetMeta Hangs or Times Out
- CVM Hangs at "Waiting for time to be synchronized"
CVM certificates are stored in a Docker named volume (kms-certs). This provides:
- Container restart persistence - Certificates survive container restarts
- CVM restart consideration - Depending on VMM configuration, volumes may or may not persist
After successful bootstrap, backup the bootstrap info:
# Save bootstrap info (contains public keys and TDX attestation quote)
curl -sk https://localhost:9100/prpc/KMS.GetMeta?json | jq '.bootstrap_info' > ~/kms-bootstrap-info-$(date +%Y%m%d).json
# The private keys remain inside the CVM for security
# For full backup, use the VMM console to export the CVM stateStore backup information securely offline.
With KMS deployed as a CVM, proceed to set up the Gateway:
- Gateway Build & Configuration - Build and configure the dstack gateway