> ## Documentation Index
> Fetch the complete documentation index at: https://docs.basaltic.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Instance pools

> Keep a set of identical instances at a target count, roll them onto a new launch template, and front them with one shared public address.

An instance pool is a launch template plus a target count. The platform keeps
that many instances running from that template, replaces ones that fail, and
spreads them across hosts.

It is a primitive for identical, interchangeable machines. Everything it does
follows from that: replicas cannot have fixed addresses, a template change does
not touch what is already running, and a pool is what makes one public address
answerable by several instances at once.

<CardGroup cols={2}>
  <Card title="Create a pool" icon="layers" href="#creating-a-pool">
    The launch template, the sizing bounds, and how replicas get named.
  </Card>

  <Card title="Sizing and healing" icon="activity" href="#sizing-and-convergence">
    What the pool converges, what it replaces, and the counter to alert on.
  </Card>

  <Card title="Rolling a template change" icon="refresh-cw" href="#changing-the-template">
    Why editing the template changes nothing yet, and what a refresh does.
  </Card>

  <Card title="A shared public address" icon="globe" href="#one-address-for-the-whole-pool">
    Anycast across the replicas — and the ways it is not a load balancer.
  </Card>
</CardGroup>

## Creating a pool

<Tabs>
  <Tab title="Console">
    Go to **Compute → Instance Pools** and choose **Create instance pool**.
    After **Details** comes a **Scaling** card with **Min**, **Desired** and
    **Max**; the rest of the form is the launch template, card for card the
    same as creating one instance — **Flavor**, **Image**, **Boot volume**,
    **Data volumes**, **Networking**, **SSH keys**, **IAM role**,
    **User data**.

    The form is explicit about which numbers are permanent: **Min** is
    *the floor for scale-in, and permanent — only Desired can be changed
    later*, and **Max** is *the ceiling for scale-out, also fixed at create*.

    The two tag maps below are named for the difference between them:
    **Instance tags** are *stamped on every replica the pool launches*, while
    **Pool tags** *label the pool itself and reach none of its instances*.
  </Tab>

  <Tab title="API">
    ```bash theme={null}
    POST https://compute.sa-saopaulo-1.basaltic.sh/v1/instance-pools
    {
      "name": "web-asg",
      "desired_count": 3,
      "min_count": 2,
      "max_count": 6,
      "tags": { "team": "backend" },
      "template": {
        "flavor_id": "550e8400-e29b-41d4-a716-446655440000",
        "image_id": "app-base:20260807",
        "networks": [
          { "subnet_id": "9b2e4f1a-3c5d-4e6f-8a90-1b2c3d4e5f60",
            "security_group_ids": ["c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f"] }
        ],
        "key_names": ["my-keypair"],
        "user_data": "I2Nsb3VkLWNvbmZpZwo...",
        "tags": { "role": "web" }
      }
    }
    ```
  </Tab>
</Tabs>

