---
summary: Register an OCI-sourced image, watch it fan out to workers via availability
  groups, and use it for new bunkers.
title: Register a custom image
path: tutorials/register-custom-image
status: published
---

You're going to register a custom ext4 image baked from an OCI source (e.g. `docker.io/r-base:4.4`), wait for it to fan out to your tenant's workers, and then create a bunker from it.

Roughly 5 minutes plus the bake time (a few seconds to a couple of minutes depending on image size).

## 1. Decide the image's shape

Settle these before the API call:

- **Build source.** OCI (`{kind: "oci", ref: "docker.io/r-base:4.4"}`) for anything pullable from a registry. `tar` for tarballs your worker can read locally — air-gapped deployments, hand-built rootfs.
- **Size.** `size_mib` is the cap on the resulting ext4 file. Too small → `mkfs.ext4` errors at bake time. Pick generously; 2048 is a sensible floor for non-minimal images.
- **Defaults.** `default_cpu_millicores`, `default_memory_mb`, `default_disk_mb` are applied when a caller creates a bunker from this image without overriding. Set them to a sensible "this image needs at least this to function" floor.
- **Scope.** `tenant` (default) — only this tenant sees it. `partner` — every tenant under your partner. `platform` — visible to every tenant; only super-admins can register at this scope.
- **Lazy pull.** `lazy_pull: true` lets the scheduler place a bunker on a worker that doesn't have the image cached yet (slower first boot). The default `false` requires the image to be `ready` on a worker before that worker is eligible.

You need `scaibunker:images:manage` to register a new image.

## 2. Register the image

POST to `/images` with the build source, size cap, and defaults. The response includes the new image id plus a `warm` summary if any workers were already in scope for fan-out.

```bash
curl -X POST "$SCAIGRID_HOST/v1/modules/scaibunker/images" \
  -H "Authorization: Bearer $SCAIGRID_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "r-stats",
    "display_name": "R 4.4 with tidyverse",
    "description": "R statistical computing environment with the tidyverse pre-installed.",
    "build_source": {"kind": "oci", "ref": "docker.io/r-base:4.4"},
    "size_mib": 4096,
    "default_cpu_millicores": 2000,
    "default_memory_mb": 4096,
    "default_disk_mb": 8192,
    "preinstalled": ["R", "tidyverse", "ggplot2", "dplyr"]
  }'
```

```python
import httpx, os

H = {"Authorization": f"Bearer {os.environ['SCAIGRID_API_KEY']}"}
HOST = os.environ["SCAIGRID_HOST"]

image = httpx.post(
    f"{HOST}/v1/modules/scaibunker/images",
    headers=H,
    json={
        "name": "r-stats",
        "display_name": "R 4.4 with tidyverse",
        "build_source": {"kind": "oci", "ref": "docker.io/r-base:4.4"},
        "size_mib": 4096,
        "default_cpu_millicores": 2000,
        "default_memory_mb": 4096,
        "default_disk_mb": 8192,
        "preinstalled": ["R", "tidyverse", "ggplot2", "dplyr"],
    },
).json()["data"]
print(image["id"], "warm queued:", image.get("warm"))
```

```javascript
const HOST = process.env.SCAIGRID_HOST;
const H = { "Authorization": `Bearer ${process.env.SCAIGRID_API_KEY}` };

const res = await fetch(`${HOST}/v1/modules/scaibunker/images`, {
  method: "POST",
  headers: { ...H, "Content-Type": "application/json" },
  body: JSON.stringify({
    name: "r-stats",
    display_name: "R 4.4 with tidyverse",
    build_source: { kind: "oci", ref: "docker.io/r-base:4.4" },
    size_mib: 4096,
    default_cpu_millicores: 2000,
    default_memory_mb: 4096,
    default_disk_mb: 8192,
    preinstalled: ["R", "tidyverse", "ggplot2", "dplyr"],
  }),
});
const { data: image } = await res.json();
console.log(image.id, "warm queued:", image.warm);
```

