Skip to content

Custom Images

Upload your own VM disk image — a hardened base build, a golden image from your CI pipeline, an appliance — and launch instances from it. A custom image belongs to the tenant that uploaded it: only that tenant can see it, and only that tenant can boot from it. It appears next to the platform catalogue everywhere an image is chosen.

Pilot — access is granted per tenant

Custom images are rolled out as a pilot, available only to tenants for which the feature has been enabled. The refusal is server-side: without the entitlement every image upload answers 403 FEATURE_NOT_ENABLED, whether it comes from the portal, the CLI or Terraform. In the portal the Images section does not appear at all. Contact support to request access. Browsing and booting the platform catalogue is unaffected.

What you can upload

  • Formats: qcow2 and raw (container format bare). Nothing else — a vmdk, vhd or vdi has to be converted locally first, e.g. qemu-img convert -O qcow2 disk.vmdk disk.qcow2.
  • Up to 35 GiB uploaded — the file itself. A larger file is refused by the storage edge before the image is imported.
  • Up to 35 GiB expanded — the image's virtual size (see below). A larger one is refused at import.

Since a raw file is its own virtual size, a raw image is in practice bounded by the 35 GiB upload limit. Use qcow2 for anything larger.

A qcow2 is also refused if it declares a backing file, is encrypted, or is a version other than 2 or 3 — as is any file whose real format disagrees with the disk-format you declared. A backing file is the common one: an image built with qemu-img create -b, or captured from a snapshot chain, references another file the platform will never have. qemu-img convert -O qcow2 src.qcow2 flat.qcow2 flattens the chain into a standalone image.

The size that counts is the expanded size

A qcow2 is compressed and sparse, and Frostmoln converts it to raw while importing it. What gets stored is therefore the image's virtual size — and the virtual size is what your quota is charged for, what you are billed for, and what a flavor's root disk must be big enough to hold.

This surprises people: an 800 MB qcow2 describing a 10 GB disk becomes a 10 GB image. Check before you upload:

bash
qemu-img info debian-13.qcow2
# virtual size: 10 GiB (10737418240 bytes)   <- this is what counts
# disk size: 794 MiB                         <- this is only the upload

If the virtual size is larger than you need, build the image with a smaller virtual disk. (qemu-img resize --shrink can cut it afterwards, but only after shrinking the filesystem inside the image — otherwise you truncate it.)

Your image will be listed as raw

The conversion is why the size changes, and it changes the format too. Upload a qcow2 and, once the import completes, the platform reports the image as raw — the portal's Format column and fm compute image get both. That is not a mistake and nothing was lost: raw describes what the platform now stores, which is the expanded image the conversion produced. While the image is still Awaiting upload or Importing it reports the format you declared.

Terraform is the exception, and deliberately so: disk_format on a frostmoln_image is a value you configure, and a refresh never overwrites it, so your state keeps saying qcow2. (Overwriting it would make every apply fail with "provider produced inconsistent result after apply".) The image data sources do not report a disk format at all.

Frostmoln does not keep a record of the format you uploaded, so there is nothing to compare an image against later. Keep your own note of which build a given image came from — the description field is a good place for it.

Prepare the image before you upload it

An image is a whole filesystem, and everyone in your tenant can launch an instance from it and read it as root. Whatever the build left behind travels with it.

  • Strip credentials. SSH private keys, authorized_keys, cloud-init state (/var/lib/cloud), API tokens, registry logins, .env files, shell history, package-manager credentials.
  • Remove the SSH host keys (/etc/ssh/ssh_host_*) and truncate /etc/machine-id. If you leave them, every instance launched from the image presents the same host identity, and host-key verification stops telling your instances apart.
  • Leave cloud-init installed and enabled — on a Linux image. It is what installs your registered SSH keys, runs your user data, and applies a console password at first boot. A Linux image without it comes up with no way in — don't work around that by baking a password or a fixed authorized_keys into the image. A console password requested for such an image is accepted and then silently ignored, because there is nothing in the image to apply it — unless the image declares no distribution the platform recognises, in which case the launch fails instead. Windows is the exception to this bullet — see Windows images.
  • You own its patching. A custom image is frozen at the moment you upload it. Unlike the platform catalogue, nothing updates it for you: rebuild and upload a new one when its packages need patching.

