> ## 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.

# Images and keypairs

> Import a disk image, move a name between builds, and manage the SSH keys an instance is launched with.

An image is the disk an instance's boot volume is cloned from. A keypair is the
SSH public key baked into it at launch. Both are regional, both live in the
compute service, and both are named things an IAM policy can wildcard.

<CardGroup cols={2}>
  <Card title="Names are movable tags" icon="tags" href="#a-name-is-a-tag-a-version-is-a-build">
    How `debian-13` and `debian-13:20260807` differ, and when the tag moves
    under you.
  </Card>

  <Card title="Importing" icon="upload" href="#importing-an-image">
    Presigned URL in, background conversion, and what the status column tells
    you.
  </Card>

  <Card title="What the listing shows" icon="list-filter" href="#what-list-images-returns">
    Why a build you published yesterday is missing, and the flag that brings
    it back.
  </Card>

  <Card title="Keypairs" icon="key-round" href="#keypairs">
    Generate or import, the one-time private key, and what deleting one does
    not do.
  </Card>
</CardGroup>

## Images

### Public and private

`GET /v1/images` returns your own images plus the **public platform catalog** —
the maintained OS images every account can boot. Platform images carry no
account in their CRN (`crn:compute:<region>::image/<id>`), so they read the same
for everyone.

<Warning>
  **`visibility: "public"` on an image you own does not share it.** Only images
  in the platform account are visible across accounts; an image of yours stays
  private to your account whatever its visibility says. The field is stored,
  returned and filterable — it just is not a sharing mechanism, and there is no
  cross-account image sharing today.
</Warning>

### A name is a tag, a version is a build

Every image row has a `name` and a `version`, and they do different jobs.

<Columns cols={2}>
  <Card title="name" icon="tag">
    A **movable tag**, shared by every build behind it. `debian-13` points at
    whichever build is current for its `(name, architecture)`.
  </Card>

  <Card title="version" icon="fingerprint">
    Identifies **one build** within that name, and must be unique there. Omit
    it on import and the server stamps a UTC timestamp, so every build is
    addressable whether or not you labelled one.
  </Card>
</Columns>

That gives three ways to name an image at launch, and the choice is a choice
about reproducibility:

| Reference            | Resolves to                                                    |
| -------------------- | -------------------------------------------------------------- |
| `debian-13`          | Whatever is current **at the moment the instance is created**. |
| `debian-13:20260807` | That build, forever.                                           |
| A UUID               | That build, forever.                                           |

`is_current` on the image says whether it is the name's current target. Older
versions stay fully bootable by id and by `name:version` — promotion moves a
pointer, it does not retire anything.

<Note>
  Publishing a version a tag already carries is a `409`, not a second anonymous
  build. The error says which half to change: rename and you publish a second
  tag for the same release, which is the mess the constraint exists to prevent
  — change the **version** instead.
</Note>

### Importing an image

No image bytes flow through the API. You upload the disk to a bucket you
control — the Basaltic object store, S3, MinIO, anything — with a real
multipart S3 client, then hand over a presigned GET URL.

<Note>
  **Importing is API only.** **Compute → Images** lists what you can boot and
  opens each row, but it has no create or import control — there is nowhere in
  it to hand over a `source_url`. Once an import is registered the row shows up
  in that listing like any other, with its **Status** column tracking it.
</Note>

<Steps>
  <Step title="Register the import">
    ```bash theme={null}
    POST https://compute.sa-saopaulo-1.basaltic.sh/v1/images
    {
      "name": "app-base",
      "version": "20260807",
      "format": "qcow2",
      "source_url": "https://bucket.s3.example.com/app-base.qcow2?X-Amz-Signature=...",
      "os": "debian",
      "os_version": "13",
      "min_disk_gb": 10
    }
    ```

    The response is **`202`** with `status: "importing"`. Accepted formats are
    `qcow2`, `raw`, `vmdk`, `vhd`, `vhdx` and `vdi`; all are converted to a raw
    base on import, and `format` describes what is at `source_url`.
  </Step>

  <Step title="Wait for the fetch and conversion">
    A worker fetches the URL once, converts the disk and imports it. The URL is
    not retained afterwards, so it only has to stay valid long enough to be
    read.
  </Step>

  <Step title="Poll until active">
    ```bash theme={null}
    GET /v1/images/{image_id}
    ```

    `active` means bootable, and `size_bytes` is filled in from what was
    actually written. `error` means the import gave up; `import_error` says
    why.
  </Step>
</Steps>

<Warning>
  **`source_url` must be `https`, and must not resolve to a private address.**
  Loopback, RFC 1918 and ULA private ranges, link-local and metadata addresses
  are all refused — on the original URL and again on every redirect, against
  the address it actually resolves to. A bucket reachable only inside your VPC
  cannot be imported from; presign from something publicly resolvable.
</Warning>

<Info>
  `import_error` appearing does not mean the import has stopped. Each failed
  attempt writes the reason onto the row so a customer polling it can see
  what is going wrong, and the import is retried. The row reaching `error` is
  what says nothing further will be attempted.