The response includes a `warm` object summarising the fan-out: `{queued, ready, failed, skipped}`. If your tenant's availability-group setup already covers some workers, those will show as `ready` or `queued` immediately.

## 3. Watch fan-out progress

GET the image's cache view to see one row per targeted worker. Poll until every row reports `status: ready`.

```bash
curl "$SCAIGRID_HOST/v1/modules/scaibunker/images/$IMAGE_ID/cache" \
  -H "Authorization: Bearer $SCAIGRID_API_KEY"
```

You'll get one row per worker that's been targeted, with `status` in `pending`, `building`, `ready`, `failed`, or `evicted`. Re-poll until every row is `ready` — at that point, every worker in the targeting groups has the ext4 cached and can launch bunkers from it instantly.

If a worker shows `failed`, the `error` field carries the worker's response (registry auth issue, size cap hit, OOM during mkfs, etc.). Fix the cause and re-trigger:

```bash
curl -X POST "$SCAIGRID_HOST/v1/modules/scaibunker/images/$IMAGE_ID/warm" \
  -H "Authorization: Bearer $SCAIGRID_API_KEY"
```

The warm route is idempotent — workers that already have the image come back instantly.

## 4. Assign to an availability group (if needed)

If your tenant's images don't yet sit in any group, the fan-out will have done nothing. Create a group and put the image in it:

```python
group = httpx.post(
    f"{HOST}/v1/modules/scaibunker/availability-groups",
    headers=H,
    json={"name": "stats-tenant-default", "description": "R + Python stats workers"},
).json()["data"]
print("group:", group["id"])

# Attach this image
httpx.post(
    f"{HOST}/v1/modules/scaibunker/availability-groups/{group['id']}/images",
    headers=H,
    json={"ref": image["id"]},
)
```

Adding a worker to the group (`POST /availability-groups/{id}/workers`) will then trigger a warm of every image already in the group on that newly-joined worker — same machinery, no extra calls.

## 5. Wait for the security scan

Every registered image is scanned automatically. The image row carries:

- `scan_status: pending` — queued; the background task runs every 2 minutes.
- `scan_status: passed` — no critical or high CVEs.
- `scan_status: failed` — CVEs found, or the scanner itself failed (missing binary, registry error). Failures don't block bunker creation; the status is informational.
- `scan_status: skipped` — image source has no scannable OCI ref.

To re-scan on demand:

```bash
curl -X POST "$SCAIGRID_HOST/v1/modules/scaibunker/images/$IMAGE_ID/scan" \
  -H "Authorization: Bearer $SCAIGRID_API_KEY"
```

## 6. Create a bunker from the new image

Reference the image by `name` in the bunker create. The scheduler will pick a worker with the image cached `ready`.

```bash
curl -X POST "$SCAIGRID_HOST/v1/modules/scaibunker/bunkers" \
  -H "Authorization: Bearer $SCAIGRID_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "image": "r-stats",
    "lifecycle_mode": "ephemeral",
    "network_profile": "registry"
  }'
```

The scheduler will pick a worker with the image cached `ready`. If none yet, the call fails with `NO_SUITABLE_WORKER` — wait for the fan-out to complete, or set `lazy_pull: true` on the image to allow placement on cold workers.

## 7. Deactivate when retiring

When the image is end-of-life, deactivate it instead of leaving it around:

```bash
curl -X DELETE "$SCAIGRID_HOST/v1/modules/scaibunker/images/$IMAGE_ID" \
  -H "Authorization: Bearer $SCAIGRID_API_KEY"
```

Deactivation is a soft-delete: existing bunkers keep running, but new bunkers can't be created from it. Per-worker cache entries get garbage-collected by the scanner background task on its next sweep.

## Done

You have a custom image baked, fanned out to your workers, scanned, and ready for production. Iterate the source by registering a new image with a different `name` — there's no in-place update; you version by name.