Windows images

Frostmoln sets no credentials inside a Windows guest. SSH key injection, the console password and the managed agent are all cloud-init mechanisms, and there is no password-retrieval API — a console password is refused outright for an image that declares Windows. So for Windows the cloud-init bullet above is inverted: the credentials have to come from you. Windows is not a tested platform image; everything below is what the hypervisor requires of the image you build.

Build it BIOS/MBR, not UEFI. Instances boot on the i440fx machine type with a legacy BIOS; the platform carries no UEFI firmware, and the portal, CLI and Terraform expose no way to ask for UEFI or Secure Boot. A modern default build — Hyper-V Generation 2, or any UEFI/GPT disk — presents no bootable MBR and hangs on "no bootable device". Use Hyper-V Generation 1, or the equivalent BIOS/MBR layout in your build tool. Hyper-V also gives you a vhdx, which is not an accepted upload format: convert it first with qemu-img convert -O qcow2 disk.vhdx disk.qcow2.

Include the virtio drivers. The root disk is attached on the virtio bus and the NIC is a virtio model — that is the default for every instance, and the portal, CLI and Terraform give you no way to change either. Slipstream or install the virtio-win drivers before sysprep: viostor is boot-critical, NetKVM is the NIC. Without them Windows bugchecks 0x7B INACCESSIBLE_BOOT_DEVICE on first boot, or comes up with no network. There is no rescue path and no image property that fixes it afterwards: you re-build and re-upload.

Size the virtual disk yourself. New-VHD defaults to 127 GB dynamically expanding, and it is the virtual size the 35 GiB ceiling measures, however empty the file is — so a default Hyper-V build is refused at import even though the file you uploaded was small. Create the disk at 32 GiB or less.

Expect a UTC clock. The hypervisor is never told the guest is Windows — os_distro is a Frostmoln-layer signal, and the separate property Nova reads for this is not something the portal, CLI or Terraform can set — so the virtual RTC runs UTC and Windows will read it as local time. Set RealTimeIsUniversal (DWORD 1) under HKLM\SYSTEM\CurrentControlSet\Control\TimeZoneInformation in the image, or live with a clock offset.

Getting in

If your image carries cloudbase-init, prefer it. User data is still served to the instance, so cloudbase-init can consume it, and you can set the Administrator password from your own per-instance user data and give every instance a different one. Frostmoln neither sets that password for you nor tests this path — but nothing blocks it, and it avoids the shared secret below. Don't attach an SSH key to a Windows instance: no key ever reaches the guest, and doing so rewrites the user data cloudbase-init reads — merged into your #cloud-config, or re-wrapped as multipart — for nothing.

A password in user data is not hidden, though. User data is served unauthenticated to anything running in the guest, at 169.254.169.254, for the life of the instance, so any local account on it can read that password — and cloudbase-init also leaves it in its own logs. It is a bootstrap credential too: change it at first login, and keep it out of a launch template, whose user data anyone in your tenant can read back.

Otherwise bake the credentials into the image — set the Administrator password, or create an account, during sysprep / your unattended setup — and reach them over the console. That is the supported route, not a workaround. Be clear about what that secret is, though: a bootstrap credential, identical on every instance ever launched from that image, readable by everyone in your tenant who can boot it (see above), and deleting the image does not revoke it on instances already running. Change it at first login.

  • Strip the credential from where setup left it. Sysprep and unattended setup leave it behind in C:\Windows\Panther\unattend.xml, C:\Windows\System32\Sysprep\unattend.xml, C:\unattend.xml and C:\Windows\Panther\setupact.log; PlainText="false" is base64, not encryption. Never use AutoAdminLogon — it writes the password to the registry in clear text. The Windows counterparts of the strip-credentials bullet above apply too: Credential Manager, saved RDP credentials, ConsoleHost_history.txt, the product key.

