Skip to main content

Deploy from CI with a service account

Deploying by hand from your laptop stops scaling the moment a second person joins. This tutorial replaces the laptop with a pipeline: every merge to main deploys your agent automatically, using a credential that belongs to a machine rather than to a person.

You'll follow the whole chain — an administrator sets up the project, a project admin creates the machine identity and mints its key, and the pipeline uses that key to deploy. You'll finish with a complete, working GitHub Actions workflow and a clear picture of what that key can and cannot do.

Why not just use your own credential?

You could put your own API key in the CI system. Don't. A service account is a machine identity that belongs to exactly one project — think of it as a robot member of the team, with an email-shaped name like ci-deploy@ab12cd.cai.local and a project role of its own.

Your personal keyA service account key
Acts as you, everywhere you have accessActs as the machine, in one project only
Reaches every project you're a member ofReaches nothing outside its project
Dies with your account; a leak exposes all your workRevoked on its own, without touching your access
Can mint more credentialsCannot mint credentials, accounts, or memberships
Nobody can tell your work from the pipeline's in the audit logEvery action is attributed to ci-deploy

That last row matters more than it looks. When the audit log says who deployed at 03:00 on a Sunday, you want it to say ci-deploy, not you.

Before you begin

  • A project to deploy into, and the admin role on it. Creating a service account and minting its keys is project-admin work. If you don't have it, ask your project admin — see Projects and access.
  • Your agent or function code in a Git repository (this tutorial uses GitHub Actions, but any CI system works the same way).
  • platformctl on your machine — see Install the CLI.

1. Create the service account

The console is the shortest path. In your project, go to Security → Service accounts and click Create:

  • Name: ci-deploy — a lowercase label of letters, digits and dashes.
  • Display name: something a human will recognize later, like GitHub Actions.
  • Role: member.

Pick member, not admin. A member can deploy, invoke, read logs and set secrets — everything this pipeline needs. Reserve admin for a pipeline that genuinely has to do irreversible things.

The same thing through the API, if you prefer scripting it:

export API=http://localhost:8080 # your CAI_API endpoint, or a port-forward
export CAI_TOKEN="your own API key or session token"
export PROJECT_ID="the ID column from: platformctl projects list"

curl -sX POST "$API/v1/projects/$PROJECT_ID/service-accounts" \
-H "Authorization: Bearer $CAI_TOKEN" -H "Content-Type: application/json" \
-d '{"name":"ci-deploy","display_name":"GitHub Actions","role":"member"}'

You should see:

{"service_account":{"id":"...","name":"ci-deploy",
"email":"ci-deploy@ab12cd.cai.local","display_name":"GitHub Actions",
"role":"member","disabled":false,"created_at":"..."},
"note":"create a key for it at POST .../ci-deploy/keys"}

The email is derived from the name and your project's short id, and it can never be changed.

Names are reserved forever

Delete a service account and its name stays taken:

that name is taken. Service account names are reserved permanently, including after deletion, so that a new principal can never inherit an old one's grants and audit history

This is deliberate. A recycled name would let a brand-new identity silently inherit an old one's history. Name yours for its job (ci-deploy, nightly-reindex), not with a number you'll want to reuse.

2. Mint a key

On the service account's detail page, click Create key. Give it a display name (gha-main) and an expiry.

Choose 90 days rather than "never". A key with an end date forces a rotation habit; a key that never expires outlives the person who created it. The allowed values are 0 for never, or 1 to 3650 days — anything else is refused:

expires_in_days must be between 1 and 3650, or 0 for a key that does not expire

Via the API:

curl -sX POST "$API/v1/projects/$PROJECT_ID/service-accounts/ci-deploy/keys" \
-H "Authorization: Bearer $CAI_TOKEN" -H "Content-Type: application/json" \
-d '{"display_name":"gha-main","expires_in_days":90}'

You should see:

