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 CI pipeline: the system that builds and deploys your code automatically whenever you merge. Every merge to main will deploy your agent, using a credential that belongs to a machine rather than to a person.
You'll follow the whole chain. A project admin creates the machine identity and mints its key, then the pipeline uses that key to deploy. You'll finish with a complete, working CI 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. Use a service account: 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 CI, but any CI system works the same way).
-
Your project connected to Crusoe Cloud. Everything you deploy is built into a container image, and that image is stored in a repository in your own Crusoe Cloud Registry — so a project with no Crusoe Cloud credential is refused before anything is built. Connecting is a one-time, project-admin step, and a project that is already connected needs nothing new. See connect your Crusoe Cloud account.
-
platformctltab: the CLI on your machine, signed in — see Install the CLI. -
curltab: a token, the API address, and your project's UUID in your shell:export CAI_API=https://api.codyhill.devexport CAI_TOKEN="<your session token or API key>"export CAI_PROJECT="<the ID column of: platformctl projects list>"The project-scoped routes address a project by its UUID, never by its slug — a slug in the path is a
404.platformctlis the looser of the two: it accepts a UUID, a slug, a short id, or a name. -
Console tab: nothing else. A browser is enough.
Every step the platform performs is shown three ways — platformctl, curl, and the console. Pick whichever tab suits you and stay in it; your choice follows you across every page in these docs. The pipeline file itself is CI configuration rather than platform work, so it appears once.
1. Create the service account
Name it for its job and give it the narrowest role that works.
- platformctl
- curl
- Console
platformctl service-accounts create ci-deploy \
--display-name "CI" --role member
You should see:
created_at 2026-08-16T09:14:02Z
disabled false
display_name CI
email ci-deploy@ab12cd.cai.local
id 0f7a5c21-3b9d-4e17-8c2a-5d6e7f801234
name ci-deploy
note create a key for it at POST .../service-accounts/ci-deploy/keys
project_id 3b9d1c40-7e52-4a88-9f13-2c6b5a0d8e77
role member
curl -sX POST "$CAI_API/v1/projects/$CAI_PROJECT/service-accounts" \
-H "Authorization: Bearer $CAI_TOKEN" -H "Content-Type: application/json" \
-d '{"name":"ci-deploy","display_name":"CI","role":"member"}'
You should see (HTTP 201):
{"service_account":{"id":"...","name":"ci-deploy",
"email":"ci-deploy@ab12cd.cai.local","display_name":"CI",
"role":"member","disabled":false,"created_at":"..."},
"note":"create a key for it at POST .../ci-deploy/keys"}
description is accepted as an alias for display_name, and role defaults to member if you leave it out.
Go to Security → Service accounts and click Create service account:
- Name:
ci-deploy— a lowercase label of letters, digits and hyphens. - Description: something a human will recognize later, like
CI. - Project role:
member. It sits behind Advanced, becausememberis already the default.
You should see: the new account's page, with a Create key button on it. An account with no key cannot do anything yet.
Pick member, not admin. A member can deploy agents and functions, invoke them, read their logs, and set their secrets. That is everything this pipeline needs. Reserve admin for a pipeline that genuinely has to do irreversible things — platformctl serverless delete is one of the few.
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
Creating the account did not create a credential. That is a second step, and it is the only moment the secret exists.
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
- platformctl
- curl
- Console
platformctl service-accounts keys create ci-deploy \
--display-name gha-main --expires-in-days 90
You should see:
key id: 7k3m9qd0f2ab
id: 2c81a4f7-6b90-4d33-a1e5-8f0c7b2d9e41 (pass this to 'service-accounts keys revoke')
expires: 2026-11-14T09:16:33Z
cai_xxxxxxxxxxxx_yyyyyyyy...
copy this now - only a hash is stored, so it cannot be shown again. If it is lost, revoke this key and create another.
Two ids, and they do different jobs. key id is the public half that appears in audit lines. id is the row id the revoke route takes — though platformctl accepts either and resolves it for you.
curl -sX POST "$CAI_API/v1/projects/$CAI_PROJECT/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 (HTTP 201):
{"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."}
Keep the id, not just the key_id: the revoke route takes the id in its path and answers 400 to anything that is not a UUID.
On the service account's page, click Create key:
- Description:
gha-main. Required here — a nameless credential in a list of six is one nobody dares revoke. - Expires in (days):
90. Blank or0means it never expires.
You should see: the secret, once, in a dialog whose Close button stays disabled until you tick I have copied this key somewhere safe.
Only a hash of the key is stored, so the platform cannot show it a second time. Not to you, not to a platform administrator, not to support.
If you lose it: revoke that key and mint another. That's the whole recovery procedure.
Test the key before it goes anywhere near CI
There is no console tab here — a service account's key is a credential you present, not an identity you can sign into the console as.
- platformctl
- curl
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. That is exactly the behavior you want in CI.
export CAI_TOKEN=cai_xxxxxxxxxxxx_yyyyyyyy
curl -s -H "Authorization: Bearer $CAI_TOKEN" "$CAI_API/v1/auth/me"
You should see:
{
"email": "ci-deploy@ab12cd.cai.local",
"role": "user",
"must_change_password": false,
"project_id": "3b9d1c40-7e52-4a88-9f13-2c6b5a0d8e77",
"project_slug": "ml-team",
"project_short": "ab12cd"
}
Worth noting for a pipeline that talks to the API directly: project_id appears only for a machine principal, and it is how a service account discovers the UUID that every project-scoped route needs. Listing projects is a person's endpoint, so this is the one place the key can learn where it lives.
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 — https://api.codyhill.dev unless you are targeting a different deployment |
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 sensitive. GitHub masks secrets in logs, but never echo it yourself.
A slug is enough here because the pipeline drives platformctl, which resolves a slug to an id for you. A pipeline that calls the API with curl needs the UUID instead — read it from GET /v1/auth/me, as above, rather than pasting it in.
4. Make sure the runner can actually reach the platform
This is the step people skip, and it is the one that fails.
The API is published on the internet at https://api.codyhill.dev — see the API overview. A stock GitHub-hosted runner can reach it, so you need no self-hosted runner and no network setup. platformctl already defaults to that address; the workflow below sets CAI_API from a repository secret anyway, so you can point a branch at a different deployment without editing the file.
The error you will actually hit is a credential one, and it names the source the CLI tried:
workload-api returned 401: ... [platformctl credential: $CAI_TOKEN; run `platformctl login` or set $CAI_TOKEN]
That means the runner reached the platform and was turned away. Check that CAI_TOKEN is set from the repository secret you created in step 3, and that the key has not been revoked.
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
# Build and install the platformctl CLI binary.
# 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: |
state=$(platformctl status research-buddy -o json | jq -r .state)
echo "state=$state"
test "$state" = "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 and printsbuild <id> accepted. Then it asks for the agent's state every 2 seconds for up to 5 minutes, printing each transition. A cold dependency cache can outlast that; setCAI_DEPLOY_TIMEOUT: 10min the job'senvand the CLI waits longer.- The readiness check is explicit.
platformctl's exit codes aren't part of the documented contract yet, so assert on what the API reports rather than trusting the shell. This step fails the job if the agent isn'tready. It readsstate, the agent's own word —building,deploying,ready,failed— so a job log shows which of those it stopped on. Asserting on thereadyboolean instead —test "$(platformctl status research-buddy -o json | jq -r .ready)" = true— is equally valid, and is the portable form:readymeans the same thing on every resource the platform serves, while each resource'sstatevocabulary is its own. 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.
An agent belongs to whoever deployed it, and a member may only manage its own agents. So if you already deployed research-buddy from your laptop, the pipeline's very first run fails with:
workload-api returned 404: unknown agent: research-buddy
That is not a missing agent and not a missing grant — it is somebody else's agent, and the API deliberately refuses to say which. Three ways out, best first: let CI deploy the agent from scratch under a name nobody has used (platformctl delete research-buddy first, if you own it); give the pipeline a name of its own; or grant the service account admin, which reaches every agent in the project and is the reason to think twice.
For a function, swap one line and leave the rest unchanged: platformctl functions deploy ./handler-dir --name my-function.
If you deploy your own container image
A serverless service starts from an image you built and pushed yourself, so the pipeline has one more step before the deploy: build the image and push it to the registry your administrator gave you. Then swap the deploy line for one of these, depending on whether the service already exists.
The first deploy creates the service:
platformctl serverless create checkout-api --image "$IMAGE_REF" --publish
Every later deploy updates it in place, which produces a new revision:
platformctl serverless update checkout-api --image "$IMAGE_REF"
Two things to plan for. create fails once the service exists, so a pipeline that runs on every merge should call update and keep create as a one-time bootstrap step you run by hand. And the flags that replace a whole group — --env, the secret and config-map imports, the compute envelope, and --publish with --publish-host — must be passed complete on every update, or the parts you left out are dropped. Scaling is the exception: --min-scale, --max-scale and --concurrency merge onto what is already set. The full rules are in serverless create and update.
The member role covers both commands. Only platformctl serverless delete needs admin, which is one more reason to leave this service account at member.
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
state: -> building
state: building -> deploying
state: deploying -> ready
research-buddy is ready at https://research-buddy-ab12cd.apps.codyhill.dev
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. --history reads persisted logs, so it still works after the agent has scaled to zero.
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.
Confirm the machine, not you, did the work
This is the payoff for using a service account at all, so check it once.
- platformctl
- curl
- Console
platformctl audit --limit 10
You should see:
TIME ACTOR ACTION TARGET
2026-08-16T09:31:44Z ci-deploy@ab12cd.cai.local agent.deploy research-buddy
2026-08-16T09:16:33Z you@example.com serviceaccount.key.create 7k3m9qd0f2ab
2026-08-16T09:14:02Z you@example.com serviceaccount.create ci-deploy@ab12cd.cai.local
curl -s -H "Authorization: Bearer $CAI_TOKEN" \
"$CAI_API/v1/projects/$CAI_PROJECT/audit?limit=10" \
| jq -r '.entries[] | [.ts, .actor, .action, .target] | @tsv'
Older entries come back with a next_page_token; pass it as page_token to continue.
Go to Security → Audit log. Entries are newest first, with Time, Actor, Action, Target and Details columns, and a filter box that matches on actor or action — type ci-deploy to see only the pipeline's work.
The deploy line should name ci-deploy@ab12cd.cai.local. If it names you, the pipeline is running on your credential and the whole exercise has bought you nothing.
7. Rotate and revoke
Rotation is three steps, in this order, with no downtime: mint a second key on the same service account, update the CAI_TOKEN secret in GitHub, then revoke the old one. Minting first means you can always fall back mid-rotation.
- platformctl
- curl
- Console
platformctl service-accounts keys create ci-deploy \
--display-name gha-main-2 --expires-in-days 90
Update the GitHub secret, then list the keys and revoke the old one:
platformctl service-accounts keys list ci-deploy
platformctl service-accounts keys revoke ci-deploy 7k3m9qd0f2ab
You should see:
revoked key 7k3m9qd0f2ab on ci-deploy - the next request presenting it is refused
revoke takes either id from the list — the ID column or the KEY_ID column.
curl -sX POST "$CAI_API/v1/projects/$CAI_PROJECT/service-accounts/ci-deploy/keys" \
-H "Authorization: Bearer $CAI_TOKEN" -H "Content-Type: application/json" \
-d '{"display_name":"gha-main-2","expires_in_days":90}'
Update the GitHub secret, then revoke the old key by its id — the UUID, not the key_id:
curl -sX DELETE \
"$CAI_API/v1/projects/$CAI_PROJECT/service-accounts/ci-deploy/keys/2c81a4f7-6b90-4d33-a1e5-8f0c7b2d9e41" \
-H "Authorization: Bearer $CAI_TOKEN"
You should see:
{"revoked":"7k3m9qd0f2ab","note":"effective immediately - the next request presenting this key is refused"}
To find the id again, list the account's keys: GET .../service-accounts/ci-deploy/keys. The secret is not in that response and never will be.
On the service account's page, click Create key for the replacement. Update the GitHub secret. Then click Revoke on the old key's row and confirm.
To retire the whole identity, delete the service account. Every key it held is revoked in the same transaction, and the name stays reserved.
- platformctl
- curl
- Console
platformctl service-accounts delete ci-deploy
You should see:
deleted service account ci-deploy (its keys were revoked; the name stays reserved)
curl -sX DELETE "$CAI_API/v1/projects/$CAI_PROJECT/service-accounts/ci-deploy" \
-H "Authorization: Bearer $CAI_TOKEN"
You should see:
{"deleted":true,"service_account":"ci-deploy@ab12cd.cai.local","note":"every key it held was revoked in the same transaction; the name stays reserved"}
On the service account's page, click Delete account and confirm.
Deleting the account does not delete what it deployed. The agents it owns keep running, and a member who is not their owner cannot manage them afterwards — so hand the agents over, or delete them, before you retire the identity that owns them.
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 covers one project, on the contents axis only. It holds no power over the project object itself: it cannot rename the project, delete it, or change who is 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. There is 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 404 "not found" is the platform's answer to both "there is no such thing" and "it isn't yours", never a 403. That is an anti-enumeration rule: it stops an unknown credential from discovering what exists.
So a sudden run of 404s from a pipeline that used to work has two likely causes, and neither of them is a vanished project. Either the service account's role was removed — check with platformctl audit — or somebody redeployed the agent under their own account and took ownership of it. unknown agent: <name> in the message points at the second.
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 |
| Let CI deploy the agents it manages | A member can only manage what it owns |
| 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.
Go deeper
These advanced guides pick up where the quickstarts stop, each exercising a different slice of the platform:
| Guide | Framework / language |
|---|---|
| Multi-step research agent | LangGraph |
| Editorial pipeline with a crew | CrewAI |
| Support agent over your own docs | ADK |
| Document ingestion pipeline | Python |
| Webhook fan-out, exactly once | Node.js |
| Scheduled reconciliation job | Go |
| Object-store ETL with move-after-read | Ruby |