Skip to main content
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:
SecretDescription
PHALA_CLOUD_API_KEYYour Phala Cloud API key
DOCKER_REGISTRY_USERNAMEContainer registry username
DOCKER_REGISTRY_PASSWORDRegistry 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.
1

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}`);
}
2

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.

Terraform in CI

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.