{"key":{"id":"...","key_id":"...","display_name":"gha-main","kind":"...",
"created_at":"...","expires_at":"...","live":true},
"secret":"cai_xxxxxxxxxxxx_yyyyyyyy...",
"note":"copy this now - only a hash is stored, so it cannot be shown again. If it is lost, revoke this key and create another."}
The secret appears exactly once

Only a hash of the key is stored, so the platform cannot show it to you a second time — not to you, not to a platform administrator, not to support. In the console, the dialog's Close button stays disabled until you tick "I have copied it", on purpose.

If you lose it: revoke that key and mint another. That's the whole recovery procedure.

Test it before it goes anywhere near CI:

export CAI_TOKEN=cai_xxxxxxxxxxxx_yyyyyyyy
platformctl whoami

You should see:

ci-deploy@ab12cd.cai.local role=user (credential: $CAI_TOKEN)

The credential: note tells you which source the CLI actually used. $CAI_TOKEN beats every other credential, including a cached login — which is exactly the behavior you want in CI.

3. Store the key in your CI system

In GitHub: Settings → Secrets and variables → Actions.

NameKindValue
CAI_TOKENSecretThe cai_..._... string you just copied
CAI_APISecretThe API endpoint your administrator gave you
CAI_PROJECTVariableYour project slug, e.g. ml-team

The project slug isn't sensitive, so it can be a plain variable. The token is; GitHub masks secrets in logs, but never echo it yourself.

4. Make sure the runner can actually reach the platform

This is the step people skip, and it is the one that fails.

Alpha honesty: there is no public API hostname

The platform's management API is not published on the internet during alpha. A stock GitHub-hosted runner sits outside your network and cannot reach it. You have two honest options:

  1. An admin-provided endpoint. If your administrator has exposed the API on a hostname your runners can resolve, put it in CAI_API and you're done.
  2. A self-hosted runner inside the network. Give the runner a KUBECONFIG for the cluster and set no CAI_API at all — platformctl opens its own background kubectl port-forward tunnel to the API. This is the same fallback it uses on your laptop.

If neither is true yet, ask your administrator which one they intend to support. Everything else on this page works identically either way.

You'll know you got it wrong from this error, which names the credential the CLI tried:

agent-engine-api returned 401: ... [platformctl credential: $CAI_TOKEN; run `platformctl login` or set $CAI_TOKEN]

or, if the tunnel is the problem:

auto port-forward failed (pass --api or set CAI_API instead): ...

5. The workflow file

Save this as .github/workflows/deploy.yml. It builds the CLI, proves who it is, deploys, checks the result, and smoke-tests the deployment.

name: Deploy agent on merge

on:
push:
branches: [main]

jobs:
deploy:
runs-on: ubuntu-latest
env:
CAI_TOKEN: ${{ secrets.CAI_TOKEN }}
CAI_API: ${{ secrets.CAI_API }}
CAI_PROJECT: ${{ vars.CAI_PROJECT }}
steps:
- name: Check out this repository
uses: actions/checkout@v4

# No prebuilt platformctl downloads exist during alpha, so build it.
# Replace the repository below with wherever your org keeps the platform source.
- name: Check out the platform source
uses: actions/checkout@v4
with:
repository: your-org/crusoe-ai-platform
path: platform
token: ${{ secrets.PLATFORM_REPO_TOKEN }}

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: platform/cli/platformctl/go.mod

- name: Build platformctl
run: |
cd platform/cli/platformctl
go build -o "$GITHUB_WORKSPACE/bin/platformctl" .
echo "$GITHUB_WORKSPACE/bin" >> "$GITHUB_PATH"

- name: Confirm the credential
run: platformctl whoami

- name: Deploy the agent
run: platformctl deploy ./agent --name research-buddy

- name: Verify it is ready
run: |
status=$(platformctl status research-buddy -o json | jq -r .status)
echo "status=$status"
test "$status" = "ready"

- name: Smoke test
run: platformctl invoke research-buddy "ping"

