Run a single-node Kubernetes cluster inside an Intel TDX Confidential VM with wildcard HTTPS for all your services.
- k3s cluster running in a hardware-isolated TEE
- Wildcard TLS certificate (Let's Encrypt) so every service gets HTTPS automatically
- Remote
kubectlaccess through the dstack gateway - Traefik ingress controller for routing HTTP traffic to pods
- Phala Cloud account (or a self-hosted dstack deployment — see Running on Raw dstack)
- Phala CLI installed and authenticated:
npm install -g phala phala auth login
kubectlandjqinstalled (kubectl install guide)- A domain you control (for the wildcard certificate)
- Cloudflare API token with Zone:Read and DNS:Edit permissions (see DNS_PROVIDERS.md for other DNS providers)
The deploy script handles everything — CVM provisioning, kubeconfig extraction, certificate waiting, and a test workload:
export CLOUDFLARE_API_TOKEN=your-cloudflare-token
export CERTBOT_EMAIL=you@example.com
./deploy.sh k3s.example.comReplace k3s.example.com with your actual domain. The script takes ~10 minutes (mostly waiting for the wildcard certificate). When done, it prints:
============================================
k3s on dstack is ready!
============================================
Kubeconfig: export KUBECONFIG=/path/to/k3s.yaml
kubectl: kubectl get nodes
Test URL: https://nginx.k3s.example.com/
Evidence: https://nginx.k3s.example.com/evidences/quote
You can then deploy your own services:
export KUBECONFIG=k3s.yaml
kubectl run my-app --image=my-app:latest --port=8080
kubectl expose pod my-app --port=8080
kubectl apply -f - <<EOF
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: my-app
spec:
entryPoints: [web]
routes:
- match: Host(\`my-app.k3s.example.com\`)
kind: Rule
services:
- name: my-app
port: 8080
EOFRemove the test workload:
kubectl delete ingressroute.traefik.io nginx
kubectl delete svc nginx
kubectl delete pod nginxDelete the CVM entirely:
echo y | phala cvms delete my-k3s
rm k3s.yamlYou can customize the deployment with environment variables:
| Variable | Default | Description |
|---|---|---|
CVM_NAME |
my-k3s |
CVM name |
INSTANCE_TYPE |
tdx.medium |
Instance type |
DISK_SIZE |
50G |
Disk size |
KUBECONFIG_FILE |
k3s.yaml |
Output kubeconfig path |
Example:
CVM_NAME=prod-k3s INSTANCE_TYPE=tdx.4xlarge DISK_SIZE=100G ./deploy.sh k3s.example.comIf you prefer to run each step manually instead of using the deploy script:
Click to expand manual steps
phala deploy \
-n my-k3s \
-c docker-compose.yaml \
-t tdx.medium \
--disk-size 50G \
--dev-os \
-e "CLOUDFLARE_API_TOKEN=your-cloudflare-token" \
-e "CERTBOT_EMAIL=you@example.com" \
-e "CLUSTER_DOMAIN=k3s.example.com" \
--waitThe --dev-os flag enables SSH access (needed to extract the kubeconfig). The --disk-size 50G gives enough room for k3s images and workloads.
The deploy command outputs an App ID and gateway info. Save the App ID (a 40-character hex string):
App ID: a1b2c3d4e5f6...
You can also retrieve these later:
phala cvms get my-k3s --jsonWait 3-4 minutes for the CVM to boot, then extract the kubeconfig:
APP_ID=<your-app-id>
GATEWAY_DOMAIN=<your-gateway-domain> # e.g. dstack-pha-prod5.phala.network
# Find the gateway domain from the CVM info if you don't have it
# phala cvms get my-k3s --json | jq -r '.gateway.base_domain'
# Extract kubeconfig from the CVM
phala ssh "$APP_ID" -- \
"docker exec dstack-k3s-1 cat /etc/rancher/k3s/k3s.yaml" \
2>/dev/null > k3s.yaml
# Rewrite the API server URL to use the gateway TLS passthrough endpoint
sed -i "s|https://127.0.0.1:6443|https://${APP_ID}-6443s.${GATEWAY_DOMAIN}|" k3s.yaml
export KUBECONFIG=$(pwd)/k3s.yamlNote: The
-6443ssuffix tells the dstack gateway to use TLS passthrough (thesmeans passthrough). This waykubectltalks directly to the k3s API server's TLS — the gateway never sees the traffic contents.
kubectl get nodesWait until the node shows Ready (1-2 minutes after SSH becomes available):
NAME STATUS ROLES AGE VERSION
k3s-node Ready control-plane,master 2m v1.31.6+k3s1
The dstack-ingress container issues a Let's Encrypt wildcard certificate via DNS-01 challenge. This takes 5-8 minutes after CVM boot (certbot installation + 120s DNS propagation + issuance). If your domain has existing CAA records, the first attempt may fail and auto-retry after setting the correct issuewild CAA record.
Check with:
curl -sI "https://test.k3s.example.com/" 2>&1 | head -3Retry until you see an HTTP response (a 404 is fine — it means TLS works but no service is routed yet):
HTTP/1.1 404 Not Found
CLUSTER_DOMAIN=k3s.example.com
kubectl run nginx --image=nginx:alpine --port=80
kubectl expose pod nginx --port=80 --target-port=80 --name=nginx
kubectl wait --for=condition=Ready pod/nginx --timeout=120s
kubectl apply -f - <<EOF
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: nginx
spec:
entryPoints: [web]
routes:
- match: Host(\`nginx.${CLUSTER_DOMAIN}\`)
kind: Rule
services:
- name: nginx
port: 80
EOF
sleep 10
curl -s "https://nginx.${CLUSTER_DOMAIN}/"You should see the nginx welcome page served over HTTPS with a valid Let's Encrypt certificate.
graph LR
subgraph Internet
User[Browser / curl]
Kubectl[kubectl]
end
subgraph "dstack CVM (Intel TDX)"
Ingress[dstack-ingress<br/>TLS termination]
Traefik[Traefik<br/>HTTP routing]
K3sAPI[k3s API server<br/>port 6443]
Pod1[Pod A]
Pod2[Pod B]
end
User -->|HTTPS| Ingress
Ingress -->|HTTP| Traefik
Traefik --> Pod1
Traefik --> Pod2
Kubectl -->|TLS passthrough<br/>via gateway| K3sAPI
External HTTPS traffic hits dstack-ingress, which terminates TLS using the wildcard certificate and forwards plain HTTP to Traefik. Traefik routes requests to pods based on Host header matching via IngressRoutes.
kubectl connects through the dstack gateway's TLS passthrough mode (port suffix -6443s), so the gateway forwards encrypted traffic directly to the k3s API server without inspecting it.
| Service | Purpose |
|---|---|
kmod-installer |
Loads kernel modules required by k3s networking (runs once at boot) |
k3s |
Single-node k3s server running in privileged mode |
dstack-ingress |
Wildcard TLS termination via Let's Encrypt DNS-01, proxies to Traefik |
- dstack-ingress requests a wildcard certificate for
*.k3s.example.comfrom Let's Encrypt using DNS-01 validation - It creates a DNS TXT record via the Cloudflare API to prove domain ownership
- After certificate issuance, all HTTPS traffic to
*.k3s.example.comis terminated by dstack-ingress - The decrypted HTTP traffic is forwarded to Traefik on port 80
- Traefik matches the
Hostheader against IngressRoute rules and routes to the right pod
| Variable | Required | Description |
|---|---|---|
CLOUDFLARE_API_TOKEN |
Yes | Cloudflare API token for DNS-01 certificate challenges |
CERTBOT_EMAIL |
Yes | Email for Let's Encrypt registration |
CLUSTER_DOMAIN |
Yes | Your domain for the wildcard cert (e.g., k3s.example.com) |
K3S_NODE_NAME |
No | Kubernetes node name (default: k3s-node) |
The following are auto-injected by Phala Cloud and used in the compose file:
| Variable | Description |
|---|---|
DSTACK_APP_ID |
CVM application ID (used for k3s TLS SAN) |
DSTACK_GATEWAY_DOMAIN |
Gateway domain (used for k3s TLS SAN and ingress routing) |
The compose file defaults to Cloudflare. To use a different provider, change DNS_PROVIDER and the corresponding credentials. See DNS_PROVIDERS.md for supported providers (Linode, Namecheap, Route53).
| Instance Type | Recommended For |
|---|---|
tdx.medium |
Testing and tutorials |
tdx.4xlarge |
Small production workloads (5-10 pods) |
tdx.8xlarge |
Larger workloads |
k3s itself uses ~500MB RAM. Budget the rest for your workloads. A 50GB disk is recommended minimum.
The kubeconfig from step 2 uses the built-in cluster-admin credentials. For programmatic access with limited permissions, create a scoped service account:
kubectl apply -f manifests/rbac.yaml
kubectl create token k3s-admin --duration=8760hUse the resulting token with the API server URL from your kubeconfig.
Restrict pod-to-pod traffic so pods can only receive traffic from Traefik and reach the internet (but not each other):
kubectl apply -f manifests/network-policy.yamlThis applies three policies:
- default-deny: blocks all ingress and egress by default
- allow-internet-egress: allows outbound internet (but blocks pod-to-pod via CIDR exclusions) and DNS
- allow-traefik-ingress: allows inbound traffic only from Traefik in kube-system
This tutorial targets Phala Cloud, but the same compose file works on a self-hosted dstack deployment. Key differences:
DSTACK_APP_IDandDSTACK_GATEWAY_DOMAINare not auto-injected. Add aK3S_TLS_SANenvironment variable to the k3s service manually with your gateway endpoint, and update the--tls-sanargument.- Deploy via the VMM web UI (port 9080) or
vmm-cli.pyinstead of the Phala CLI. - See the dstack deployment guide for self-hosted setup.
| Phase | Duration |
|---|---|
| CVM provision | ~1s |
| SSH available | ~2-3 min |
| k3s node Ready | ~1 min after SSH |
| Wildcard cert issued | ~5-8 min (certbot install + DNS propagation) |
| Total to first HTTPS 200 | ~8-10 min |
Measured on tdx.medium with 50GB disk. The wildcard cert is the bottleneck — certbot installs its dependencies on first boot, then waits 120s for DNS propagation. If your domain has existing CAA records, add another ~3 min for the auto-retry.
kubectl connection refused
The k3s API server may not be ready yet. Wait 3-5 minutes after deploy, then retry. Check that the --tls-san value matches your gateway endpoint.
Wildcard cert not issuing Check dstack-ingress logs:
phala ssh <app-id> -- "docker logs dstack-dstack-ingress-1 2>&1 | tail -30"Common causes: wrong Cloudflare token, domain not on your Cloudflare account, DNS propagation delay, existing CAA records (auto-retried).
IngressRoute returns 404 Traefik may not have picked up the route yet. Wait 10-15 seconds and retry. Verify the IngressRoute exists:
kubectl get ingressroute.traefik.ioNode stuck in NotReady The kmod-installer may have failed. Check k3s logs:
phala ssh <app-id> -- "docker logs dstack-k3s-1 2>&1 | tail -30"k3s/
├── docker-compose.yaml # k3s + kmod-installer + dstack-ingress
├── deploy.sh # One-command deploy + setup
├── README.md
└── manifests/
├── rbac.yaml # Optional: scoped service account
└── network-policy.yaml # Optional: restrict pod traffic