# VMs: sizing, disks, addressing, and access

A VM is a plain Linux machine on NVMe with one public IPv4, built from a stock
image we keep patched. Nothing is installed on it that we chose —
configuration is code you re-run. Every machine runs in our single site, in
**Los Angeles, US**; there is no region field. For the walkthrough that gets
you your first one, start with [Onboarding](onboarding.md).

Storage is documented here: a root disk is part of a VM, and a volume, though
it can sit detached, is useful only in one of a VM's disk slots.

## What you are running on

**One site, Los Angeles, US.** There is no region field and nothing to pick,
and no second region to fail into.

**AMD EPYC Milan (Zen 3).** That is `x86-64-v3` — AVX2 and everything below it,
no AVX-512. Pick binaries on that baseline; an `x86-64-v4` build faults on
first use of an instruction the CPU does not have.

**A 10 Gbps network**, shared with other VMs. Egress is metered per GB and
priced in the price book (`GET /v1/billing/prices`); inbound is free.

**Storage is redundant on root disks and volumes alike**, so you do not need to
build replication. That is not backup. It does nothing about a delete you
issued yourself, which is what [snapshots](#snapshots) are for.

## Sizing: two numbers, and the CPU follows

```
POST /v1/vms {"name":"web1","ram_mb":4096,"disk_gb":40,
              "image":"ubuntu-lts","ssh_keys":["ssh-ed25519 AAAA..."]}
```

| Field | Minimum | Step | Notes |
|-------|---------|------|-------|
| `ram_mb` | 1024 | 1024 | max 262144 |
| `disk_gb` | 24 | 8 | max 8192; **never resizable after creation** |

**The vCPU count is derived, not chosen**: `ceil(ram_gb / 2)`. A 1–2 GB VM
gets 1 vCPU, 4 GB gets 2, 16 GB gets 8. Omit `cpu`: the field is optional, and
a value disagreeing with the formula is `400 cpu_derived`. The
price book (`GET /v1/billing/prices`) prices RAM and nothing for vCPU.

Resize with `POST /v1/vms/{id}/actions {"type":"resize","ram_mb":8192}`; the
vCPU count follows. **Stop the VM first** — a resize on a running VM is
`409 vm_not_stopped`. You can size down as well as up, but nothing in the
guest is adjusted for you: bring your services' own memory settings down
before you shrink the machine, or they are killed on the way back up. The
root disk is never resizable — see below.

## Two kinds of disk

They differ in speed as much as in lifetime, and speed is usually what decides
where something goes.

- **Root disk** — local NVMe, the fast tier. The OS, databases, and anything
  else doing small random reads and writes belong here. It lives and dies with
  the VM, and you pick its size at creation: **it can never be resized
  afterwards**.
- **Volumes** — bulk storage, reached over the network. Slower per operation
  and cheaper per GB, so it holds what grows: uploads, archives, logs, backups,
  datasets. Attachable, detachable, growable, and they **survive VM deletion**,
  so anything that has to outlive the machine belongs on one.

A database on the root with its backups on a volume is the normal shape.

### The root disk is fixed at creation

`disk_gb` is the one decision you cannot change later. Oversize it — the fast
tier prices above bulk storage (both are in the price book,
`GET /v1/billing/prices`), and the extra GB still cost less than a recreate —
and treat the root as disposable: a VM whose root is full or too small is
[recreated](best-practices/recreate-vm.md), not resized.

Root disks start at **24 GB and move in 8 GB steps**. Round up.

### Volumes

```
GET    /v1/volumes                     yours, attached or not
GET    /v1/volumes/{id}
POST   /v1/volumes                     {"name":"data1","size_gb":500}
POST   /v1/volumes/{id}/actions        {"type":"attach","vm_id":"vm_..."}
POST   /v1/volumes/{id}/actions        {"type":"detach"}
POST   /v1/volumes/{id}/actions        {"type":"resize","size_gb":1000}
DELETE /v1/volumes/{id}
```

- **A volume is created detached, and its life is its own.** You don't need a
  VM to make one, it survives every VM you attach it to, and it disappears
  only when you delete it. Attaching is a separate call.
- **Attach is hot; detach is not.** You can attach a volume to a running VM —
  the device shows up live, ready to partition and mount. Detaching needs the
  VM `stopped` (`409 vm_not_stopped`): unmount in-guest, stop, detach.
- **A volume stays with the VM whose snapshots include it.** Detaching it
  answers `409 volume_in_snapshots` and names those snapshots in the
  `snapshots` extra; delete them first. A snapshot can restore a volume only
  while the volume is still on the VM that took it, so moving a volume means
  giving up the snapshots that include it. Snapshots taken before you attached
  it don't count.
- Neither call moves data: a 500 GB volume and an 8 TB one attach in the same
  instant.
- Volumes are sold in **500 GB steps, starting at 500 GB** — `size_gb` must be
  a multiple of 500 on create and on grow.
- **Growing works live.** Resize an attached volume while the VM runs; the
  guest sees the new size immediately, then you grow the filesystem in-guest
  (`growpart` + `resize2fs` for ext4, `xfs_growfs` for xfs). No stop, no
  detach.
- Growing is one-way. There is no shrink, at any size.
- **Deleting requires the volume detached** (`409 must_detach_first`), and it
  destroys the data.
- A volume bills from creation, attached or not — it occupies provisioned
  space either way. Billing stops at delete, not at detach.
- Volumes attach to one VM at a time (block devices, not a shared filesystem).
- **30 volumes per VM** — the disk-slot ceiling, reported as
  `409 vm_slots_full`. If you need more, add a VM.
- Two refusals mean somebody else moved first. `409 volume_moved` says the
  volume was attached, detached or deleted between your read and your write:
  re-read `GET /v1/volumes/{id}` and decide again, because a blind retry will
  not fix it. `409 volume_already_grown` says another resize passed the size
  you asked for; retry only with a larger number. Neither applied anything.

**A new volume arrives unformatted**, and what you do with it once is
in-guest: find it with `lsblk` (a disk with no partitions), make a filesystem
on it, then read its UUID with `blkid`.

**Mount it by UUID in `/etc/fstab`, never by device name.** The kernel names
disks in the order it finds them, and that order changes when you attach
another volume or [recreate](best-practices/recreate-vm.md) the machine — so a
`/dev/sdb` in `fstab` eventually mounts the wrong disk or none. Add `nofail`
while you are there: without it a volume that is missing at boot stops the
machine before SSH comes up, and there is no console to fix that from.

### Snapshots

```
GET    /v1/vms/{id}/snapshot                    list, newest first
POST   /v1/vms/{id}/snapshot                    {"name":"before-upgrade"}
DELETE /v1/vms/{id}/snapshot/{name}
POST   /v1/vms/{id}/snapshot/{name}/rollback    newest only; VM must be stopped
```

- **Snapshots cost money while they exist.** A snapshot is charged for the
  storage it covers — the root disk, plus every volume attached when you took
  it — at **half** the rate those disks bill at, for as long as you keep it.
  Snapshotting a VM with a 100 GB root and a 500 GB volume therefore adds
  half the price of a 100 GB root disk plus half the price of a 500 GB volume
  to your bill. Rates are in the price book (`GET /v1/billing/prices`).
  Billing is per snapshot, so `GET /v1/usage?group_by=resource` tells you
  which one to delete. Deleting
  it stops the charge within the minute.
- **VM snapshots** (`POST /v1/vms/{id}/snapshot`) cover the root **and all
  attached volumes** at once. They're **crash-consistent** — the same thing
  your filesystem sees after a power cut, which ext4/xfs and any crash-safe
  database recover from cleanly. Memory isn't captured, so a rollback is a
  boot, not a resume.
- **Rollback needs the VM stopped**, and leaves it stopped — it replaces the
  disk under the guest, so it can't run while that happens. Start it yourself
  when you're ready. Rollback is destructive of later state.
- **You can only roll back to the newest snapshot.** Snapshots form a stack,
  not a tree: to reach an older one, delete every snapshot taken after it
  first, newest first. Attempting it otherwise fails with the hypervisor
  saying more recent snapshots exist. Deleting those later snapshots is
  irreversible, so decide the restore point before you start deleting: if you
  snapshot before each step of a long job, going back three steps costs you
  the two in between.
- **A rollback restores the machine's size too**, since the snapshot recorded
  it. If you've resized since, you get the snapshot's RAM back and your bill
  follows the machine you actually have. Growing that way is checked against
  your quota first.
- **The same volumes must still be attached.** A snapshot records which
  volumes were attached, and a rollback reapplies that layout — so if you've
  attached one since, you get a `409 snapshot_volumes_changed` naming it.
  Detach it, or snapshot again and roll back to that. The volumes a snapshot
  includes can't have gone anywhere: they stay attached until it is deleted.
- **A volume you grew since goes back to its old size**, with the data it held
  at snapshot time — filesystem included, so it comes back consistent. Your
  bill follows it down. This is the one case where a volume gets smaller, so
  check it before rolling back a VM whose data disk you have since expanded.
- **There are no per-volume snapshots**, and no endpoint for them. Snapshot
  the VM while the volume is attached, or back up in-guest (restic/borg to
  another VM or external storage). A snapshot is stored alongside the disk it
  covers, so it is not a backup.

## The IP is fixed to the VM

Every VM gets one public IPv4 from our own pool at creation. It stays with
that VM until the VM is destroyed, then returns to the pool. No endpoint
reassigns an address.

It bills on the `ip-hours` meter, priced in the price book
(`GET /v1/billing/prices`), for as long as you hold it, including while the VM
is stopped. Deleting the VM returns it to the pool and ends the charge.

So **put a DNS name in front of every VM** rather than writing its address
down anywhere. Keep the TTL low (60s is plenty) and a recreate costs a minute;
both VMs exist during the cutover, so you can verify the new one before the
old address goes away. If you need a specific address moved, open a ticket
(`POST /v1/support/tickets`).

Your addresses are in `GET /v1/vms` — each machine carries its own `ip`. There
is no separate address list.

## Firewall: one platform rule, the rest is yours

The only rule we enforce is **anti-spoof** — a VM may emit only from its own
IP and MAC. Nothing else is filtered and there is no firewall API. Run
`nftables` or `ufw` in-guest; you own the policy.

Your VM's network reaches the Internet and nothing of ours. Everything
arriving on your ports is yours to filter.

Two things the platform does decide for you:

- **Outbound TCP `:25` is blocked** — at the network, upstream of your VM, so
  no in-guest configuration changes it. You cannot run your own mail server
  that delivers to receiving servers directly; send through the
  [relay](email.md) instead. Submission ports (`587`, `465`) outbound to
  third parties are not blocked.
- **No rDNS/PTR management.** Ask via a ticket if you need one; there is no
  API for it today.

## Access is SSH, and only SSH

Pass at least one public key at creation — there is no password, no console,
and no web reset path. Log in as `ubuntu`.

**A firewall or sshd change you haven't tested can brick the VM
permanently**, with no out-of-band door to fix it through. Test changes before
you commit them, and treat a locked-out VM as a
[recreate](best-practices/recreate-vm.md), not a rescue.

## Lifecycle

```
GET    /v1/vms                yours, with power read live
GET    /v1/vms/{id}
POST   /v1/vms/{id}/actions   {"type":"start"|"stop"|"reboot"|"resize"}
DELETE /v1/vms/{id}
GET    /v1/vms/{id}/events    what happened, in order
```

A VM carries **two separate fields, and there is no `state`**: `status` is the
platform's view of the record (`active`), and `power` is read live from the
hypervisor on every request — `running`, `stopped`, or `unknown` when the
hypervisor could not be reached, which `health: unreachable` says at the same
time. `power` is never cached.

**Creation blocks, and leaves the VM stopped**: `POST /v1/vms` builds the
machine and answers `201` with `power: "stopped"`, typically in about a
second. If it can't, you get the failure directly — `409 create_failed`,
carrying the hypervisor's own error text. Two refusals arrive before the
build starts rather than from it: `409 no_capacity_available` when there is no
room for that much RAM right now, which is not a quota and not raised by
raising one, and `409 ip_pool_exhausted` when no address is free.

Creating and starting are separate calls. Attach before the first boot if your
setup automation expects the disk to be there:

```
POST /v1/vms      {...}                             → 201, stopped
POST /v1/volumes  {"name":"data1","size_gb":500}    → 201, detached
POST /v1/volumes/{id}/actions {"type":"attach","vm_id":"vm_..."}
POST /v1/vms/{id}/actions {"type":"start"}
```

A failed create is **rolled back**: the partial VM is destroyed and its IP
returned, so there is nothing to clean up and nothing holding quota. Fix what
`detail` says and POST again.

**Every action works the same way**: it runs while you wait and the response
is the outcome — `200` when it's done, or the error itself (the hypervisor's
own text) when it isn't. There is nothing to poll; if a call fails, fix what
`detail` says and issue it again. `GET /v1/vms/{id}`
reports `power` live from the hypervisor whenever you want to look. `stop` is
graceful with a ~20-second bound (pass `"force": true` for an immediate hard
stop); `start` and `stop` are both idempotent, so a machine already in the
state you asked for answers `200` rather than an error.
**Stopping a VM stops its RAM and vCPU metering and nothing else**: the root
disk, the address, its snapshots and its volumes bill whether it runs or not,
so a machine you are done with is deleted rather than parked.
`GET /v1/vms/{id}/events` records what happened, in order — it is where to
look when a create fails. A VM that was running when the hypervisor under it
rebooted comes back up on its own.

Deleting a VM destroys the VM and its root disk, and releases its IP. It
destroys nothing else and tidies up nothing: delete the VM's snapshots first
(newest first), then stop it and detach its volumes, then delete it. Until you
do, `DELETE` answers `409 vm_has_snapshots` or
`409 vm_has_volumes` and lists what is still there. The order matters — a
volume can't be detached while one of the VM's snapshots includes it.

## Images

Stock images only; no custom image upload. `GET /v1/images` is the catalog and
the only source of truth for what is offered — Ubuntu LTS today, so check it
rather than assuming. Pin an `img_...` id for reproducibility, or use a name
alias like `ubuntu-lts` to follow the newest version under that name.

**Images have no status and no expiry date.** Everything listed is usable, and
a withdrawn image stops being listed, so `GET /v1/images` is the whole answer.

Anything custom happens in-guest after boot.

## What happens at zero balance

At balance ≤ 0, mutating calls stop (`402 credit_cutoff`) but **running VMs
are not killed** and disks keep their data. There is a grace period before
VMs are stopped and a longer one before any disk is deleted; top up inside it
and everything resumes. The timings:
[API conventions](api.md#errors-rfc-7807-problem-json).