`template` is the launch config, in the same shape a
[standalone instance create](/compute#launching-an-instance) takes — same field
names, same types, same meanings. There is no separate launch-template resource
to create, version or share; the template belongs to the pool.

A flavor and a primary subnet are required: `template.flavor_id` and
`template.networks[0].subnet_id`. Index 0 is the primary NIC; the rest are
extras.

<Note>
  An older, flat shape — `flavor_id`, `subnet_id`, `extra_nics` and friends at
  the top level — still works, and the response renders both, so nothing
  written against either has to move. They are **alternatives, not layers**:
  sending `template` together with any flat launch field is a `400` rather than
  a precedence rule you would have to know to predict what your replicas boot
  as.

  Read `template` in responses. The flat fields are a projection of the same
  stored config and cannot disagree with it.
</Note>

<Warning>
  **A template cannot carry a fixed `ip_address` or `mac`.** Every replica
  launches from the same template, so a fixed address would have the second
  replica ask for one the first already holds. Both are refused with a `400`
  rather than dropped in silence.
</Warning>

### The image is resolved once

`template.image_id` takes the same three forms instance create does — an id,
`name:version`, or a bare `name`. Unlike instance create, **the reference is
resolved once, when the pool is created (or when the template is replaced), and
the resulting image id is what every replica boots** — including replacements
spawned months later.

That is deliberate. A tag re-resolved per replica would let a healed member
boot a newer build than its siblings, and a pool whose members are quietly not
identical is the premise of the primitive breaking silently. To move a pool to
a new build, change the template and refresh.

### What the replicas are called

Each replica gets a **sequence number**, stable for as long as it holds the
slot, and is named `<pool-name>-<sequence_num>` — `web-asg-0`, `web-asg-1`, and
so on. `GET /v1/instance-pools/{pool_id}/instances` lists the live bindings with
their sequence numbers; a replacement takes over the freed number.

<Note>
  Instance names are unique per account, so a pool called `web` cannot coexist
  with an instance you already named `web-0`. Pool names themselves are 1–127
  characters of letters, digits, dot, dash and underscore, unique per account.
</Note>

### Per-replica disks and addresses

`template.data_volumes` creates a blank volume with each replica and reclaims
it with that replica. `delete_on_termination` defaults to `true`; set it to
`false` and a replica scaled in, replaced, or torn down with the pool
**releases** its volume back to `available` instead of destroying it.

`template.assign_public_ip` gives **each replica its own** floating IP on its
primary NIC, allocated as the pool scales out and released as it scales in. A
NIC in `template.networks[]` carries its own flag, so a secondary interface can
be the public one. Each address counts against your `floating_ips` quota.

That is a different thing from the pool's shared address —
[see below](#one-address-for-the-whole-pool).

## Sizing and convergence

<ResponseField name="desired_count" type="0–100">
  The target. This is the only sizing field you can change later. In the
  console it is **Scale** on the pool's header, which opens **Scale instance
  pool** showing the **Current size** and taking a new **Desired count**.
</ResponseField>

<ResponseField name="min_count / max_count" type="fixed at create">
  Bounds on `desired_count`. Both default to `desired_count` when omitted, and
  **neither is patchable** — to change them, create a new pool.
</ResponseField>

Creating the pool returns `201` immediately. The instances are spawned by a
background reconciler, which is also what converges the pool toward
`desired_count` whenever you change it, so watch the pool rather than expecting
members in the create response.

Four counters tell you where a pool stands, and confusing two of them is the
usual source of a false alarm:

| Field                  | Question it answers                                                                                |
| ---------------------- | -------------------------------------------------------------------------------------------------- |
| `desired_count`        | How many you asked for.                                                                            |
| `member_count`         | How many the pool holds, running or not. This is what `status` reflects.                           |
| `live_count`           | How many are **up** — members in `vm_state: running`. **This is the number to alert or scale on.** |
| `stale_instance_count` | How many are on a template other than the current one.                                             |

<Info>
  `status: "active"` means `member_count == desired_count` — the pool holds the
  members it was asked for. **It is not a claim that all of them are up.** A
  pool can be `active` with `live_count` below `desired_count` when members have
  stopped. Read `live_count` for liveness.

  `scaling` means it does not hold its target and is converging: after a
  create, after a `desired_count` change, and for the length of a refresh.
  `error` carries `error_message` and is still reconciled — the pool keeps
  being retried. `deleting` is a teardown in flight.
</Info>

`error_message` is cleared when the pool reaches its target, and is left in
place through a later resize: a pool that failed to spawn and is being scaled
again has not yet proved the failure is behind it.

### What gets replaced, and what does not

Each pass, the pool replaces any member whose `vm_state` is `error` or
`deleted`, and any binding whose instance has been deleted out from under it.
The replacement takes the freed sequence number and launches from the pool's
current template.

<Warning>
  **The pool does not health-check anything inside the guest, and it does not
  replace a member you stopped.** A replica that is `stopped`, or running but
  serving errors, stays a member: `live_count` falls for a stopped one, and
  nothing at all changes for a wedged application.

  Replacement is driven by the instance failing, not by the workload failing.
  If you need application-level health checking, put a load balancer in front.
</Warning>

Scaling in removes the **highest sequence numbers first**, so a scale from 5 to
3 retires `-4` and `-3`. Each retirement runs the full instance delete, so its
quota, its volumes and its addresses are handled exactly as for a standalone
instance.

Replicas are spread across hosts — best effort. Each new replica avoids the
hosts its siblings already occupy, but when the fleet has no room the spread is
dropped rather than the launch failing, so replicas can end up sharing a host.

## Changing the template

`PATCH /v1/instance-pools/{pool_id}` changes `desired_count`, the pool's `tags`,
the `template`, or any combination. Every field is optional; sending none of
them is a `400` rather than a silent no-op.

<Tabs>
  <Tab title="Console">
    The pool's **Settings** tab edits three parts of it: **Tags** on the pool,
    and **Instance tags** and **Instance metadata** on the launch template.
    **Desired count** is the **Scale** button on the header.

    <Warning>
      **The rest of the template is API only.** There is no console control for
      the template's flavor, image, boot volume, networking, SSH keys, IAM role
      or user data — the pool's page shows those as read-only detail, and
      changing any of them is this `PATCH`.
    </Warning>
  </Tab>

  <Tab title="API">
    ```bash theme={null}
    PATCH /v1/instance-pools/{pool_id}
    { "desired_count": 4,
      "template": { "...": "the whole launch config" } }
    ```
  </Tab>
</Tabs>

<Warning>
  **A new `template` replaces the stored one wholesale.** Anything you leave out
  is cleared, not kept — replacement rather than a deep merge, so a shorter
  `networks` or `data_volumes` array cannot be read as a truncation and silently
  drop an interface or a disk. Send the whole config you want.
</Warning>

A template change decides what the pool launches **next**. The instances
already running keep what they booted with, because a live VM cannot change
flavor, tier, subnet or its tags in place.

So between the edit and a roll, the pool legitimately holds members from two
different templates. `stale_instance_count` is how many are on the older one,
and a non-zero value is the signal that a template change has not been rolled
out yet.

<Note>
  This is the same split between "edit the template" and "replace the
  instances" that a `PATCH` silently replacing every running member would erase
  — a destructive operation wearing the shape of an edit.
</Note>

### Rolling the pool

```bash theme={null}
POST /v1/instance-pools/{pool_id}/refresh
```

<Note>
  **Rolling a pool is API only.** No console control replaces a pool's members
  — the header's **Refresh** is the icon that reloads the page's data, not a
  roll of the fleet. After editing a template in the console, `POST
      .../refresh` is what carries the change onto the instances already running.
</Note>

It answers `202` with the pool as it stands, and replaces every member not
launched from the current template — including any that predate template
tracking. Asynchronous, and deliberately so: each replacement is a VM boot, and
a request that waited would time out long before a pool of any size finished.

<Steps>
  <Step title="One member per pass">
    The reconciler retires one stale member at a time, oldest slot first, so
    the roll walks the pool in a predictable order.
  </Step>

  <Step title="And only once the pool is whole">
    The next replacement waits until the pool is back at size with every member
    running. A template that does not boot therefore **stalls the roll with the
    pool intact**, instead of walking it down one instance at a time.
  </Step>

  <Step title="Capacity does not dip">
    For the length of the roll the pool runs one instance over its target, so a
    replacement is already serving before anything is retired. `desired_count`
    is not touched — the surge is derived, not written into what you asked for.
  </Step>
</Steps>

<Warning>
  **A pool already at `max_count` has nowhere to grow, so it replaces in
  place** — retiring a member before its replacement is up, and dipping by one
  for each. Leave headroom between `desired_count` and `max_count` at create if
  you want rolls that never reduce capacity.
</Warning>

Watch `refresh_in_progress` and `stale_instance_count` for progress. The pool
reads `scaling` for the duration and flips back to `active` only when nothing
is stale — calling it settled mid-roll would tell you a refresh had finished
while most of it had not started.

Asking again while a roll is running is accepted and does not restart it.

### Two sets of tags

A pool carries two tag maps and they answer different questions.

<Columns cols={2}>
  <Card title="tags" icon="tag">
    Labels the **pool resource**. Read by IAM conditions as
    `basalt:ResourceTag/<key>` and used for cost attribution. Takes effect
    immediately, touches no instance, and replaces the whole set — an empty
    object clears them, an omitted field leaves them alone.
  </Card>

  <Card title="template.tags" icon="tags">
    Stamped on **every replica the pool launches**. Part of the launch config,
    so changing it affects future launches only and needs a refresh to reach
    what is already running.
  </Card>
</Columns>

Editing `template.tags` alone is the easiest way to end up with a pool whose
members carry two different tag sets — which matters if an IAM policy or a cost
report keys on them. `stale_instance_count` is how many are still on the old
set.

## One address for the whole pool

`POST /v1/instance-pools/{pool_id}/floating-ips` binds a floating IP you
already allocated to the pool. One public IP, answered by every replica — an
anycast address — as opposed to `template.assign_public_ip`, which gives each
replica its own.

<Tabs>
  <Tab title="Console">
    The pool's **Floating IPs** tab lists its shared addresses and offers
    **Attach floating IP**, which picks from the addresses you hold that are
    attached to nothing.

    The tab explains the same asymmetry as the note below, under the heading
    **One address, every replica**: the address's members are one per
    hypervisor, so the member count can be lower than the pool's size.
  </Tab>

  <Tab title="API">
    ```bash theme={null}
    POST /v1/instance-pools/{pool_id}/floating-ips
    { "floating_ip_id": "3f9a1c7e-5b2d-4e8a-9c1f-6d3b7a2e5c9f" }
    ```
  </Tab>
</Tabs>

<Info>
  **The operation names the pool because the pool is what makes the address
  work.** Each member's translation rule is matched per hypervisor, so two
  members on one hypervisor both match there: one answers and the other
  receives nothing, with no signal anywhere that half the capacity is dark.

  A pool spreads its replicas across hypervisors, and the address's members are
  derived from the live ones — one per hypervisor. A replica sharing a
  hypervisor with an existing member is **left out** rather than added and
  silently starved, so the address can have fewer members than the pool has
  replicas. `members` on the floating IP says which.
</Info>

Membership is maintained for you: a scale-out joins, a scale-in leaves, a
replaced member is swapped. There is no per-replica attach to make, and the
network service's own attach and detach are refused on a pool's address —
use these two endpoints.

### It is not a load balancer

With more than one member, the region's edge picks **one member per
connection**, by hashing the flow's addresses and ports, and every packet of
that connection goes to the same one. That spreads connections across
independent instances and survives the loss of a host.

<Warning>
  It does none of the things a load balancer does. There is no health checking
  of what runs inside the instance, so a replica whose application is broken
  keeps receiving its share of connections. Connections in progress to a member
  that goes away are not moved — they end. And there is no TLS termination, no
  request routing, and no way to weight members.
</Warning>

### Requirements and removal

The floating IP must be **unattached** and yours, and the pool's subnet must
already route `0.0.0.0/0` to an internet gateway. Attaching is idempotent:
re-attaching the same address to the same pool returns it unchanged. A `409`
means the address is already attached to something, or already belongs to
another pool.

`DELETE /v1/instance-pools/{pool_id}/floating-ips/{floating_ip_id}` stops
routing the address to the pool. In the console it is the row action on the
**Floating IPs** tab, confirmed as **Detach floating IP**.

<Note>
  **The address is not released.** You allocated it, it stays yours and
  unattached, to reuse or to release with
  `DELETE /v1/floating-ips/{floating_ip_id}`. Detaching one the pool does not
  hold answers `204`.
</Note>

`GET /v1/instance-pools/{pool_id}/floating-ips` lists the shared addresses with
their current members. Per-replica addresses are not here — those belong to the
replica and are read from the instance's
[NIC listing](/compute#network-interfaces).

## Deleting a pool

<Tabs>
  <Tab title="Console">
    **Delete pool** is on the pool's **Settings** tab, in the danger zone. The
    confirmation restates the cost — *terminates every VM in the pool* — and
    needs the pool name typed back.
  </Tab>

  <Tab title="API">
    ```bash theme={null}
    DELETE /v1/instance-pools/{pool_id}
    ```
  </Tab>
</Tabs>

It tears down every instance the pool owns and drops the pool. It is
idempotent, and answers `204`.

Deleting the pool is the only way to remove its members: deleting a replica
directly just frees its sequence number, and the pool spawns a replacement for
it on the next pass.

## Permissions

Every pool operation authorizes against `crn:compute:<region>:<account>:instance-pool/<id>`
— that is the value an IAM policy statement must name to scope a permission to
one pool.

<Note>
  A refresh authorizes as **`compute:UpdateInstancePool`**, the same action as
  a `PATCH`, not as an action of its own. Granting someone the ability to edit
  a pool therefore also grants them the ability to roll it, which replaces
  every running member.
</Note>

Replicas are launched as the principal that created the pool, so that
principal's `compute:CreateInstance` permission — and its `iam:PassRole` on
`template.iam_role_id`, if the template carries one — is what every later heal
and scale-out runs under. See [policies](/iam/policies) and
[roles](/iam/roles).

## Troubleshooting

<AccordionGroup>
  <Accordion title="The pool says active but capacity is down" icon="activity">
    `active` means `member_count == desired_count`, not that the members are
    up. Read `live_count`. A gap between them is members that exist and are not
    running — stopped, still booting, or wedged — and the pool does not replace
    a stopped member.
  </Accordion>

  <Accordion title="A refresh is not progressing" icon="loader">
    The roll waits for the pool to be back at size with **every** member
    running before retiring the next one. If the new template does not boot,
    the roll stalls there by design rather than emptying the pool — check the
    newest replica's `fault` and its
    [console output](/compute#console-access).

    `stale_instance_count` stops falling as soon as that happens, and
    `refresh_in_progress` stays true.
  </Accordion>

  <Accordion title="I edited the template and nothing changed" icon="git-branch">
    Expected. A template change decides what the pool launches next; the
    instances already running keep what they booted with. `stale_instance_count`
    counts them, and `POST /v1/instance-pools/{pool_id}/refresh` rolls them.
  </Accordion>

  <Accordion title="The template lost a NIC or a data volume" icon="triangle-alert">
    `template` on a `PATCH` replaces the stored config wholesale — anything
    omitted is cleared. Read the pool back, edit the whole `template` object
    you got, and send all of it.
  </Accordion>

  <Accordion title="The shared address has fewer members than the pool has replicas" icon="globe">
    Working as designed. Members are one per hypervisor, so replicas that share
    a hypervisor with an existing member are left out rather than added and
    silently receiving nothing. Spreading is best effort, so a pool larger than
    the fleet — or one scaled up when the fleet is full — can land co-resident
    replicas.
  </Accordion>

  <Accordion title="Attaching a floating IP to the pool answers 400" icon="circle-x">
    Three causes, and the message says which: the id is missing or malformed;
    the pool's subnet has no default route to an internet gateway; or the
    region does not have shared pool addresses switched on. A `409` is
    different — that is an address already attached to something else, or
    already held by another pool.
  </Accordion>

  <Accordion title="A replica I deleted came back" icon="rotate-cw">
    The pool converges toward `desired_count`, so deleting a member is read as
    drift and refilled at the freed sequence number. Lower `desired_count`, or
    delete the pool.
  </Accordion>

  <Accordion title="Creating the pool answers 400 on min or max" icon="ruler">
    `desired_count` must sit within `min_count` and `max_count`, and none may
    exceed the platform cap of 100. Both bounds default to `desired_count` when
    omitted and are fixed for the pool's life — a pool created without
    `max_count` cannot later grow past the size it was created at.
  </Accordion>
</AccordionGroup>

## Next

<CardGroup cols={2}>
  <Card title="Instances" icon="server" href="/compute">
    Everything a replica is: flavors, images, disks, interfaces and the
    lifecycle.
  </Card>

  <Card title="Images" icon="disc" href="/compute/images">
    Pinning a build so a pool's replicas stay identical.
  </Card>
</CardGroup>