Whichever route you take:

  • Choose a password you can type at a US keyboard. The console is a VNC session with an en-US keymap, so å, ä, ö — and anything else needing a Nordic layout — cannot be typed at it. Unlike Linux there is no SSH-key fallback, so an untypeable password means an unreachable instance. Use printable ASCII in US-keyboard positions, leave the image's keyboard layout at US until you have another way in, and remember Windows may lock the account after repeated failures.
  • Capture with sysprep /generalize /oobe /shutdown. It is the Windows form of removing the SSH host keys: without it every instance from the image shares one SID, one RDP certificate and one set of DPAPI machine keys.
  • Declare os_distro as windows when you create the image: --os-distro windows on the CLI, OS distribution in the portal, os_distro on a frostmoln_image in Terraform. It cannot be changed afterwards — an image that got it wrong has to be re-uploaded — and it is the only field the portal, CLI and Terraform expose that tells the platform the image is Windows. It matters when someone asks for a console password: the launch then fails with console password is not supported for Windows images rather than the vaguer cannot determine the default user for this image; omit the console password or use an SSH key, which never mentions the distribution. Never label a Windows image with a Linux distribution to get past that message — the platform then accepts the console password and silently does nothing with it.
  • Don't request the managed agent (--instance-access, instance_access = true). It needs systemd and cloud-init, so it is Linux only. Asking for it is not refused: the instance creates normally, the agent never installs, and fm ssh later fails telling you to re-create the instance with the flag you already used.
  • Get in over the console. Console on the instance in the portal, or fm compute instance console <instance>, which prints a console URL. It is a noVNC session and that is the only console the platform serves. It is not a way to get RDP.
  • Then open your own remote access, narrowly. Log in at the console, set up RDP or WinRM yourself, and open it in a security group — never to 0.0.0.0/0. Nothing is open by default. Prefer reaching the instance on its private address over a VPN or a bastion, keep NLA enabled, and run WinRM over HTTPS (5986), never plaintext 5985.

Windows licensing is yours to bring and yours to comply with. Frostmoln supplies no Windows licence, key or activation service, so a KMS-activated capture drifts out of activation here — use MAK, or a KMS your instances can reach.

Don't install a guest agent

Frostmoln does not use qemu-guest-agent for anything, and you should not bake Frostmoln's own agent into the image either.

Frostmoln installs its agent when the instance is created, and only if you ask for it — fm compute instance create --instance-access, or instance_access = true on a frostmoln_instance in Terraform. It cannot be added to an instance that already exists: re-create the instance instead. Using the access afterwards — fm ssh, fm forward — also needs the instance-access entitlement on your tenant; contact support if fm ssh is refused.

It cannot be part of the image: the agent's identity is the instance's own id, which does not exist yet while you are building the image, so a copy baked in would have no identity and no way to obtain one.

What the image has to provide is what the installer needs at first boot:

RequirementWhy
x86_64The only architecture the agent is built for.
systemd as init, with systemctl on PATHThe agent is installed as a systemd service.
cloud-init, able to report the instance idIt runs the installer, and the installer asks it for the instance's own id — which is the agent's identity. It falls back to reading the config drive, which needs mount and sed.
sha256sumThe downloaded agent is checked against a pinned digest.
curl or wgetTo fetch the agent.
Outbound HTTPS to the Frostmoln agent gateway, permanentlyNot just for the download — the installed agent keeps an outbound connection to the gateway, and that is what carries your sessions. See the note below.
An existing /usr/local/bin, and writable /etc + /etc/systemd/systemWhere the binary, its config and its unit are written. The installer does not create /usr/local/bin.

A missing tool fails quietly

This matters most for stripped-down and minimal images. If any of the above is missing the instance boots normally and simply never gets an agent — there is no error on the create, and fm ssh to it will not work.

Where to look: /var/log/frostmoln-managed-agent-install.log on the instance names the reason, and so does the serial console. cloud-init status --long will also report an error, because a failed install exits non-zero — so on an otherwise healthy instance a failed enrollment is what turns cloud-init's final status red.

Egress is the one that fails after a clean install

