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 key | A service account key |
|---|---|
| Acts as you, everywhere you have access | Acts as the machine, in one project only |
| Reaches every project you're a member of | Reaches nothing outside its project |
| Dies with your account; a leak exposes all your work | Revoked on its own, without touching your access |
| Can mint more credentials | Cannot mint credentials, accounts, or memberships |
| Nobody can tell your work from the pipeline's in the audit log | Every 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).
platformctlon 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.
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."}
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.
| Name | Kind | Value |
|---|---|---|
CAI_TOKEN | Secret | The cai_..._... string you just copied |
CAI_API | Secret | The API endpoint your administrator gave you |
CAI_PROJECT | Variable | Your 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.
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:
- An admin-provided endpoint. If your administrator has exposed the API on a hostname your runners can resolve, put it in
CAI_APIand you're done. - A self-hosted runner inside the network. Give the runner a
KUBECONFIGfor the cluster and set noCAI_APIat all —platformctlopens its own backgroundkubectl port-forwardtunnel 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:
whoamiruns first. One cheap step turns "the deploy mysteriously 401'd" into "the credential is wrong", before you've uploaded anything.deployblocks until the build finishes. It uploads the folder, printsbuild <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'tready. CAI_PROJECTis 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.
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:
- Mint a second key on the same service account (
gha-main-2). - Update the
CAI_TOKENsecret in GitHub. - 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 nothingThat 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 laterserviceaccount.key.revoke. See Quotas and audit log.
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 this | Why |
|---|---|
| One service account per pipeline | Revoking one pipeline's access never breaks another's |
Role member unless you need more | admin adds the irreversible operations you don't want automated by accident |
| Expiry of 90 days, not never | Forces a rotation you'd otherwise never do |
| Store the key as a CI secret, never in the repo | A 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 deploy | Confirms the pipeline is attributed to the machine, not to you |
| Rotate by minting, then switching, then revoking | Zero-downtime, and you can always fall back mid-rotation |
Next steps
- Service accounts and API keys — the console flows and every rule in one page.
- API authentication — how session tokens, API keys, and workload keys differ.
- CLI reference: platform and auth —
login,whoami,config, and credential resolution. - Deploy an agent — what actually happens between upload and
ready. - Quotas and audit log — where every action your pipeline takes is recorded.