# Compute Services



Compute services are long-running deployments reached at `<name>.clusterbase.dev`. Each service runs on its own managed instance with a stable hostname, environment variables, and a deployment history you can inspect and roll forward. A service can optionally be attached to a [Project](/docs/ccp/projects) or left standalone.

Compute is a sibling product to [serverless functions](/docs/ccp/functions): functions run JS/TS in V8 isolates with sub-millisecond cold starts, while compute runs anything you can ship as a container image or pre-built native binary — Go, Rust, Python, Node, whatever — for workloads that need long-lived processes, custom system dependencies, or non-JS runtimes.

Two deploy modes:

* **`--image`** — point at a container image reference (Docker Hub, GHCR, etc.). Best for anything that already has a Dockerfile or that needs a custom system environment.
* **`--binary`** — upload a pre-built linux/amd64 ELF binary directly. No Docker required. Best for Go and Rust services where the binary is the entire deliverable.

Source autodetect (Dockerfile / `go.mod` / `Cargo.toml` / `package.json`) is tracked under [#96](https://github.com/Peteskiis/cluster-infra/issues/96).

## Quickstart [#quickstart]

Get a public-facing service live in under a minute. Two flavors below — pick the one that matches your workload.

### Image quickstart [#image-quickstart]

```bash
ccp compute deploy --name hello --image nginxdemos/hello --port 80
```

You'll be prompted for any missing values and for an organization if you have more than one. On success, ccp writes `cluster.toml` to the current directory and prints the public URL:

```
 ✓ Service deployed
   name      hello
   image     nginxdemos/hello
   status    running
   URL       https://hello.clusterbase.dev
```

### Binary quickstart [#binary-quickstart]

Build a Linux x86\_64 binary, then upload it directly — no Dockerfile, no registry:

```bash
# Build (Go example)
GOOS=linux GOARCH=amd64 go build -o ./hello-server .

# Or Rust — either linkage works; musl-static targets Alpine, glibc targets Debian:
cargo build --release --target x86_64-unknown-linux-musl

# Deploy
ccp compute deploy --name hello --binary ./hello-server --port 8080
```

The CLI inspects the ELF before upload — non-ELF files and non-x86\_64 binaries are rejected client-side. The inspection also picks the runtime, so build whichever way your toolchain prefers — see [Binary runtimes](#binary-runtimes) below.

```
 ✓ Service deployed
   name      hello
   status    running
   URL       https://hello.clusterbase.dev
```

(Binary-mode deploys deliberately don't print the upload URL — it's an internal storage location, not a stable identifier.)

### Verify [#verify]

```bash
curl https://hello.clusterbase.dev | head -1
```

### Inspect [#inspect]

```bash
ccp compute status
# Compute service hello
#
#   ID       3fd25c…
#   Status   running
#   Image    nginxdemos/hello       # or "Binary  binary:<short>"
#   Port     80
#   URL      https://hello.clusterbase.dev
```

### Redeploy [#redeploy]

Run `compute deploy` again — the `service_id` recorded in `.ccp/compute-link.json` triggers an update instead of a fresh service. Image-mode services redeploy with a new `--image`, binary-mode services redeploy with a new `--binary` (or omit the flag to re-upload the path recorded in `cluster.toml`):

```bash
# Image mode
ccp compute deploy --image nginxdemos/hello:plain-text

# Binary mode — re-uploads the path recorded in cluster.toml
ccp compute deploy

# Binary mode with a different file
ccp compute deploy --binary ./dist/server-v2
```

The redeploy is a `PATCH` against the existing service; only the fields you pass are changed.

Editing an immutable field in `cluster.toml` (mode, runtime, resources, port) no longer requires
manual teardown: ordinary `ccp compute deploy` detects the change, shows a replacement plan, and
asks for confirmation before proceeding. See [Replace immutable configuration](#replace-immutable-configuration).

### Tear down [#tear-down]

```bash
ccp compute destroy
```

Deletes the service, its instance, the route, and this directory's local link (`.ccp/compute-link.json`). `cluster.toml` is preserved byte-for-byte — it describes what to run, not what is deployed — so a future `compute deploy` in the same directory recreates the service from the same committed description. Accepts `<NAME>` or `<UUID>` as a positional argument when run from a different directory.

## Project Shape: `cluster.toml` and the local link [#project-shape-clustertoml]

The first `ccp compute deploy` writes a TOML manifest describing the service — this part is committed, so every clone builds the same thing:

```toml
name = "hello"
mode = "binary"  # or "image"

[image]
ref = "nginxdemos/hello"  # only when mode = "image"

[binary]
path = "./hello-server"   # only when mode = "binary"
args = ["--bind", "0.0.0.0:8080"]  # optional argv tail (binary mode only)
runtime = "alpine"        # optional: auto | alpine | debian (default auto)

[service]
internal_port = 8080
always_on = false

[resources]                # optional; omit for the default shape
vcpu = 2
memory_mb = 1024

[env]
DATABASE_URL = "postgres://..."
LOG_LEVEL = "info"

[health]                              # optional; enables HTTP probes
path = "/healthz"                     # required when [health] is present
```

Which service *this machine* talks to lives separately, in `.ccp/compute-link.json` (gitignored):

```json
{
  "service_id": "uuid",
  "organization_id": "uuid",
  "hostname": "hello.clusterbase.dev"
}
```

`cluster.toml` describes *what to run*; `.ccp/compute-link.json` says *which deployed service this machine/environment is talking to*. That split means a project can have a staging and a production compute service — each machine just carries its own link — and a teammate's `ccp compute status` never resolves your service by accident.

Subsequent `ccp compute *` commands read `service_id` / `organization_id` from `.ccp/compute-link.json` when no flag is provided. **Commit `cluster.toml`; never commit the link** — `ccp init` wires `.ccp/` into `.gitignore` for you.

You can hand-edit `[image].ref`, `[binary].path`, `[binary].args`, `[service].*`, `[env].*`, and `[health].*` between deploys. Run `ccp compute deploy` to apply the changes. Don't hand-edit `.ccp/compute-link.json`.

### A checkout with no link (fresh clone or CI) [#a-checkout-with-no-link-fresh-clone-or-ci]

A fresh clone has `cluster.toml` but no `.ccp/compute-link.json`. `ccp compute deploy` looks up the committed `name` in the org and links to that existing service instead of creating a duplicate — it only creates a new one when the name is free (confirmed interactively, or automatically with `-y`). `--name` overrides the lookup/create target for that deploy but is **not** written back to `cluster.toml`, so it stays per-environment. On a multi-org account in headless mode this lookup needs `--org-id` or `CCP_ORG_ID`, since the org is no longer implied by a committed `[managed]` block.

### Migrating from an older ccp [#migrating-from-an-older-ccp]

Projects created with an older ccp have their link committed inside a `[managed]` table in `cluster.toml`. The next `ccp compute deploy` or `ccp compute destroy` moves `service_id` / `organization_id` / `hostname` into `.ccp/compute-link.json` and strips `[managed]` from `cluster.toml`, printing a notice to commit the change.

**This is a one-way door that travels through git.** ccp older than **0.1.68** requires `[managed]` and fails every compute command against a migrated `cluster.toml` with a `missing field managed` error — including on machines that never ran the new ccp. Upgrade every teammate, CI runner, and VM template to ccp 0.1.68+ *before* committing the stripped `cluster.toml`.

### `[health]` — readiness probes (optional) [#health--readiness-probes-optional]

Without `[health]`, `ccp compute deploy` checks only that something is listening on the configured port (TCP-level `nc -z`). With it, the deploy also runs an HTTP-level probe and detects a common deploy footgun where the service is bound to loopback only.

```toml
[health]
path = "/healthz"            # required: HTTP path on the service
# port = 9090                # optional: defaults to [service].internal_port
# initial_delay_ms = 1000    # wait before the first probe attempt
# timeout_ms = 2000           # per-probe timeout
# period_ms = 500             # interval between attempts
# success_threshold = 1       # consecutive 2xx required
# startup_budget_ms = 30000   # total time the probe will retry
```

When set, deploy:

1. Polls `wget --spider http://127.0.0.1:<port><path>` inside the VM until your service returns a 2xx (catches "port open but app not ready yet" — DB pools warming, JIT, migrations, etc.).
2. Sends the same request through the proxy from outside the VM. If this connect-refuses or times out while step 1 succeeded, your service is almost certainly bound to `127.0.0.1` instead of `0.0.0.0`. The deploy succeeds but prints a yellow warning with the bind-fix hint.

This mirrors Kubernetes `httpGet` probes — opt-in, user-defined path, no platform default. Add it to existing services by editing `cluster.toml` and redeploying; the change persists on the API on next deploy.

### `[network]` — private overlay networking (optional) [#network--private-overlay-networking-optional]

An always-on service can join an organization-scoped private network by adding a `[network]` table to `cluster.toml`:

```toml
[network]
name = "backend"
```

The network is created automatically on first deploy. Services on the same network reach one another at `<service>.<network>.internal` regardless of which node they're placed on; services outside the network cannot resolve or connect to that private address. Both `[service]` name and `[network].name` must be lowercase DNS labels.

Private networking currently requires `[service].always_on = true`. Joining, changing, or removing the network on an existing service is an immutable-field change: ordinary `ccp compute deploy` detects it and offers a replacement (see [Replace immutable configuration](#replace-immutable-configuration)) rather than requiring manual destroy/recreate. It does not change the service's public `<name>.clusterbase.dev` route.

`cluster.toml` is independent of `.ccp/config.json` (the serverless function link). A project may have one, the other, both, or neither. Projects upgraded from a pre-#458 ccp will see their legacy `.cluster/compute.json` auto-migrated to `cluster.toml` on the first command; projects upgraded from a pre-#610 ccp will see the committed `[managed]` block moved into `.ccp/compute-link.json` — see [Migrating from an older ccp](#migrating-from-an-older-ccp) above.

## Deploy [#deploy]

```bash
ccp compute deploy [--name N] [--image I | --binary PATH] [--port P] \
                   [--runtime auto|alpine|debian] [--vcpu N --memory-mb M] \
                   [--env K=V]... [--always-on] \
                   [--service-id S] [--org-id O] [-y]
```

`--image` and `--binary` are mutually exclusive. `compute deploy` decides between **create** and **update** by inspecting `.ccp/compute-link.json` and `--service-id`:

### First deploy (create) [#first-deploy-create]

When there's no `cluster.toml`, no `.ccp/compute-link.json`, and no `--service-id`, ccp creates a new service. Required: `--name`, `--port`, and exactly one of `--image` / `--binary`. In an interactive terminal, missing values are prompted; in headless mode, missing flags error.

```bash
# Image mode
ccp compute deploy --name api --image ghcr.io/me/api:v3 --port 8080 \
  --env DATABASE_URL=postgres://... --env LOG_LEVEL=debug

# Binary mode
ccp compute deploy --name api --binary ./target/release/server --port 8080 \
  --env DATABASE_URL=postgres://...
```

### Redeploy (update) [#redeploy-update]

When `.ccp/compute-link.json` exists (or `--service-id` is passed), ccp updates the existing service. Only the fields you pass change. Passing `--image` to a binary-mode service (or `--binary` to an image-mode service) is an immutable-field change: ccp detects it and errors, asking you to move the new mode/image/binary into `cluster.toml` and rerun `ccp compute deploy` — see [Replace immutable configuration](#replace-immutable-configuration).

```bash
# Image mode redeploy
ccp compute deploy --image ghcr.io/me/api:v4         # roll a new image
ccp compute deploy --env LOG_LEVEL=info              # update env

# Binary mode redeploy
ccp compute deploy --binary ./target/release/server-v2  # upload a new build
ccp compute deploy                                       # re-upload the path in cluster.toml
```

The `--port` cannot change after first deploy via redeploy flags. To change the port, edit `[service].internal_port` in `cluster.toml` and run `ccp compute deploy`; it detects the change and offers a [replacement](#replace-immutable-configuration).

If `.ccp/compute-link.json` points at a service that's been deleted (e.g. destroyed from another machine), the next `compute deploy` detects the orphan and offers to recreate the service with the same name and configuration.

### Flags [#flags]

| Flag                     | When         | Notes                                                                                         |
| ------------------------ | ------------ | --------------------------------------------------------------------------------------------- |
| `--name`                 | first deploy | 1–64 chars, lowercase alphanumeric + dashes. Becomes the subdomain.                           |
| `--image`                | image mode   | Container image reference (Docker Hub, GHCR, etc.). Mutually exclusive with `--binary`.       |
| `--binary`               | binary mode  | Local path to a linux/amd64 ELF binary. Uploaded directly. Mutually exclusive with `--image`. |
| `--runtime`              | binary mode  | `auto` (default), `alpine`, or `debian`. See [Binary runtimes](#binary-runtimes).             |
| `--vcpu` + `--memory-mb` | first deploy | Optional VM shape; the flags must be paired. See [VM resources](#vm-resources).               |
| `--port`                 | first deploy | Port your service listens on inside the VM.                                                   |
| `--env K=V`              | any          | Repeatable. Sets/replaces a single environment variable per flag.                             |
| `--always-on`            | first deploy | Disable auto-pause when idle.                                                                 |
| `--service-id`           | redeploy     | Update a specific service by UUID, ignoring the local link.                                   |
| `--org-id`               | first deploy | Required if you belong to multiple orgs and have no manifest or `CCP_ORG_ID`.                 |
| `-y` / `--yes`           | any          | Skip interactive prompts. Combine with explicit flags for headless deploys.                   |

### Binary runtimes [#binary-runtimes]

Binary-mode services run on one of two runtimes: **Alpine** (musl) or **Debian** (glibc). Resolution precedence is `--runtime` → `[binary].runtime` in `cluster.toml` → `auto`. `auto` inspects the ELF and routes static and musl-linked binaries to Alpine and glibc-linked binaries to Debian, so most deploys never need the flag. An explicit runtime that cannot run the binary — `--runtime alpine` with a glibc ELF, or `--runtime debian` with a musl ELF — is rejected before upload.

After a successful deploy, ccp writes the concrete runtime back to `[binary].runtime` in `cluster.toml`. The runtime is immutable for an existing service: change `[binary].runtime` and run `ccp compute deploy` to move between Alpine and Debian — ccp detects the change and offers a [replacement](#replace-immutable-configuration).

### VM resources [#vm-resources]

By default a service gets its runtime's shape — 1 vCPU / 512 MiB for binary services. To pick a different one, pass `--vcpu` and `--memory-mb` together on first deploy, or set the `[resources]` table in `cluster.toml`. The published shapes are `1/256`, `1/512`, `2/1024`, `4/2048`, and `4/4096` (vCPU/MiB); anything else is rejected. The shape is immutable after create — edit `[resources]` in `cluster.toml` and run `ccp compute deploy` to change it, which detects the change and offers a [replacement](#replace-immutable-configuration). `ccp compute list` and `ccp compute status` show the persisted shape.

### Replace immutable configuration [#replace-immutable-configuration]

Editing an immutable field in `cluster.toml` — `mode`, `[binary].runtime`, `[service].internal_port`,
`[resources]`, `[service].always_on`, or `[network]` — no longer requires manually destroying and
recreating the service. Ordinary `ccp compute deploy` compares the manifest against the live service,
and if it finds an immutable change, prints a plan of the changed fields and asks for confirmation
before proceeding:

```bash
# After editing cluster.toml, review the plan and confirm interactively
ccp compute deploy

# Explicit consent for automation; non-interactive replacement requires -y or --yes
ccp compute deploy -y # --yes is equivalent
```

Replacement reads the desired configuration only from `cluster.toml` and `.env` — pass `--name`,
`--image`, `--binary`, `--runtime`, `--port`, `--vcpu`/`--memory-mb`, `--env`, or `--always-on`
alongside an immutable change and ccp refuses, asking you to put the override in those files first. An explicit
`--service-id` must match this project's local link, and an explicit `--org-id` must match the
linked service's organization.

Under the hood, ccp prepares and uploads any binary before deleting the old service, then deletes
the old service and creates a new one. This is a real interruption: the old guest's filesystem and
deployment history are lost, and the service and VM get new IDs. The existing service name and
built-in `<name>.clusterbase.dev` hostname (including a per-environment name previously set with
`--name`) are preserved, and the committed `cluster.toml` is left byte-for-byte unchanged. Custom
domains that target the old VM must be rebound afterward. Omitting `[resources]` retains the
existing resource shape rather than resetting it.

The local link isn't updated until the API accepts the new service, and a local, context-scoped
recovery record (outside `cluster.toml`) tracks the original identity so a retry can resume safely.
If deletion or creation fails partway, fix the reported cause and rerun `ccp compute deploy --yes`
in the same project/context — keep `cluster.toml`, `.env`, and the local link untouched while
recovering. If the original service still exists, the retry shows the plan again, asks for
confirmation, and deletes it before creating the replacement. If the original is already gone, the
retry instead looks for an already-accepted service under the original name and, if it finds one,
recovers the link to it without deleting it or creating a duplicate. Use `ccp compute status` to
check a recovered service's readiness.

### Auto-pause and wake [#auto-pause-and-wake]

Compute services auto-pause after **10 minutes idle** to free the underlying VM while you're not using it. The pause is transparent to most usage:

* A new HTTPS request to the service hostname wakes the VM automatically. The first request after a pause sees a brief wake latency (typically a few seconds); subsequent requests are normal.
* `ccp compute deploy` (redeploy), `compute restart`, `compute logs`, and `compute exec` all transparently wake the service before sending their RPC — no manual unpause needed.

To opt out for services that need consistent latency on every cold request (e.g. a public-facing API that can't absorb the wake delay), set `--always-on` on first deploy or `always_on = true` in `cluster.toml`. Always-on services keep their VM running indefinitely.

A VM that Billing previously paused for non-payment performs a fresh admission check on the next wake attempt. If the account is now eligible, the wake resumes normally; otherwise the VM stays paused and the operation returns `payment_required` (HTTP 402).

Workload operations (`compute deploy` redeploy, `restart`, `logs`, `exec`) return `service_not_running` while initial deployment is still pending; wait for deployment to finish before retrying.

### Binary mode details [#binary-mode-details]

* **Bind to `0.0.0.0`, not `127.0.0.1`.** The platform's HTTP proxy runs on the host, in a different network namespace from your VM. A service bound to loopback (`127.0.0.1:PORT`) is reachable only from inside the VM — every public request returns `502 Bad Gateway`. Bind to `0.0.0.0:PORT` (or `[::]:PORT` for IPv6) so the listener is on the VM's network interface. The most common cause of "deploy succeeded but my service won't load" is this one line. Image-mode services hit the same issue if the container's CMD binds to loopback.
* **ELF inspection runs client-side.** Non-ELF and non-x86\_64 binaries are rejected before upload. Architecture is detected from the ELF header.
* **Glibc binaries run natively.** No compat shim, no warning — `--runtime auto` routes glibc ELFs to Debian and musl-static (`--target x86_64-unknown-linux-musl` for Rust, `CGO_ENABLED=0` for Go) to Alpine; see [Binary runtimes](#binary-runtimes).
* **Upload size limit: 256 MiB.** ccp uploads through a signed URL and the VM downloads the binary directly from object storage, verifying its size and SHA-256 before swapping it in.
* **Argv tail.** Set `[binary].args = [...]` in `cluster.toml` to pass argv to your binary on every deploy. Each list element becomes one argv entry — no shell parsing — so values can contain spaces, quotes, `$`, etc. without escaping. Removing the key from `cluster.toml` clears the args server-side on next redeploy.
* **Crash recovery.** The binary runs under OpenRC's supervise-daemon, with crash-restart enabled (10 restarts in 5 minutes before the service is marked crashed). Logs are tailed via `ccp compute logs`.

## List Services [#list-services]

```bash
ccp compute list [--org-id O]
# alias: ccp compute ls
```

Prints one line per service: name, status, source (image ref or `binary:<short>`), hostname, last-update timestamp. Org resolves from `--org-id` → `.ccp/compute-link.json` → `CCP_ORG_ID` → single-org auto-pick → interactive picker.

A malformed `.ccp/compute-link.json` fails loudly during this org lookup — including for `ccp compute ls` — rather than silently falling back to a different organization. Fix the link file or pass `--org-id` explicitly.

A service's hostname is carried as a clickable OSC 8 hyperlink rather than printed as a bare URL, unless output is piped or redirected — then it drops the link in favor of a plain trailing `URL` column. Pass `--json` for full ids, exact timestamps, and complete URLs with no styling, suitable for scripts.

## Show Status [#show-status]

```bash
ccp compute status [SERVICE_ID|NAME]
```

Prints metadata plus the last 10 deployments — each deployment's id-prefix, status (`live` / `deploying` / `superseded` / `failed`), source (image ref or `binary:<short>`), deploy time, and any error message. Useful for debugging a redeploy that didn't reach `running`.

Service identifier resolves from positional arg (UUID or service name) → `.ccp/compute-link.json` → interactive picker.

## Tail Logs [#tail-logs]

```bash
ccp compute logs [SERVICE_ID] [-n TAIL]
```

Prints the last `N` lines of stdout/stderr (default 100, max 1000). Pipe-friendly: each log line is a single line on stdout.

```bash
ccp compute logs -n 500 | grep ERROR
```

Live-follow (`-f`) is not yet supported; for now, re-run periodically.

## Run a One-Shot Command [#run-a-one-shot-command]

```bash
ccp compute exec [SERVICE_ID] [--timeout-ms MS] -- <CMD> [ARGS...]
```

Runs a single command inside the running container, forwards its stdout/stderr to your terminal, and exits with the container's exit code. The `--` is mandatory — it separates ccp's flags from the command you're running.

```bash
ccp compute exec -- ls -la /app
ccp compute exec -- sh -c 'echo $DATABASE_URL | head -c 20'
ccp compute exec --timeout-ms 5000 -- env
```

Default timeout is 30 seconds. Long-running commands appear to hang until they exit.

## Restart [#restart]

```bash
ccp compute restart [SERVICE_ID]
```

Re-runs the same image. Use after env changes that aren't picked up by hot reload, or to clear in-process state.

## Destroy [#destroy]

```bash
ccp compute destroy [SERVICE_ID|NAME] [-y]
```

Tears down the service, its instance, and the route. Asks for confirmation unless `-y` is passed or you're in headless mode (which auto-confirms). On success, deletes this directory's `.ccp/compute-link.json` (and a legacy committed `[managed]` block, if one is still present) — but only when the destroyed service is the one this directory is linked to. `cluster.toml` is preserved byte-for-byte, so `ccp compute deploy` can recreate the service from the same committed description.

Accepts either a UUID or a service name as the positional argument. If the local link already points at a deleted service, destroy succeeds with `Compute service was already gone — cleaned up local manifest`.

## Headless Use [#headless-use]

All `ccp compute` commands are headless-safe with `CCP_HEADLESS=1` and a session token in `CCP_SESSION_TOKEN`. Compute also accepts an organization API key via `CCP_API_KEY` for organization-level automation — see [Authentication](/docs/ccp/headless#authentication):

```bash
export CCP_HEADLESS=1
export CCP_SESSION_TOKEN=$(... fetched from operator's machine ...)

# First deploy (image mode) needs explicit flags — no prompts in headless mode:
ccp compute deploy --name my-api --image ghcr.io/me/api:v3 --port 8080 --org-id "$ORG"

# Or binary mode — binary is uploaded directly:
ccp compute deploy --name my-api --binary ./target/release/server --port 8080 --org-id "$ORG"

# Redeploy reads service_id from the local .ccp/compute-link.json:
ccp compute deploy --image ghcr.io/me/api:v4
ccp compute deploy --binary ./target/release/server-v2

# Destructive commands auto-confirm — verify the target before invoking:
ccp compute destroy
```

See [Headless mode](/docs/ccp/headless) for the full pattern.

## Custom Domains [#custom-domains]

A registered domain can be pointed at a compute service via its underlying instance id. Get the id from `ccp compute status`, then:

```bash
ccp domain link example.com --vm "<instance_id>:<port>"
```

The `<port>` is the same one passed to `ccp compute deploy --port`. See [Domains](/docs/ccp/domains#compute-services) for the full flow.

## Next Steps [#next-steps]

* [Headless deploys](/docs/ccp/headless) — wire compute into CI / agent workflows
* [Custom domains](/docs/ccp/domains) — serve compute services from your own domain
* [Database](/docs/ccp/database) — pair a managed Postgres with your compute service