A few things about that file are deliberate:

  • whoami runs first. One cheap step turns "the deploy mysteriously 401'd" into "the credential is wrong", before you've uploaded anything.
  • deploy blocks until the build finishes. It uploads the folder, prints build <id> accepted, then polls the agent's status every 2 seconds for up to 5 minutes, printing each transition.
  • The status check is explicit. platformctl's exit codes aren't part of the documented contract yet, so assert on the reported status rather than trusting the shell. This step fails the job if the agent isn't ready.
  • CAI_PROJECT is set once, for the whole job. Project resolution order is --project, then $CAI_PROJECT, then the CLI's saved default. CI has no saved default, so the environment variable is doing real work.

For a function, swap one line — platformctl functions deploy ./handler-dir --name my-function — and the rest is unchanged.

6. Watch the first run

Merge to main and open the job log. The deploy step should look like this:

packaging ./agent...
uploading research-buddy (4.2 KiB, framework=adk)...
build b-1a2b3c accepted
status: -> building
status: building -> deploying
status: deploying -> ready
research-buddy is ready at http://research-buddy.cai-p-ab12cd.svc.cluster.local

If it ends in failed, the CLI tells you where to look:

research-buddy failed to build/deploy (see `platformctl logs research-buddy`)

Add platformctl logs research-buddy --history as a step that runs on failure, and the build error lands in your CI log where you'll actually read it.

Keep the uploaded folder clean

platformctl deploy uploads everything in the directory you point it at. There is no ignore file. A stray .venv/, node_modules/, or .git directory ships to the platform and can blow through the 100 MiB upload cap. Keep your agent's directory to source code and requirements.txt.

7. Rotate and revoke

Rotation is three steps, in this order, with no downtime:

  1. Mint a second key on the same service account (gha-main-2).
  2. Update the CAI_TOKEN secret in GitHub.
  3. Revoke the old key.

Revocation takes effect immediately:

effective immediately - the next request presenting this key is refused

To retire the whole identity, delete the service account. Every key it held is revoked in the same transaction, and the name stays reserved:

every key it held was revoked in the same transaction; the name stays reserved

What this key can never do

The fences below are structural, not settings — you cannot turn them off, and neither can an attacker holding the key.

  • It cannot mint credentials. Not keys, not accounts, not project memberships:

    a service account cannot create or manage credentials. Sign in as a user (or use your own API key) to issue keys - otherwise a leaked key could mint replacements and revoking it would achieve nothing

    That is the whole point: revoking a leaked key actually ends the incident, because the key could not have made copies of itself.

  • It cannot leave its project. Its authority is one project's, on the contents axis only. It holds no power over the project object itself — it cannot rename the project, delete it, or change who's a member.

  • It cannot outlive its grant. Authority is re-read from the database on every single request. Remove the service account's role and the key stops working on its next call — no waiting for a token to expire.

  • It leaves a trail. Every step you took is in the project's audit log, readable by any project member: serviceaccount.create, serviceaccount.key.create, agent.deploy, and later serviceaccount.key.revoke. See Quotas and audit log.

If CI starts getting 404s, check the grant

A request against a project the caller holds no grant on returns 404 "not found", never 403. That's an anti-enumeration rule: it stops an unknown credential from discovering which projects exist. So a sudden run of 404s from a pipeline that used to work usually means the service account's role was removed — not that the project vanished.

Security checklist

Do thisWhy
One service account per pipelineRevoking one pipeline's access never breaks another's
Role member unless you need moreadmin adds the irreversible operations you don't want automated by accident
Expiry of 90 days, not neverForces a rotation you'd otherwise never do
Store the key as a CI secret, never in the repoA key in Git is a key in every fork and every clone
Never echo "$CAI_TOKEN"Masking in logs is a safety net, not a plan
Read the audit log after your first deployConfirms the pipeline is attributed to the machine, not to you
Rotate by minting, then switching, then revokingZero-downtime, and you can always fall back mid-rotation

Next steps