Deploy a service
In this guide you deploy a container image as a serverless service, watch it become ready, and learn exactly how much of your project's quota each deploy consumes. It takes about 5 minutes. It also costs nothing while the service is idle, because scale-to-zero means no compute charge until the first request arrives.
Before you begin
- An account and a project. Ask your administrator for an account or an invitation link — there is no self-service sign-up. See projects and access.
- A container image, which is a packaged copy of your app and everything it needs to run. Serverless deploys images, not source code. To deploy from source instead, use Functions or Agents, which build images for you. Your image must listen for HTTP on a port; the default is 8080. Is your image private and stored in Crusoe Container Registry? Then map the project to Crusoe Cloud first — see where your image can come from.
- For the API path: a credential. That is either an API key (see service accounts and API keys) or a 12-hour session token from
platformctl login. You send it as a bearer token — the wordBearer, a space, then the credential, in theAuthorizationheader. Details in API authentication.
A registry is a server that stores container images. Reference your image either from a public registry, or from a private Crusoe Container Registry repository — including the repository the platform built into when it built an image for you. See where your image can come from.
Anything else is accepted at deploy time and then fails minutes later with an image pull error. The machines that run your container cannot reach it.
Where your image can come from
Serverless pulls from two kinds of registry: public ones, which need no setup, and your own private Crusoe Container Registry, which needs the project connected to Crusoe Cloud first. Anything the platform built for you is already in the second kind.
| Source | An image reference looks like | How an image gets there |
|---|---|---|
| Images the platform built for you | registry.us-east1-a.ccr.crusoecloudcompute.com/cai-ab12cd-checkout-api:c5c2f25d | You do not push these. When the platform builds from your source — see Functions, Agents and deploy from source — it writes the image into a repository in your own Crusoe Cloud Registry, named cai-<project-short>-<workload>. Reference it when you want to redeploy an image the platform already built. It is a CCR reference, so the row below applies to it |
| A public registry | docker.io/library/nginx:1.27 | You push it wherever you normally do. No setup here, but the image must be readable without a password |
| Crusoe Container Registry (CCR) — your private images | registry.us-east1-a.ccr.crusoecloudcompute.com/checkout-api.7dhg29ls:v1 | Map the project to Crusoe Cloud first, then push the image to your repository |
Private images: map your project to Crusoe Cloud first
A private registry asks for a password, and the platform has no way to guess yours. Mapping your project to a Crusoe Cloud account is how it gets one.
Do this once, in the console. Open your project, choose Project Settings, and enter a Crusoe Cloud access key ID and secret key. The same thing over the API is PUT /v1/projects/{projectID}/crusoe-cloud. That route lives on the platform API, so use $CAI_API, not $CAI_SERVERLESS_API. The Crusoe Cloud integration page walks through it step by step.
Mapping does two things:
- It stores your Crusoe Cloud credential for the project.
- It creates a hidden image-pull credential in your project, named
ccr-pull. The platform builds this credential for you. The username is your Crusoe account email. The password is a short-lived registry token that can do nothing but pull images. Your access key is never used as a registry password, because CCR does not accept it as one.
From then on, every deploy and every edit runs the same check by itself. It asks two questions. Is this project mapped? And is the image's host one that the ccr-pull credential can sign in to?
If both answers are yes, the platform attaches the credential for you. It does that by pointing the service at a ccr-pull service account that carries it. If either answer is no, it attaches nothing and uses your image reference exactly as written. That is why a public image is never touched.
You never create, name, or reference that credential yourself. There is no field on this API for supplying your own registry password.
If the project is not mapped, a CCR deploy is still accepted with 202. No credential is attached, and nothing in the response mentions the mapping. Minutes later the image pull fails, and the service never reaches ready. Map the project before you deploy a CCR image.
Already deployed, and mapped afterwards? PATCH the service so the check runs again and the new revision gets the credential. Re-sending the same image is enough.
Two more sharp edges before you rely on CCR:
- The
ccr-pullcredential is built only during the mapping call. A repository you create later is not added to it. - The token inside the credential expires, which breaks pulls that had worked for weeks.
The fix for both is the same one line: map the project again. Known issues and the Crusoe Cloud integration page have the details.
One last rule. If you set service_account_name yourself, the platform leaves your choice alone and does not attach the CCR credential. Leave that field unset for CCR images. The serverless API reference covers both preconditions in full.
Deploy the service
Only two things are ever required: a name and an image. A name is 1–52 characters of lowercase letters, digits, and dashes, starting with a letter and ending with a letter or digit, and it cannot be changed later. Everything else has a sensible default.
- platformctl
- curl
- Console
platformctl serverless create checkout-api \
--image registry.us-east1-a.ccr.crusoecloudcompute.com/cai-ab12cd-checkout-api:c5c2f25d \
--env LOG_LEVEL=info
--envAn --env value lands in your shell history and shows up in the machine's list of running processes. Use --env-from-secret, which names the secret and nothing else.
Then watch it come up:
platformctl serverless list
You should see:
NAME STATE PUBLISHED URL
checkout-api ready no http://<private-hostname>
platformctl serverless get checkout-api -o json prints the full service object, exactly as the API returns it.
The CLI covers the whole lifecycle: create, update, delete, get, list, logs, metrics, revisions, set-traffic, spec, and triggers. It needs a project set, which you do with --project, the $CAI_PROJECT variable, or platformctl config set-project.
Serverless is project-scoped, so the project UUID goes in the path:
export CAI_SERVERLESS_API=https://api.codyhill.dev
export CAI_TOKEN=cai_... # your API key or session token
export CAI_PROJECT=<your-project-uuid> # from: platformctl projects list
curl -sS -X POST "$CAI_SERVERLESS_API/v1/projects/$CAI_PROJECT/services" \
-H "Authorization: Bearer $CAI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"name": "checkout-api",
"image": "registry.us-east1-a.ccr.crusoecloudcompute.com/cai-ab12cd-checkout-api:c5c2f25d",
"env": {"LOG_LEVEL": "info"}
}'
You should see:
HTTP 202 Accepted
{
"name": "checkout-api",
"image": "registry.us-east1-a.ccr.crusoecloudcompute.com/cai-ab12cd-checkout-api:c5c2f25d",
"scaling": {"min_scale": 0, "max_scale": 10, "container_concurrency": 80},
"publish": {"enabled": false, "host": ""},
"timeout_seconds": 300,
"state": "pending",
"ready": false
}
The response is 202 Accepted, not 201 Created. That is deliberate honesty: the platform took your request, but nothing is serving yet. state and ready sit at the top level of the service, beside the settings you sent — there is no status object to open. Ask for the service repeatedly until ready is true:
curl -sS -H "Authorization: Bearer $CAI_TOKEN" \
"$CAI_SERVERLESS_API/v1/projects/$CAI_PROJECT/services/checkout-api" \
| jq '.state, .ready, .internal_url'
You should see:
"ready"
true
"http://<private-hostname>"
Request bodies to this API are capped at 256 KiB. The full field reference is in the serverless API reference.
- Sign in to the web console, open your project, and choose Serverless.
- Click Deploy service. The dialog asks for three things up front:
- Name — see the naming rules above.
- Container image — for example
registry.us-east1-a.ccr.crusoecloudcompute.com/cai-ab12cd-checkout-api:c5c2f25d. If your project is mapped to Crusoe Cloud, a Pick a Crusoe Cloud repository dropdown appears above this field and fills it in for you. You can always type the reference by hand instead. - Trigger type — leave this as "HTTP only" unless you want the service fired on a schedule or by an event.
- Optionally expand Advanced options. Everything there has a default, so you can skip it:
- Container port (default 8080) and protocol — HTTP/1.1, or
h2cif your service speaks gRPC (a binary protocol that rides on HTTP/2 without TLS). - Environment variables, one
KEY=valueper line. - CPU and memory requests and limits.
- Minimum instances (default 0), maximum instances (default 10), and concurrent requests per instance (default 80).
- Publish to the internet, a checkbox that is off by default.
- Container port (default 8080) and protocol — HTTP/1.1, or
- Click through the Review & deploy confirmation. It lists every value, including the defaults it filled in for you, so there are no surprises. Confirm to deploy.
- Watch the state badge on the list page turn from pending to ready. The page refreshes itself.
Leave a field out and the platform fills in a default, then echoes it back so you can see what you got:
| Field | Default |
|---|---|
port | 8080 |
| CPU / memory request | 250m / 512Mi |
| CPU / memory limit | 1 CPU / 512Mi |
scaling.min_scale / max_scale / container_concurrency | 0 / 10 / 80 |
publish.enabled | false |
timeout_seconds | 300 |
One more thing happens on every deploy: the platform injects the environment variable CRUSOE_REQUEST_TIMEOUT_SECONDS into your container, matching timeout_seconds.
Update a service
Every edit creates a new revision.
- platformctl
- curl
- Console
platformctl serverless update checkout-api --image registry.us-east1-a.ccr.crusoecloudcompute.com/cai-ab12cd-checkout-api:9f31ab02
--env on update replaces the whole environmentUnlike the individual fields, --env is not a merge on update — it replaces everything. Resupply every variable you want to keep.
PATCH the same path with only the fields you want to change:
curl -sS -X PATCH "$CAI_SERVERLESS_API/v1/projects/$CAI_PROJECT/services/checkout-api" \
-H "Authorization: Bearer $CAI_TOKEN" -H 'Content-Type: application/json' \
-d '{"image": "registry.us-east1-a.ccr.crusoecloudcompute.com/cai-ab12cd-checkout-api:9f31ab02"}'
Open the service and click Edit. The dialog is the deploy dialog again, prefilled with the service's current values.
scaling follows the same rule as every other field in a PATCH. Send a scaling block and any sub-field you leave out keeps the service's current value, not a platform default. So a PATCH carrying only {"min_scale": 2} leaves max_scale and container_concurrency exactly as they were.
Create behaves differently, because a brand-new service has no current scaling to keep. There, any sub-field you omit gets the platform default of 0 / 10 / 80.
Read whether it is serving
Everything the platform observed about your service sits at the top level of the object, alongside the settings you sent. There is no status block to open — the readiness answer is spelled the same way here as on every other resource on the platform.
Four things tell you almost everything:
state— the one-word summary, always lowercase.pendingmeans the platform is still working toward your spec.readymeans it is serving as requested.not_readymeans it is not serving.degradedmeans it is serving, but not with every setting you asked for.invalidmeans the spec was rejected. This is the word to show a person.ready— a plain true/false, and the field to branch on in a script. It istrueonly forstate: ready. When it isfalse,messagecarries one sentence saying why; when it istrue,messageis left out entirely.conditions— the named pass/fail checks behind the state:Ready,ServiceReady,VisibilityEnforced,RuntimeClassApplied,TrafficAccepted, andExposed.messageis theReadycondition's own words; when you need more than one sentence, this is where the rest is.generationvs.observed_generation—generationcounts your edits;observed_generationcounts the ones the platform has applied. If they differ, your latest change was accepted but has not taken effect yet. The console shows a notice for this state.
One more pair is worth knowing about, because the two look alike and mean different things. traffic is the split you asked for. active_traffic is the split actually being served right now. They match once a change has rolled out, and differ while one is on its way — providing real-time visibility into traffic routing status.
The console's service detail page shows all of this on its Overview tab. Three more tabs sit beside it: Revisions, which holds the traffic controls and one-click rollback; Logs; and YAML, which prints the service definition with secret values removed.
Work out what one service costs your quota
A quota is a cap on how much of something your project may use at once. Every project starts with these: 50 running instances, 30 services, 10 CPU / 20 GiB reserved, and 20 CPU / 40 GiB maximum. The services cap is the one that surprises people, because of how revisions work:
- Every revision of a serverless service permanently holds 2 services, which are internal routing objects. It holds them for as long as the revision exists.
- Every deploy and every edit creates a new revision.
So a service you deployed and then updated once has 2 revisions, which hold about 4 of your 30 services. The count keeps growing with every edit until old revisions are cleaned up.
At 30 of 30, new revisions jam across the whole project with SKSReady=NotReady: No Private Service Name. Nothing becomes ready again until revisions are removed. See troubleshooting for the fix, and quotas and audit for managing limits.
Your project's live number is always the authority. Read it on the console's Quotas page, or from GET /v1/projects/{projectID}/quota. That route is on the platform API, so use $CAI_API, not $CAI_SERVERLESS_API; see API authentication. See all platform limits.
Set a CPU or memory request but no limit, and the platform stamps a default limit of 2 CPU / 4 GiB onto your container. It stamps the same limit onto the small helper container that handles your service's networking, which quietly drains your quota. You find out later, as a FailedCreate error. Either set both request and limit, or set neither and take the platform defaults (250m/512Mi request, 1 CPU/512Mi limit).
Clean up
Deleting a service requires the project admin role. Members get a 403, and this is the only place this API uses 403. A delete removes the endpoint, releases its public hostname, and frees the quota held by all of its revisions.
- platformctl
- curl
- Console
platformctl serverless delete checkout-api
curl -sS -X DELETE "$CAI_SERVERLESS_API/v1/projects/$CAI_PROJECT/services/checkout-api" \
-H "Authorization: Bearer $CAI_TOKEN" -w '%{http_code}\n'
You should see:
204
The Delete button (admins only) confirms first and warns about any triggers that would be left pointing at nothing.
Next steps
- Autoscaling and scale to zero — tune instances, concurrency, and cold starts.
- Public endpoints and domains — publish your service to the internet.
- Serverless API reference — every endpoint, field, and error.