| title | Hello World Application | |||||
|---|---|---|---|---|---|---|
| description | Deploy your first application to a dstack Confidential Virtual Machine | |||||
| section | First Application | |||||
| stepNumber | 1 | |||||
| totalSteps | 2 | |||||
| lastUpdated | 2026-03-06 | |||||
| prerequisites |
|
|||||
| tags |
|
|||||
| difficulty | intermediate | |||||
| estimatedTime | 30 minutes |
This tutorial guides you through deploying your first application to a dstack Confidential Virtual Machine (CVM). You'll deploy a simple nginx web server that runs inside a TDX-protected environment with full gateway integration, verifying that your entire dstack infrastructure is working correctly end-to-end.
| Component | Description |
|---|---|
| nginx:alpine | Lightweight web server running inside a CVM |
| KMS attestation | TDX-verified app identity via on-chain compose hash |
| Gateway routing | HTTPS access via WireGuard tunnel with Let's Encrypt certificate |
When you deploy an application to dstack:
- vmm-cli.py compose generates an encrypted deployment manifest (
app-compose.json) - On-chain registration whitelists the compose hash so KMS will attest the app
- vmm-cli.py deploy creates a TDX-protected CVM with the manifest
- Guest OS boots, Docker containers start, and the app contacts KMS for attestation
- Gateway registration — with
--gatewayflag, the app CVM establishes a WireGuard tunnel to the gateway - HTTPS routing — the gateway provisions a Let's Encrypt certificate and routes traffic to the app
Client HTTPS Request
│
▼
┌──────────────────┐
│ HAProxy (:443) │
│ SNI routing │
└────────┬─────────┘
│
▼
┌──────────────────┐ WireGuard ┌──────────────┐
│ Gateway CVM │ ◄────────────────► │ App CVM │
│ TLS termination │ tunnel │ nginx :80 │
│ Let's Encrypt │ │ TDX protected│
└──────────────────┘ └──────────────┘
- Completed Gateway CVM Deployment — gateway running and admin API bootstrapped
- KMS CVM running on port 9100
- VMM running (
systemctl status dstack-vmm) - Python cryptography libraries for
vmm-cli.py:pip3 install --break-system-packages cryptography eth-keys eth-utils "eth-hash[pycryptodome]"
- Foundry toolchain installed (
castcommand available) - Wallet private key at
~/.dstack/secrets/sepolia-private-key - KMS contract address at
~/.dstack/secrets/kms-contract-address
Verify the infrastructure is ready:
# KMS is responding
curl -sk https://localhost:9100/prpc/KMS.GetMeta | jq '{chain_id}' && echo "KMS: OK"
# Gateway admin API is responding
curl -sf http://127.0.0.1:9203/prpc/Status > /dev/null && echo "Gateway: OK"
# VMM is running
systemctl is-active dstack-vmm && echo "VMM: OK"mkdir -p ~/hello-world-deploy
cd ~/hello-world-deployCreate a minimal compose file. The app runs inside a CVM, so there is no access to the host filesystem — do not use local volume mounts.
cat > docker-compose.yaml << 'EOF'
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
restart: always
EOF| Setting | Description |
|---|---|
image: nginx:alpine |
Lightweight nginx image, pulled from Docker Hub at boot |
ports: "80:80" |
Expose port 80 inside the CVM |
restart: always |
Restart container if it crashes |
No local volumes: Unlike a traditional Docker setup, CVMs don't have access to host directories. The default nginx welcome page is served automatically. To serve custom content, you would bake it into a custom Docker image.
Run on your local machine. This step uses
cast(Foundry) and your wallet private key, which live on your local machine — not on the server.
The app needs an on-chain identity so KMS can attest it and the gateway can route traffic to it.
export PRIVATE_KEY=$(cat ~/.dstack/secrets/sepolia-private-key)
export ETH_RPC_URL="https://ethereum-sepolia-rpc.publicnode.com"
export KMS_CONTRACT_ADDR=$(cat ~/.dstack/secrets/kms-contract-address)HELLO_APP_ID=$(cast send "$KMS_CONTRACT_ADDR" \
"deployAndRegisterApp(address,bool,bool,bytes32,bytes32)" \
"$(cast wallet address --private-key $PRIVATE_KEY)" \
false \
true \
0x0000000000000000000000000000000000000000000000000000000000000000 \
0x0000000000000000000000000000000000000000000000000000000000000000 \
--rpc-url "$ETH_RPC_URL" \
--private-key "$PRIVATE_KEY" \
--json | jq -r '.logs[-1].topics[1]' | sed 's/0x000000000000000000000000/0x/')
echo "Hello World App ID: $HELLO_APP_ID"Verify the app was created:
cast call "$HELLO_APP_ID" "owner()(address)" --rpc-url "$ETH_RPC_URL"This should return your wallet address.
echo "$HELLO_APP_ID" > ~/.dstack/secrets/hello-world-app-idThe server needs the app ID for Step 6 (CVM deployment). Copy it over:
# Replace user@your-server with your actual server SSH target
scp ~/.dstack/secrets/hello-world-app-id user@your-server:~/.dstack/secrets/SSH back into the server before continuing:
ssh user@your-serverUse vmm-cli.py compose to generate the encrypted deployment manifest. The --gateway and --kms flags enable gateway registration and KMS attestation.
cd ~/dstack/vmm
export DSTACK_VMM_AUTH_PASSWORD=$(cat ~/.dstack/secrets/vmm-auth-token)
./src/vmm-cli.py --url http://127.0.0.1:9080 compose \
--docker-compose ~/hello-world-deploy/docker-compose.yaml \
--name hello-world \
--gateway \
--kms \
--public-logs \
--output ~/hello-world-deploy/app-compose.jsonKey flags:
| Flag | Purpose |
|---|---|
--gateway |
Enable gateway integration — the CVM will register with the gateway and establish a WireGuard tunnel |
--kms |
Enable KMS attestation — the CVM will contact KMS for TDX verification |
--public-logs |
Allow log access via VMM API (useful for debugging) |
The compose hash is needed on your local machine for on-chain whitelisting. Display it and copy the value:
COMPOSE_HASH=$(sha256sum ~/hello-world-deploy/app-compose.json | cut -d' ' -f1)
echo "Compose hash: 0x$COMPOSE_HASH"Copy the full 0x... hash value — you'll paste it into Step 5 on your local machine.
Run on your local machine. This step uses
castand your wallet private key.
The KMS contract verifies that the exact compose configuration is authorized. Use the compose hash from Step 4 and register it on-chain.
If you're in a new shell since Step 3, re-load your wallet credentials:
export PRIVATE_KEY=$(cat ~/.dstack/secrets/sepolia-private-key)
export ETH_RPC_URL="https://ethereum-sepolia-rpc.publicnode.com"
export KMS_CONTRACT_ADDR=$(cat ~/.dstack/secrets/kms-contract-address)Set the compose hash (paste the value displayed in Step 4):
COMPOSE_HASH="<paste-hash-from-step-4-without-0x-prefix>"
HELLO_APP_ID=$(cat ~/.dstack/secrets/hello-world-app-id)
cast send "$HELLO_APP_ID" \
"addComposeHash(bytes32)" \
"0x$COMPOSE_HASH" \
--rpc-url "$ETH_RPC_URL" \
--private-key "$PRIVATE_KEY"Verify:
cast call "$HELLO_APP_ID" \
"allowedComposeHashes(bytes32)(bool)" \
"0x$COMPOSE_HASH" \
--rpc-url "$ETH_RPC_URL"Expected output: true
Important: If you modify
docker-compose.yamland regenerateapp-compose.json, the hash changes. You must whitelist the new hash before deploying.
SSH back into the server before continuing:
ssh user@your-servercd ~/dstack/vmm
export DSTACK_VMM_AUTH_PASSWORD=$(cat ~/.dstack/secrets/vmm-auth-token)
SRV_DOMAIN=$(grep ^SRV_DOMAIN ~/gateway-deploy/.env | cut -d= -f2)
KMS_DOMAIN=$(grep ^KMS_DOMAIN ~/gateway-deploy/.env | cut -d= -f2)
./src/vmm-cli.py --url http://127.0.0.1:9080 deploy \
--name hello-world \
--app-id "$(cat ~/.dstack/secrets/hello-world-app-id)" \
--compose ~/hello-world-deploy/app-compose.json \
--gateway-url "https://gateway.$SRV_DOMAIN" \
--kms-url "https://$KMS_DOMAIN:9100" \
--image dstack-0.5.7 \
--vcpu 2 \
--memory 2G \
--port tcp:0.0.0.0:9300:80Key flags:
| Flag | Value | Purpose |
|---|---|---|
--app-id |
Hello World app ID | Links CVM to on-chain app identity |
--gateway-url |
https://gateway.$SRV_DOMAIN |
Gateway RPC endpoint (uses port 443 via HAProxy passthrough) |
--kms-url (1st) |
https://127.0.0.1:9100 |
Host-side KMS for env encryption |
--kms-url (2nd) |
https://$KMS_DOMAIN:9100 |
CVM-side KMS (domain must match TLS cert) |
--port |
tcp:0.0.0.0:9300:80 |
Direct port mapping for testing (optional) |
Why two
--kms-urlvalues? Same reason as the gateway — the first is for host-side encryption, the second is for CVM-side runtime access. See Gateway CVM Deployment for details.
List VMs and get the hello-world ID:
./src/vmm-cli.py --url http://127.0.0.1:9080 lsvmFollow the boot logs (replace VM_ID with the actual ID):
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"Watch for these key log messages:
Docker container starting...
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
And if gateway integration is working:
Registering with gateway...
WireGuard tunnel established
The CVM typically boots in 1-2 minutes.
Once the CVM registers with the gateway, it's accessible via an HTTPS URL. The gateway automatically provisions a Let's Encrypt certificate.
Find your app's gateway URL. If you've deployed multiple times, the hosts array may contain stale entries from previous deployments. Use the most recent latest_handshake to identify the active instance:
# Get the most recently active app instance
curl -sf http://127.0.0.1:9203/prpc/Status | jq '.hosts | sort_by(.latest_handshake) | reverse | .[0]'The instance_id and base_domain fields determine the app URL: https://<instance_id>-80.gateway.<base_domain>.
Access the app:
# Replace with your actual instance_id and base_domain from the output above
curl -s "https://<instance_id>-80.gateway.<base_domain>/"You should see the default nginx welcome page HTML. The Let's Encrypt certificate is automatically provisioned, so this works without -k.
Verify the certificate:
echo | openssl s_client -connect <instance_id>-80.gateway.<base_domain>:443 -servername <instance_id>-80.gateway.<base_domain> 2>/dev/null | openssl x509 -noout -issuer -subjectThe issuer should be Let's Encrypt (not STAGING).
As an alternative to gateway access, you can test directly via the mapped port:
curl -s http://YOUR_SERVER_IP:9300/This bypasses the gateway and hits nginx directly. You should see the same nginx welcome page.
Note: Direct port access is unencrypted HTTP. In production, use the gateway HTTPS URL.
Navigate to the VMM directory:
cd ~/dstack/vmm
export DSTACK_VMM_AUTH_PASSWORD=$(cat ~/.dstack/secrets/vmm-auth-token)./src/vmm-cli.py --url http://127.0.0.1:9080 lsvmVM_ID=$(./src/vmm-cli.py --url http://127.0.0.1:9080 lsvm --json | jq -r '.[] | select(.name=="hello-world") | .id')
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=50"VM_ID=$(./src/vmm-cli.py --url http://127.0.0.1:9080 lsvm --json | jq -r '.[] | select(.name=="hello-world") | .id')
./src/vmm-cli.py --url http://127.0.0.1:9080 stop --force "$VM_ID"
./src/vmm-cli.py --url http://127.0.0.1:9080 remove "$VM_ID"To redeploy after changes:
- Remove the existing CVM (see above)
- If you changed
docker-compose.yaml, regenerateapp-compose.json(Step 4) and whitelist the new hash (Step 5) - Re-run the deploy command (Step 6)
For detailed solutions, see the First Application Troubleshooting Guide:
- CVM fails to start
- "OS image is not allowed"
- CVM boots but no gateway registration
- Application not accessible via gateway
- Cannot pull Docker images
Before proceeding, verify:
- App registered on-chain with
deployAndRegisterApp - Compose hash whitelisted on app contract
- CVM deployed and running (
lsvmshows status) - CVM registered with gateway (WireGuard tunnel established)
- Application accessible via gateway HTTPS URL (valid Let's Encrypt cert)
- Application accessible via direct port mapping (optional)
┌─────────────────────────────────────────────────────────────┐
│ CVM (TDX Protected) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Docker Container │ │
│ │ ┌─────────────┐ │ │
│ │ │ nginx │ │ │
│ │ │ :80 │ │ │
│ │ └─────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Guest Agent │ │
│ │ - TDX attestation via /var/run/dstack.sock │ │
│ │ - Docker lifecycle management │ │
│ │ - WireGuard tunnel to gateway │ │
│ │ - Log forwarding to VMM │ │
│ └───────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ TDX Protection │ │
│ │ - Encrypted memory (hardware-enforced) │ │
│ │ - Measured boot chain (MRTD, RTMRs) │ │
│ │ - Isolated from host OS │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Your Hello World application is running inside a TDX-protected CVM with full gateway integration. From here you can:
- Deploy more complex applications with multiple containers
- Use the tappd socket (
/var/run/tappd.sock) for TDX attestation from your application - Build custom Docker images with your own application code