</Info>

#### Publishing without switching the tag

`current` defaults to `true`: a completed import becomes the name's current
version and future launches of that bare name boot the new bits. Send
`"current": false` to stage a build without switching, then promote it later:

```bash theme={null}
PATCH /v1/images/{image_id}
{ "current": true }
```

Promotion is atomic — whatever else was current for that `(name, architecture)`
is demoted in the same operation. The same call is how you **roll back**: point
the name at the older build and launches follow it immediately.

<Note>
  The switch happens when the import **completes**, not when it is accepted. A
  new build is `importing` for as long as the conversion takes, and the tag
  keeps pointing at the previous build for all of it — so publishing over a
  name in use never leaves it resolving to something that cannot boot.

  Only an `active` image can be made current; asking to promote one that is
  still importing is a `400`.
</Note>

<Warning>
  **Promotion and rollback are API only.** An image's page in the console edits
  its **Name**, **Description**, **Visibility**, **Tags** and **Attributes**,
  and nothing else — there is no control that moves the tag. The listing shows
  you where the tag points, as a **current** badge on the build it resolves to,
  but moving it is this `PATCH`.
</Warning>

### What `List images` returns

`GET /v1/images` is a listing of **what is worth launching**, not of every row
you own. One tag normally contributes one entry.

A build drops out only when a **newer build holds its name**. That is
superseded history, and nothing else — the predicate is deliberately narrow:

<AccordionGroup>
  <Accordion title="Still listed: anything importing or errored" icon="loader">
    Whatever its age. These are rows you have to deal with — an import you are
    waiting on, or one that failed and is still holding an image slot until you
    delete it.
  </Accordion>

  <Accordion title="Still listed: a newer version staged with current: false" icon="git-branch">
    "Not current" is the wrong test on its own. A version you staged
    deliberately, and a version you rolled back *from*, are both not current
    and both still yours to act on. Only being superseded by something newer
    takes a build out of the listing.
  </Accordion>

  <Accordion title="Never listed: hidden images" icon="eye-off">
    A deleted image, or a platform release the catalog withdrew, is hidden and
    excluded — from every listing, whatever you filter on. Passing
    `?status=hidden` returns nothing rather than revealing them, because every
    other endpoint answers `404` for a hidden image.
  </Accordion>
</AccordionGroup>

Pass `all_versions=true` for a tag's whole history. Results are ordered by
name, and paginate through `meta.marker` like every other listing.

The other filters are `os`, `architecture`, `visibility`, `status` and `name`
— which is a substring match, not a prefix or an exact one.

### End of life

`eol_date` records the day an OS release stops receiving free security updates
for a default install. Absent means **nobody has recorded one**, which is not
the same as supported indefinitely.

Omit `eol_date` on a new build and it inherits the date the name's current
version carries, so republishing a tag cannot quietly stop tracking its
release. An explicit `null` on `PATCH` clears it; omitting the field leaves it
alone.

<Warning>
  **Platform images are withdrawn from the catalog a grace period after their
  `eol_date`.** They stay bootable by id until then, and the date is published
  well ahead of it so you can plan the move.

  After withdrawal, resolving the bare name fails with a message naming the
  release and the date it ended — so a launch that suddenly cannot find
  `some-distro-11` tells you why, rather than looking like a typo. Anything
  pinning `name:version` or an id of a withdrawn platform image also stops
  resolving, because withdrawal hides every build of the tag.
</Warning>

An `eol_date` on **your own** image is stored and shown, and nothing acts on
it. The platform never hides an image you own — there is no un-hide, so
withdrawing your image would be a silent, unrecoverable delete of something you
still hold.

### Deleting an image

<Tabs>
  <Tab title="Console">
    Open the image from **Compute → Images** and choose **Delete**. The
    confirmation says what survives — *Deleting removes it from the catalog.
    Instances already launched from it keep running* — and needs the image name
    typed back.

    The control only appears on an image you own. A platform image has no
    **Delete** button, and no edit cards either.
  </Tab>

  <Tab title="API">
    ```bash theme={null}
    DELETE /v1/images/{image_id}
    ```
  </Tab>
</Tabs>

It answers `202` and marks the image hidden, so clones already in flight can
finish. The underlying data is reclaimed as soon as nothing is cloned from it — immediately when no instance was built from the
image, and by a background sweep otherwise.

<Note>
  Superseded builds of **your own** images are never reclaimed for you. Every
  build you publish holds an `images` slot and its bytes against
  `image_storage_gb` until you delete it — so a pipeline that publishes on
  every commit needs a deletion step, or the quota becomes the deletion step.
</Note>

Deleting an already-deleted image is a `404`. A platform image the catalog
withdrew at end of life keeps its data on purpose, so deleting it by id is how
you ask for that data to go.

### Sizes

<ResponseField name="min_disk_gb" type="enforced floor">
  The smallest boot volume that can hold the image. Defaults at import to the
  image's virtual size rounded up. A launch or reinstall asking for less is
  refused.
</ResponseField>