Every other requirement fails loudly in that log. Egress does not: the agent installs perfectly if the gateway is reachable during first boot, and then goes quiet if you close egress afterwards. fm ssh stops working with nothing in the install log to explain it, because the install succeeded.

Your allowance

By default each tenant may own 10 custom images totalling 376 GB of stored image data (decimal GB, counted on the expanded size). Snapshots and the platform catalogue don't count towards it.

Your current usage is in the portal under Quotas, category Custom images. To have the allowance raised, contact support.

Three different refusals can arrive while uploading, and they do not have the same remedy:

StatusMeaningWhat to do
409 quota_exceededYour allowance is full.Delete a custom image to free a slot. Retrying will never succeed.
403 quota_exceededToo much is already in flight — too many images awaiting upload, or too many bytes staged.Finish or delete the images sitting in Awaiting upload, then try again.
429You have started too many uploads in the last hour.Wait for the window to pass. An image you already created is still there.

A failed import keeps holding a slot

A failed import leaves the image in Awaiting upload, so it keeps counting towards the in-flight limit. That is why a 403 does not always clear by waiting — delete the images you are not going to finish.

Deleting an image frees its slot immediately — but only once nothing is built on it.

An image the storage backend still references cannot be deleted

Every instance disk is a copy-on-write clone of the image it was launched from, so the platform refuses the delete with a 409 for as long as any of those clones exists. Delete the instances launched from the image, then retry.

A refusal changes nothing: the image is still there, still Active, and the same delete succeeds once the last clone is gone.

Upload from the portal

Compute → Images → Upload image. You give it a name, the disk file, the disk format, and optionally a description, OS distribution, OS version and architecture (the OS fields are metadata — they help you and your colleagues recognise the image later).

Behind the button the portal runs four steps: it creates the image, sends the file straight to object storage (image bytes never pass through the API), asks the platform to import it, then polls until it's ready. Conversion is server-side and proportional to the image, so a large image can take several minutes; you can leave the page — the import continues without the browser.

The image's status tells you where it is:

StatusMeaning
Awaiting uploadThe image exists but holds no bytes yet.
ImportingThe bytes are being converted and stored.
ActiveReady to boot.
Import failedThe bytes were rejected — see When an import fails.

An upload interrupted halfway leaves the image Awaiting upload. Don't create a second one: use Upload again on the existing row to finish it.

Upload with the fm CLI

bash
fm compute image create \
  --name my-debian \
  --file debian-13.qcow2 \
  --disk-format qcow2 \
  --os-distro debian \
  --os-version 13 \
  --arch x86_64 \
  --wait

create runs the whole flow — create, upload, import — and prints the image id as soon as the image exists, before the upload starts, so a failure later never loses it. (Under -o json / -o yaml the document, and with it the id, comes at the end instead.) --wait blocks until the image is active (up to --timeout, 30 minutes by default); without it the import continues server-side and fm compute image get shows the result.

If the upload fails or is interrupted, the image still exists — fill it in rather than creating a new one:

bash
fm compute image upload <image-id> --file debian-13.qcow2

The rest of the family:

bash
fm compute image list --visibility private   # your own images
fm compute image list --visibility public    # the platform catalogue
fm compute image get <image-id>
fm compute image update <image-id> --name my-debian-13 --min-disk 20
fm compute image delete <image-id>

Only --name, --description, --min-disk and --min-ram can be changed after an image is created. Full flags: the fm CLI reference (English).

list prints the raw status, where a failed import is indistinguishable from an image that was never uploaded — both read queued. Use get, which says so explicitly.

With Terraform

hcl
resource "frostmoln_image" "golden" {
  name             = "golden-ubuntu-24.04"
  source_file      = "${path.module}/build/golden-ubuntu-24.04.qcow2"
  source_file_hash = filesha256("${path.module}/build/golden-ubuntu-24.04.qcow2")
  disk_format      = "qcow2"

  os_distro    = "ubuntu"
  os_version   = "24.04"
  architecture = "x86_64"
  min_disk_gb  = 20
}

resource "frostmoln_instance" "app" {
  name      = "app-01"
  image_id  = frostmoln_image.golden.id
  flavor_id = "gp1.small"
  subnet_id = frostmoln_subnet.main.id
}

