# Services & instances

> What an instance is, how its health is observed, and how to run your own image.

An **instance** is one running service — your NocoBase, your n8n, your own container image. Each instance runs on Canadian infrastructure with its own storage, TLS certificate, and backup schedule. Instances are addressed by **slug**, unique within your organization.

## Health

An agent on each VM reports container state to our control plane: is the service answering, is its database reachable, did setup finish. The dashboard and the API show the same observed state our operators act on.

Health statuses:

- **healthy** — up and answering.
- **unhealthy** — the container is running but its own health check is failing.
- **degraded** — partly up: a container is stopped or crash-looping while another still serves, or setup is still in progress.
- **down** — no container is serving.
- **unknown** — not yet observed, or the last report is stale. Common for a short window right after a deploy. Treat a short `unknown` as inconclusive, not as an outage.

## Reading and managing instances over the API

- `GET /api/v1/instances` lists your instances with their health (`read_instances` scope).
- `GET /api/v1/instances/{slug}` returns one (`read_instances`).
- `POST /api/v1/instances` provisions one from the catalog, or from your own image (below), and returns `201` with the apply run in `meta.run_id` (`write_instances`).
- `PATCH /api/v1/instances/{slug}` moves one to a new image and redeploys it; returns `202` with the run id (`write_instances`). Instances we manage declaratively on your behalf return `422 not_operator_managed`.
- `DELETE /api/v1/instances/{slug}` tears one down and returns `202` with the run id (`write_instances`). The application stops; your data is kept.

Full parameters and response shapes are in the [API reference](/api).

## Bring your own app

Any single-container image can run as an instance. Provision with `catalog_slug: "custom"` and describe the app in `custom`; Lunary renders the compose file server-side. A compose file is never accepted.

```json
{
  "catalog_slug": "custom",
  "slug": "docs",
  "custom": {
    "image": "ghcr.io/acme/docs:1.4.2",
    "port": 4000,
    "env": { "PORT": "4000" },
    "secrets": ["SECRET_KEY_BASE"],
    "volumes": [{ "name": "data", "mount": "/var/lib/app" }],
    "healthcheck": { "path": "/healthz" },
    "size": "small"
  },
  "secrets": { "SECRET_KEY_BASE": "…" }
}
```

- **image** must carry a tag or digest (`name:tag` or `name@sha256:…`). Docker Hub is assumed when no registry is given.
- **port** is what the container listens on; TLS, the public URL, and custom domains work as for catalog apps.
- **env** is non-secret. Names listed in **secrets** take their values from the request's top-level `secrets`, are stored encrypted, and are never returned.
- **volumes** are named, backed up, and restorable. Host paths are not settable.
- **healthcheck** (optional) is an HTTP path on `port` returning 2xx when the app is ready; it drives the health statuses above. The check runs inside your container with `wget` or `curl`, so the image needs a shell and one of the two.
- **size** is `small` (0.25 vCPU / 512 MiB / 2 GiB), `medium` (0.5 / 1 GiB / 5 GiB), or `large` (1 / 2 GiB / 10 GiB), counted against your plan.

### Private registries

A private image needs a pull credential for its registry host, stored once per organization:

- `POST /api/v1/registries` with `host`, `username`, and `token` (`write_instances`). One credential per host; delete it to rotate.
- `GET /api/v1/registries` lists each credential's `id`, `host`, `username`, and `created_at` — the token is never returned (`read_instances`).
- `DELETE /api/v1/registries/{id}` removes it (`write_instances`). Running instances keep their pulled image; the next deploy or redeploy of an image that needs the credential fails until a new one is stored. A public image on that host still pulls anonymously.

The credential is encrypted at rest and released only to your own VM, for the pull and start of a deploy, then removed. For GHCR, use a classic personal access token with the `read:packages` scope.

### Updating the image

`PATCH /api/v1/instances/{slug}` takes `{ "image": "…" }` and nothing else. The image is validated like `custom.image`, stored as given, and a `redeploy` run is created; poll it with `GET /api/v1/runs/{run_id}`.

- A **tag** is re-resolved on every redeploy; a **digest** is not. Pin a digest when you want a deploy to mean exactly one build.
- A **custom** instance can move to any repository. Port, env, secrets, volumes, and healthcheck are carried over.
- A **catalog** instance takes only a new tag or digest of the repository Lunary pins (`repository_mismatch` otherwise). Its database and other secondary images stay as pinned. Where the catalog pins a tag variant — Stirling PDF runs `-ultra-lite` — the new tag must keep that suffix, and a digest is refused because it cannot show one (`variant_mismatch`).
- The request is refused while a redeploy of the instance is still running (`redeploy_in_flight`), while its `redeploy` plug is switched off (`plug_disabled`), and when it would move from a registry you hold a credential for to one you do not (`no_registry_credential`). A move between hosts you hold no credential for is accepted; if the new image is private, the redeploy run fails at pull.

The dashboard's **Settings → Image** card shows the desired image, the image each container is observed running, and the digest the last successful deploy resolved to.