<ResponseField name="min_ram_mb" type="recorded, not enforced">
  What the image is documented to need. It is stored and returned for you to
  read; nothing stops an instance booting on a flavor below it.
</ResponseField>

## Keypairs

A keypair is an SSH public key stored under a name in your account. A name is
1–128 characters of letters, digits, dot, dash and underscore, starting and
ending alphanumeric, and unique per account. It goes straight into the CRN —
`crn:compute:<region>:<account>:keypair/<name>` — so an IAM policy can fence a
naming convention rather than a list of ids.

<Tabs>
  <Tab title="Console">
    Go to **Compute → Key Pairs** and choose **Import / create key pair**.
    Give it a **Name**, then pick the **Key source**: **Generate new key pair**,
    or **Import public key**, which opens a **Public key** box to paste into.
  </Tab>

  <Tab title="API">
    ```bash theme={null}
    POST https://compute.sa-saopaulo-1.basaltic.sh/v1/keypairs
    ```

    Whether you get a generated key or store one of your own is decided by a
    single field — see the two bodies below.
  </Tab>
</Tabs>

<CodeGroup>
  ```json Import a key you already have theme={null}
  {
    "name": "my-keypair",
    "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... me@laptop"
  }
  ```

  ```json Have one generated theme={null}
  {
    "name": "ci-deploy"
  }
  ```
</CodeGroup>

Import accepts the ordinary `authorized_keys` line shape — `ssh-ed25519`,
`ssh-rsa`, `ecdsa-sha2-nistp256`, with or without a trailing comment.

<Warning>
  **Omit `public_key` and a keypair is generated for you — and the private key
  is returned once, in the `201` response, and never stored.** There is no
  endpoint that returns it again. Save it before you do anything else with the
  response, or delete the keypair and create another.

  The console makes the same one-shot explicit: generating lands you on **Key
  pair created**, headed **Save your private key now**, with the **Private
  key** shown for copying or **Download .pem**. Leaving that page is the point
  of no return.
</Warning>

Generated keys are **ed25519**. `fingerprint` is the SHA256 form (`SHA256:…`),
which is what `ssh-keygen -lf` prints by default.

### Using a keypair

Name keypairs at launch with `key_names`, and their public keys are written
into the instance's cloud-init seed. Several are fine — every named key ends up
in the default user's `authorized_keys`.

<Warning>
  **Deleting a keypair does not revoke access to instances already running.**
  The key is baked in at launch, exactly as it is on AWS: the instance never
  consults the keypair again, so removing the record removes it from future
  launches only. To cut off access to a running instance, remove the key inside
  the guest.

  This is true of the console's **Delete key pair** too — it removes the
  record, not the access.
</Warning>

The keypairs an instance was launched with are embedded in the instance itself,
so `GET /v1/instances/{instance_id}` shows them without a second call.

## Troubleshooting

<AccordionGroup>
  <Accordion title="An image I just published is not in the listing" icon="list-filter">
    Check whether a newer build holds the same name. The default listing shows
    one entry per tag, and drops builds a newer one has superseded. Add
    `?all_versions=true` to see the history — the build is still there and
    still bootable by `name:version` or by id.
  </Accordion>

  <Accordion title="Launches picked up a different image than last week" icon="git-branch">
    A bare `name` follows the tag, and the tag moves when a new build is
    published as current. Pin `name:version` or an id in anything that has to
    be reproducible; keep the bare name for "always the latest".
  </Accordion>

  <Accordion title="A name stopped resolving" icon="circle-x">
    Two causes. A platform release past end of life has been withdrawn — the
    error names the release and the date. Or the name has no current version,
    which happens after a `PATCH` with `current: false` on the only current
    build: the tag then points at nothing, though every build behind it still
    launches by `name:version`. Promote one to fix it.
  </Accordion>

  <Accordion title="An import sits in importing forever" icon="loader">
    Read `import_error` — a failed attempt records the reason on the row while
    retrying. The usual causes are a presigned URL that expired before the
    fetch, a `format` that does not match the bytes at the URL, or a host that
    resolves to a private address and is refused.

    Note that an import which finishes but exceeds your `image_storage_gb`
    quota is refused and **not** retried: rebuilding the same bytes would cost
    a full download and conversion to reach the same answer.
  </Accordion>

  <Accordion title="POST /v1/images answers 409" icon="copy">
    That name already carries that version. A name is shared by every build
    behind it on purpose, so publish under a new `version` rather than a new
    name.
  </Accordion>

  <Accordion title="I cannot see another account's image" icon="eye-off">
    That is the design. Only the platform catalog crosses accounts;
    `visibility: "public"` on your own image does not share it, and an image id
    belonging to another account answers `404` rather than `403`, so the API
    never confirms it exists.
  </Accordion>
</AccordionGroup>

## Next

<CardGroup cols={2}>
  <Card title="Launching instances" icon="server" href="/compute">
    Flavors, boot volumes, cloud-init and the instance lifecycle.
  </Card>

  <Card title="Instance pools" icon="layers" href="/compute/instance-pools">
    Where an image reference is resolved once, at create, and every replica
    boots the same build.
  </Card>
</CardGroup>
