# Container services (/using/services/containers)



<AlphaNotice />

A **container service** is an image Chelabase pulls and runs for you through the **service runner**. It is routed at `/services/v1/{name}/*` with the same [signed headers](/using/services/verify-signature) as an [external service](/using/services/external), health-checked, and switched on and off per branch. Containers need the `services` compose profile: see [Self-hosting functions and services](/self-hosting/functions#the-services-profile).

<Callout type="warn">
  Container deploys are **tenant-admin only**, and the runner puts **no egress filter** on its containers. Read [What the runner doesn't isolate](#what-the-runner-doesnt-isolate) before you let anyone deploy an image.
</Callout>

## Deploy [#deploy]

```bash
chelabase services deploy NAME --image <name:tag|name@sha256:…> --port <1-65535> [--health PATH] [--memory 256|256m|1g] [--cpus 0.5]

chelabase services deploy echo --image registry.example.com/echo@sha256:… --port 8080 --health /healthz
```

The first deploy creates the service and prints its signing secret once (`echo: signing secret whsec_… (shown only now)`; keep it for [verifying requests](/using/services/verify-signature)). Every deploy prints `NAME: deployed <id>`, then waits for a background [rollover](#rollover) and exits non-zero if it fails or a newer deployment replaces it. The first deploy then turns the service on. `--memory` is MiB, or a size with an `m` or `g` suffix; `--memory` and `--cpus` set `resources` below.

| Command                             | What it does                                                                                    |
| ----------------------------------- | ----------------------------------------------------------------------------------------------- |
| `services list`                     | The branch's services: name, enabled or disabled, health, active deployment, last error.        |
| `services enable NAME`              | Turns the service on for this branch.                                                           |
| `services disable NAME`             | Turns it off: calls answer 503 and its container stops.                                         |
| `services logs NAME`                | The container's last 200 log lines.                                                             |
| `services rollback NAME DEPLOYMENT` | Rolls back to an earlier deployment, once its container is healthy (a rollover, like a deploy). |

The deployment's spec is `{backend: "container", image, port, health_path, resources}`:

* `image` is `name@sha256:<64 hex>` or `name:tag`. The orchestrator resolves a tag to a digest through the runner **before storing**, so what runs is what was checked.
* `port` is the port the service listens on inside the container (it is also given to the container as `PORT`). `health_path` defaults to `/`.
* `resources` may lower `memory_mb` and `cpus`, never raise them past the runner's caps (`CHELABASE_SERVICE_RUNNER_MEMORY_CAP_MB`, default 512, and `CHELABASE_SERVICE_RUNNER_CPU_CAP`, default 1).
* Unknown spec fields are refused, so a spec can't ask for `privileged`, a mount or a network.

Studio's **Functions** tab lists services beside the functions: a `service` kind badge with the backend (`container` or `external`) under it, a **Status** (Running, Unhealthy, Starting, Not deployed, Stopped) and the enabled switch. A container service's drawer has a **Logs** section with the last 200 lines and a **Refresh** button (Developer and up), and a notice while a rollover is pending or after one failed.

## Rollover [#rollover]

A new image reference is a new deployment, and it **rolls over** without downtime:

1. the new container is started and checked until it is healthy (60 seconds at most, then the rollover fails);
2. the route switches to it;
3. the old container is stopped.

The rollover runs in the background: the deploy (or rollback) of a service that is enabled on the branch answers `202` with `rollout: pending`, and the service's `rollout` field in the API says `pending`, then disappears once the new deployment is active, or turns `failed` with the reason (`rollout.state: "failed"`, `rollout.error`). `chelabase services deploy` and `rollback` wait for it, and Studio shows it on the service.

If the new image never passes its health check, traffic stays on the old container, the service's `last_error` on the branch says why, `rollout.state` is `failed` with the reason, and the deploy command exits non-zero.

## Switch off and logs [#switch-off-and-logs]

Turning a service off answers 503 within about 2 seconds and stops its container within about 30 seconds. `chelabase services logs` and Studio's logs panel show the container's last 200 lines, as text, from [`GET …/functions/{name}/logs`](/reference/management/functions/getServiceLogs):

* Developers and above may read them; a Viewer gets 403 (logs can hold what a service prints).
* 404 when the service has no running container on the branch, 409 `conflict` with `reason: not_deployed` when it has no deployment there, 422 for a function or an external service.

With the `services` profile off, a container deploy, rollback or logs read answers 503 `runner_unavailable`, container services don't run, and external services keep working.

## Environment [#environment]

A container gets the service's [secrets](/using/functions/environment#secrets) on the branch as environment variables, plus:

| Variable              | Value                           |
| --------------------- | ------------------------------- |
| `PORT`                | The spec's `port`               |
| `CHELABASE_URL`       | The instance's public API URL   |
| `CHELABASE_BRANCH_ID` | The branch the container serves |

## How the runner works [#how-the-runner-works]

The **service runner** is the only component that starts member containers. It holds the Docker socket, so it is narrow on purpose:

* It acts **only on containers labelled** with its own stack **and** a service, checked on every call; any other container (a compute, another stack's) is "not found", even by id.
* Every container it creates is hardened the same way: non-root, read-only root file system, every Linux capability dropped, `no-new-privileges`, memory, CPU and pids limits, no host mount, no device, no host namespace.
* Each service gets **a private network of its own**, which only the stack's functions container joins, so service containers never reach one another and traffic flows only from the functions service to the service.
* Only the "ensure" call creates a container (with the branch's secrets as its environment); the reconciler (every `CHELABASE_SERVICE_RUNNER_RECONCILE_SECS`, default 30) starts, stops and removes.
* Its two callers, the functions service and the worker, each present their own bearer token.
* Environment: at most 128 entries per container, 32 KiB per value, 256 KiB in all.

## What the runner doesn't isolate [#what-the-runner-doesnt-isolate]

A container's network has **no egress filter**. From a service container you can reach whatever the Docker host's network reaches: **host-side private and link-local addresses** (the host's own listeners, other machines on your LAN, a cloud metadata address), as well as the public internet. The container can't reach the other stack services (it shares only its own network with the functions container), but the host and your network are open. Treat an image as trusted code on your network, and keep the privilege to deploy one with tenant admins.

Pulling an image also happens from the Docker host's network. The runner refuses a registry whose host is, or resolves to, a **non-public address** (loopback, private, link-local, `localhost`). That narrows pull-side SSRF but **can't close it**: a registry can still redirect the engine through its token realm to somewhere else. This is the other reason deploys are tenant-admin only.
