Automating deployments means every push to your main branch updates your CVM without manual intervention. This guide covers four approaches: the Phala CLI in GitHub Actions, the JS SDK in GitHub Actions, a GitLab CI pipeline, and Terraform in CI.
Prerequisites
- A Git repository with your application code and a
docker-compose.yml
- A Phala Cloud account with an API key
- A container registry account (Docker Hub, GHCR, or similar)
Secrets Management
Regardless of which CI platform you use, store your Phala Cloud API key as a secret. Never hardcode it in workflow files.
GitHub: Go to Settings > Secrets and variables > Actions and add:
| Secret | Description |
|---|
PHALA_CLOUD_API_KEY | Your Phala Cloud API key |
DOCKER_REGISTRY_USERNAME | Container registry username |
DOCKER_REGISTRY_PASSWORD | Registry password or access token |
GitLab: Go to Settings > CI/CD > Variables and add the same values as masked variables.
Rotate your API key immediately if it’s ever exposed in logs or committed to a repository. Generate a new one from Settings > API Keys in the Phala Cloud dashboard.
GitHub Actions with CLI
This is the simplest approach. The workflow builds your Docker image, pushes it to a registry, and deploys with phala deploy.
name: Deploy to Phala Cloud
on:
push:
branches: [main]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to Docker Registry
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
- name: Build and Push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ secrets.DOCKER_REGISTRY_USERNAME }}/my-app:${{ github.sha }}
- name: Update compose with image tag
run: |
sed -i "s|\${DOCKER_IMAGE}|${{ secrets.DOCKER_REGISTRY_USERNAME }}/my-app:${{ github.sha }}|g" docker-compose.yml
- name: Install Phala CLI
run: npm install -g phala
- name: Deploy to Phala Cloud
env:
PHALA_CLOUD_API_KEY: ${{ secrets.PHALA_CLOUD_API_KEY }}
run: phala deploy -c docker-compose.yml -n my-tee-app --wait
Your docker-compose.yml should reference the image variable:
services:
app:
image: ${DOCKER_IMAGE}
ports:
- "80:80"
The CLI detects existing CVMs by name. If my-tee-app already exists, it updates in place. Otherwise, it creates a new one.
GitHub Actions with JS SDK
If you need more control over the deployment (conditional logic, custom error handling, multi-step provisioning), use the SDK directly in a Node.js script.
Create a deploy script
Add scripts/deploy.mjs to your repository:import { createClient } from "@phala/cloud";
const client = createClient({
apiKey: process.env.PHALA_CLOUD_API_KEY,
});
const imageTag = process.env.IMAGE_TAG;
const appName = process.env.APP_NAME || "my-tee-app";
const compose = `
services:
app:
image: ${imageTag}
ports:
- "80:80"
`;
// Check if CVM already exists
const cvms = await client.getCvmList();
const existing = cvms.items.find((c) => c.name === appName);
if (existing) {
// Update existing CVM
await client.updateDockerCompose({
id: existing.id,
dockerCompose: compose,
});
await client.restartCvm({ id: existing.id });
console.log(`Updated CVM: ${existing.id}`);
} else {
// Provision new CVM
const provision = await client.provisionCvm({
name: appName,
composeFile: { dockerComposeFile: compose },
vcpu: 2,
memory: 4096,
diskSize: 20,
});
const cvm = await client.commitCvmProvision({
appId: provision.appId,
composeHash: provision.composeHash,
transactionHash: provision.transactionHash,
});
console.log(`Created CVM: ${cvm}`);
}
Create the workflow
name: Deploy with SDK
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Log in to Docker Registry
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
- name: Build and Push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ secrets.DOCKER_REGISTRY_USERNAME }}/my-app:${{ github.sha }}
- name: Install dependencies
run: npm install @phala/cloud
- name: Deploy
env:
PHALA_CLOUD_API_KEY: ${{ secrets.PHALA_CLOUD_API_KEY }}
IMAGE_TAG: ${{ secrets.DOCKER_REGISTRY_USERNAME }}/my-app:${{ github.sha }}
APP_NAME: my-tee-app
run: node scripts/deploy.mjs
GitLab CI
GitLab CI uses a similar pattern. Define a .gitlab-ci.yml in your repository root:
stages:
- build
- deploy
variables:
IMAGE_TAG: "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
build:
stage: build
image: docker:24
services:
- docker:24-dind
script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
- docker build -t "$IMAGE_TAG" .
- docker push "$IMAGE_TAG"
deploy:
stage: deploy
image: node:20
script:
- npm install -g phala
- sed -i "s|\${DOCKER_IMAGE}|$IMAGE_TAG|g" docker-compose.yml
- phala deploy -c docker-compose.yml -n my-tee-app --wait
variables:
PHALA_CLOUD_API_KEY: "$PHALA_CLOUD_API_KEY"
only:
- main
GitLab’s built-in container registry works well here. The $CI_REGISTRY_* variables are available automatically in every pipeline.
For infrastructure-as-code workflows, run Terraform in your pipeline. This is especially useful when you manage replicas, instance types, or environment variables declaratively.
name: Deploy with Terraform
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.5"
- name: Terraform Init
run: terraform init
working-directory: ./infra
- name: Terraform Apply
env:
PHALA_CLOUD_API_KEY: ${{ secrets.PHALA_CLOUD_API_KEY }}
run: terraform apply -auto-approve -var="image_tag=${{ github.sha }}"
working-directory: ./infra
Your infra/main.tf would look something like this:
variable "image_tag" {
type = string
}
resource "phala_app" "web" {
name = "my-tee-app"
size = "tdx.medium"
region = "US-WEST-1"
image = "dstack-dev-0.5.7-9b6a5239"
disk_size = 40
replicas = 1
docker_compose = <<-YAML
services:
app:
image: myregistry/my-app:${var.image_tag}
ports:
- "80:80"
YAML
wait_for_ready = true
wait_timeout_seconds = 900
}
Store your Terraform state in a remote backend (S3, GCS, or Terraform Cloud) so that CI runs can access the same state file. Without remote state, each pipeline run would try to create a new CVM instead of updating the existing one.
Verify Deployment
After your pipeline completes, confirm the CVM is running:
# CLI
phala cvms get my-tee-app
# Or check the dashboard
# https://cloud.phala.com/dashboard
Troubleshooting
Authentication errors: Make sure PHALA_CLOUD_API_KEY is set correctly in your CI secrets. Test locally with phala status to verify the key works.
Build failures: Ensure your Dockerfile builds locally with docker build . before pushing to CI.
Deploy timeouts: If --wait times out, the CVM may still be starting. Check the dashboard or run phala cvms get to see the current status. Increase the timeout with --wait-timeout if your app takes longer to boot.
Image pull errors: Verify the image tag in your compose file matches what was pushed to the registry. For private registries, set DSTACK_DOCKER_USERNAME and DSTACK_DOCKER_PASSWORD as encrypted secrets on the CVM.