source_file is a local path, read by the machine running Terraform — not a bucket key or a URL. Terraform cannot see a change to the file's contents under an unchanged path, so pair it with source_file_hash when you rebuild the image in place: a changed hash replaces the image, which re-uploads and re-imports it. terraform import is deliberately not supported — declare the image in configuration and apply. Note that the platform sets min_disk from the image's real expanded size during the import, replacing whatever you configured; a configured min_disk_gb stays in state as you wrote it, so a later refresh can surface the platform's figure as a diff.

Full schema, and the data sources for looking up an existing image (English):

Boot an instance from it

Your images appear wherever an image is selected — the portal's create-instance form, fm compute instance create --image <image-id>, image_id in Terraform.

Only an Active image can boot. The picker lists your images whatever their state, so a queued or failed one is offered like any other and the instance create then fails. Check the status first.

During the import the platform records the image's minimum disk from its real expanded size, replacing any value you set. A flavor whose root disk is smaller than that cannot boot the image.

When an import fails

A failed import does not leave the image in a failed state. It reverts the image to queued — the same status an image that was never uploaded has — and records a separate importFailed flag. That distinction matters if you write your own polling: poll for active OR importFailed, never for active alone, or a failed import leaves your script waiting forever.

The portal and fm compute image get both surface it (Import failed / a warning line), together with the reason, when the platform can determine one:

  • The file contains another disk image — a qcow2 exported from inside another qcow2. Uploading the same file again fails identically. Export the guest disk from the hypervisor as a plain qcow2 or raw image and upload that instead.
  • The file uses a feature that cannot be converted — a backing file, or encryption. Flatten it into a single unencrypted image (qemu-img convert) and upload that.
  • The file is not the format you declared — check what it really is (qemu-img info will tell you), set the matching disk format on the image, then upload it again.
  • Conversion failed — usually an incomplete upload or a corrupt image. Check the file opens locally, then upload it again.
  • The platform could not determine a reason — contact support and quote the image ID. Uploading the same file again is unlikely to help.

If you poll the API yourself, the reason is importFailureReason on the image, and its value is one of nestedFormat, unsupportedFeature, declaredFormat, conversionFailed or unknown. New codes may be added at any time — treat an unrecognised one exactly as unknown.

The reason stays on the image, so it is still there after a reload or the next day.

A failure takes a few seconds to appear, and that is deliberate. The platform learns that an import failed slightly before it learns why, and it publishes neither until both are ready — so that you are never shown "the import failed" with no explanation and the useless advice to upload the file again. For up to a minute the image therefore looks exactly like one nobody has uploaded to: queued, no importFailed. Keep polling. In the rare case where the platform could not determine a reason at all, you get the failure without one.

Not every rejection gets here. A file that isn't a valid qcow2 or raw disk image, one that declares a backing file or is encrypted, and one whose expanded size is over the 35 GiB ceiling are all refused immediately, with a 400 and a message naming the reason — the import never starts and no importFailed is recorded. importFailed means the file passed those checks and then failed while being converted or stored.

Either way the image stays in Awaiting upload and you can upload a corrected file into the same record.

Billing

Custom images are billed per GB-month on the logical (expanded) size of the image, in decimal GB, for as long as the image exists — priced like a volume snapshot. The size shown in the portal is the size you pay for. Deleting an image stops the charge.

Prices come from the platform's pricing catalogue and are shown in the portal; this guide doesn't quote amounts. See Billing.

Limits

  • qcow2 and raw only, bare container format.
  • 35 GiB uploaded / 35 GiB expanded per image; 10 images / 376 GB per tenant by default. How many uploads may be in flight at once is also bounded.
  • Images are private to your tenant — sharing an image with another tenant is not available. Inside the tenant they are not confidential: anyone who can launch an instance can read the whole filesystem.
  • No download or export — an uploaded image cannot be fetched back out as a file. Keep your own copy of anything you may need again.
  • Creating a custom image from a running instance's snapshot is not available; upload the disk image instead.