Curated VM blueprints for one-click virtual machine deployment on HexOS.
A blueprint is a JSON document describing a ready-to-run VM: where to get its disk image (or how to answer its installer), the resource envelope to scale within, the virtual hardware it needs, and how HexOS knows it's online. HexOS syncs this repo on a schedule (the prod branch feeds production; staging environments sync staging) and handles everything else — storage placement, image download and verification, device creation, boot, and readiness detection.
Grouped by category, which is how the HexOS UI groups them too.
| Blueprint | Description |
|---|---|
| AlmaLinux 9 | Official AlmaLinux GenericCloud image, RHEL-compatible |
| AlmaLinux 10 | Official AlmaLinux GenericCloud image; needs an x86-64-v3 CPU |
| Debian 12 (Bookworm) | Official Debian cloud image, for workloads pinned to oldstable |
| Debian 13 (Trixie) | Official Debian cloud image, configured on first boot |
| Fedora 44 | Official Fedora Cloud Base image, configured on first boot |
| Fedora CoreOS | Minimal auto-updating container host, configured by Ignition |
| Flatcar Container Linux | Immutable auto-updating container host, configured by Ignition |
| Rocky Linux 9 | Official Rocky GenericCloud image, RHEL-compatible |
| Rocky Linux 10 | Official Rocky GenericCloud image; needs an x86-64-v3 CPU |
| Ubuntu Server 24.04 LTS | Canonical's official cloud image, configured on first boot |
| Ubuntu Server 26.04 LTS | Canonical's official cloud image, configured on first boot |
Every desktop blueprint offers the optional Apps step. The Apps column is the
package runtime that guest has, which is what decides which apps are offered on it.
| Blueprint | Apps | Description |
|---|---|---|
| Bazzite | flatpak |
Gaming-focused atomic KDE desktop from Universal Blue; offers GPU passthrough |
| CachyOS | flatpak |
Performance-tuned Arch with KDE Plasma, installed hands-free by its own headless installer |
| Fedora KDE Plasma Desktop 44 | flatpak |
KDE Plasma, from the same Fedora network installer as Workstation |
| Fedora Workstation 44 | flatpak |
GNOME desktop, installed hands-free by Fedora's network installer |
| Kubuntu 26.04 LTS | flatpak |
Ubuntu with the KDE Plasma desktop, installed hands-free from the official ISO |
| Linux Mint 22.3 Cinnamon | flatpak |
Cinnamon desktop, installed hands-free from the official ISO |
| Omarchy 4 | flatpak |
DHH's Arch + Hyprland desktop, installed hands-free via the ISO's own autoinstall |
| openSUSE Leap 16.0 | flatpak |
Fixed-release SLE-based desktop, installed hands-free by Agama |
| Pop!_OS 24.04 LTS | flatpak |
System76's COSMIC desktop, installed hands-free from the official ISO |
| SteamOS | flatpak |
Valve's Steam Deck OS from the official recovery image; Gaming Mode needs a passed-through AMD GPU |
| Ubuntu Desktop 26.04 LTS | flatpak |
The Ubuntu desktop, installed hands-free from Canonical's official installer |
| Windows 10 Pro | winget |
Unattended install from a user-supplied installer ISO |
| Windows 11 Pro | winget |
Unattended install from a user-supplied installer ISO |
| Xubuntu 26.04 LTS | flatpak |
Ubuntu with the Xfce desktop, light on resources |
| Zorin OS 18.1 Core | flatpak |
Windows-familiar desktop, installed hands-free from the official ISO |
| Blueprint | Description |
|---|---|
| Home Assistant OS | Official Home Assistant appliance OS |
| OpenWrt 25.12 | Official OpenWrt x86-64 router/firewall image, managed from LuCI |
| Plex Media Server | Plex on Ubuntu's cloud image, started at first boot; open it and you are in Plex. Sign in during the install and it is claimed on your account |
Drafts that aren't ready to ship live in a gitignored _pending/ directory on
the maintainer's machine — a draft carries TODO digests and URLs nobody has
confirmed yet, and neither the sync nor the validator looks at underscore-
prefixed paths. Nothing is published from this repo until it reaches the root
with a real digest.
Optional software a desktop VM install can lay down after the guest is up, picked from a multi-select step in the install flow (the Ninite idea, in the HexOS wizard). Apps install last — after the OS, after Windows Update — and a failed app never fails a working VM.
Each app declares a package id per guest runtime: winget for Windows guests, flatpak (Flathub) for Linux desktops. A blueprint opts in by declaring the one runtime its guest has, and only apps carrying a target for that runtime are offered. That is also how single-platform apps express themselves: 7-Zip and PowerToys are Windows software, and FileZilla is absent from winget entirely, so each simply carries one target.
W = offered on Windows guests, L = offered on Linux desktops.
| App | Package ids | Description | |
|---|---|---|---|
| Brave | WL | Brave.Bravecom.brave.Browser |
Chromium-based browser that blocks ads and trackers by default. |
| Firefox ★ | WL | Mozilla.Firefoxorg.mozilla.firefox |
Mozilla's browser, with tracking protection on by default. |
| Google Chrome | WL | Google.Chromecom.google.Chrome |
Google's browser. |
| App | Package ids | Description | |
|---|---|---|---|
| Discord | WL | Discord.Discordcom.discordapp.Discord |
Voice, video and text chat built around servers and channels. |
| Signal | WL | OpenWhisperSystems.Signalorg.signal.Signal |
End-to-end encrypted messaging and calls. |
| Slack | WL | SlackTechnologies.Slackcom.slack.Slack |
Team chat, channels and calls. |
| Telegram | WL | Telegram.TelegramDesktoporg.telegram.desktop |
Cloud-synced messaging with large group and file support. |
| Zoom | WL | Zoom.Zoomus.zoom.Zoom |
Video meetings and screen sharing. |
| App | Package ids | Description | |
|---|---|---|---|
| Audacity | WL | Audacity.Audacityorg.audacityteam.Audacity |
Multi-track audio recording and editing. |
| HandBrake | WL | HandBrake.HandBrakefr.handbrake.ghb |
Video transcoder for converting almost any source into common formats. |
| OBS Studio | WL | OBSProject.OBSStudiocom.obsproject.Studio |
Screen recording and live streaming with scene compositing. |
| Spotify | WL | Spotify.Spotifycom.spotify.Client |
Music streaming client. |
| VLC ★ | WL | VideoLAN.VLCorg.videolan.VLC |
Plays essentially every media format without extra codecs. |
| App | Package ids | Description | |
|---|---|---|---|
| Steam | WL | Valve.Steamcom.valvesoftware.Steam |
Valve's game store and library client. Games install on top of this. |
| App | Package ids | Description | |
|---|---|---|---|
| Blender | WL | BlenderFoundation.Blenderorg.blender.Blender |
3D modelling, animation, simulation and rendering suite. |
| GIMP | WL | GIMP.GIMPorg.gimp.GIMP |
Raster image editor for photo retouching and composition. |
| Inkscape | WL | Inkscape.Inkscapeorg.inkscape.Inkscape |
Vector graphics editor working natively in SVG. |
| Krita | WL | KDE.Kritaorg.kde.krita |
Digital painting and illustration, built around brush engines. |
| App | Package ids | Description | |
|---|---|---|---|
| LibreOffice ★ | WL | TheDocumentFoundation.LibreOfficeorg.libreoffice.LibreOffice |
Word processor, spreadsheet, presentations and more. |
| Thunderbird | WL | Mozilla.Thunderbirdorg.mozilla.Thunderbird |
Mail, calendar and contacts client from Mozilla. |
| App | Package ids | Description | |
|---|---|---|---|
| FileZilla | L | org.filezillaproject.Filezilla |
FTP, FTPS and SFTP client. |
| Git | W | Git.Git |
Version control, plus Git Bash on Windows. |
| Node.js | W | OpenJS.NodeJS |
JavaScript runtime and npm. |
| PuTTY | W | PuTTY.PuTTY |
SSH and serial terminal client for Windows. |
| Python | W | Python.Python.3.14 |
The CPython 3.14 runtime and pip. |
| Visual Studio Code | WL | Microsoft.VisualStudioCodecom.visualstudio.code |
Microsoft's code editor, with an extension marketplace. |
| WinSCP | W | WinSCP.WinSCP |
SFTP, FTP and SCP file transfer client for Windows. |
| App | Package ids | Description | |
|---|---|---|---|
| 7-Zip ★ | W | 7zip.7zip |
Archive manager handling 7z, zip, rar and most other formats. |
| Bitwarden | WL | Bitwarden.Bitwardencom.bitwarden.desktop |
Password manager desktop client. |
| KeePassXC | WL | KeePassXCTeam.KeePassXCorg.keepassxc.KeePassXC |
Offline password manager using local KeePass databases. |
| Notepad++ | W | Notepad++.Notepad++ |
Lightweight text and source editor for Windows. |
| PowerToys | W | Microsoft.PowerToys |
Microsoft's power-user utilities: FancyZones, PowerRename and more. |
★ marks an app that arrives pre-checked in the picker. Keep that set small: it is what a user who clicks straight through actually installs.
Two things worth knowing about the ids above:
- Five winget packages are user-scope only — Discord, Slack, Telegram, Signal and Spotify publish per-user installers, so they cannot be installed by a SYSTEM-context task and have to run in the created account's logon session instead.
bun run validatewarns on every one so the cost of adding another is visible at review time. Everything else installs machine-wide. Python.Python.3.14pins a minor version, because winget's id does. Bumping Python here is a deliberate commit, the same as bumping a blueprint's image version.
Apps carry no download URL and no digest, unlike blueprints: the package manager owns fetching and verification, and a second copy of that claim in this repo would only be a staler one. What does rot is the identifier — winget-pkgs removed FileZilla outright over its bundled installer, Flathub retires ids when a project moves — so bun run check-sources resolves every id nightly alongside the blueprint URLs.
Icons live under _icons/apps/, one per app, mirrored here exactly like the blueprint icons and recorded per file in ATTRIBUTION.md. Most come from Simple Icons (CC0, pinned), which is where several blueprint icons already came from; six needed another source and two of those ship as PNG because their project publishes no vector at all. An app with no icon renders a lettered tile, so removing one is always safe.
Warning
The blueprint format is still in flux and may change without notice, including in backwards-incompatible ways. The example below reflects the format at time of writing; always check the authoritative schema (see below) before writing or updating a blueprint.
provisioning.strategy picks one of six install pipelines. Each is described
here as what actually happens on the box, in the order the pipeline does it,
followed by what the blueprint has to supply. The steps mirror the roadmap the
install pipeline registers upfront, so this section is what needs re-checking
whenever the pipeline gains or loses a step.
- Allocate the zvol at the requested disk size.
- Download the vendor's disk image.
- Verify it against the digest in the blueprint.
- Decompress and write it to the zvol.
- Configure the domain (firmware, disk bus, NIC, CPU, memory) and boot.
- Wait for the guest to come online using the blueprint's readiness probe.
By default nothing is injected — no account is collected because the appliance
ships with its own (HAOS-style). The blueprint supplies a bootable source
image and a readiness probe that can see the appliance's own UI (e.g. mDNS +
port). A blueprint may instead name a first-boot profile the backend ships
(firstBoot.profile, one of the names in _lib/contract.ts), which the backend
injects on the first boot the way the installer seeds are: the Plex appliance
names plex-appliance and lists the HexOS locations it mounts in
firstBoot.shares (["Media", "Movies", "Shows", "Music", "Photos", "Videos"]).
A list may name the user's own folders only (VM_SHARE_LOCATIONS in the
vendored schema: those six plus Documents and Downloads), each once, and
each mounts at /mnt/<id in lowercase> in the guest, read-only unless the
blueprint sets firstBoot.shareAccess: "write". The guest reaches them as an
SMB account the install creates for that VM, with Read on those shares (Change
for write: read, write and delete, so the guest can change and delete the
user's files there) and deleted with the VM; the installer asks for no
credentials. The
blueprint names that account in firstBoot.shareAccount (plexvm for Plex;
lowercase letters and digits), and a second VM of the same blueprint gets the
name with 2, up to 9. The contract requires the name and the capability
firstBootShareAccount of every blueprint that lists shares, refuses shares on
a profile that mounts none, refuses shareAccess without a list, and refuses
the old firstBoot.mediaShare flag the list replaced. The profile's content lives in the platform; the catalog only
names it.
firstBoot.shareTransport: "virtiofs" asks for the same folders over virtiofs
instead: the box shares each folder straight into the guest (one virtiofsd
per folder, running as that same account), with no network and no password in
the path, and starts the VM itself. A box that can't (an older platform, or a
TrueNAS train it hasn't been proven on) ignores the field and mounts over SMB,
so the blueprint doesn't require a capability for it. The contract refuses the
field without a share list, and virtiofs on a profile that can't take it
(only plex-appliance can).
- Allocate the zvol, download, verify and write the image (as for
image). - Render the named user-data template with the first-user account (password hash and/or SSH key) plus a phone-home URL.
- Attach the rendered document as a NoCloud seed volume.
- Boot; cloud-init reads the seed on first boot and creates the account.
- Wait for the guest to phone home. On success the seed stays attached — only a failed install removes it.
Needs a username and a password OR an SSH key — either login path is enough.
cloudInit.userDataTemplate names a first-boot template shipped in the HexOS
backend. Readiness is usually { "type": "phone-home" }.
- Allocate the zvol, download, verify and write the image (as for
image). - Render the named machine-config template with the credentials and a phone-home URL.
- Attach it as a config-drive volume the guest reads at first boot.
- Boot; the guest applies the config to its built-in account.
- Wait for the phone home, then eject and scrub the config drive.
Configures the image's built-in account (e.g. core) rather than creating one,
so no username is asked for — these guests ship no cloud-init at all (Fedora
CoreOS and Flatcar use Ignition). source is a prebuilt image, and
machineConfig.template names a backend template whose rendered document
reaches the guest via machineConfig.delivery. Always config-drive: fw-cfg
works, but the document rides in the domain's command line where it can't be
scrubbed after install.
- Allocate a BLANK zvol — the installer fills it, nothing is written up front.
- Download the installer ISO (skipped when it is already cached on the box).
- Render the seed (autoinstall / kickstart / preseed) with the account and the disk layout, and build it into a seed ISO.
- Attach the installer ISO and the seed ISO as CD-ROMs, then boot.
- The installer runs unattended and installs onto the zvol.
- Wait for the INSTALLED system to phone home — that is what proves it rebooted out of the installer.
- Eject and truncate the seed (it carries the password hash); eject the installer but keep it as the shared cache for the next install.
For freely redistributable media (desktop Linux). A password is mandatory here:
a desktop login cannot run on an SSH key alone. source is a downloadable ISO
(url/version/digest, no format/compression), and seed.template names
a backend template that generates the answer-seed ISO (Ubuntu autoinstall on a
NoCloud cidata volume, Fedora kickstart on OEMDRV). Readiness is usually
{ "type": "phone-home" }.
- Allocate a BLANK zvol — the installer fills it, nothing is written up front.
- Download the vendor's raw installer image, verify it against the digest, and decompress it onto a dedicated MEDIA zvol (sized from the decompression estimate; sparse, so over-sizing costs nothing).
- Render the seed (the install script) with the account and build it into a seed ISO.
- Attach the blank target as the first disk, the media zvol as the second (firmware falls through the blank disk and boots the media), the seed as a CD-ROM, then boot.
- After the live session settles, type the bootstrap into a text VT: it mounts the seed by volume label and runs the install script, which drives the image's own repair/install tooling against the target disk and powers off.
- On the power-off, detach the media disk and the seed CD, and start the VM again — first boot runs media-less from the target.
- Wait for the INSTALLED system to phone home, then truncate the seed (it carries the password hash) and destroy the media zvol — regenerable media, destroyed only on a confirmed install; a timeout keeps it attached.
For vendors that publish no installer ISO and no cloud image — only a raw
recovery disk image that must boot from its own drive and image a second one
(SteamOS's Steam Deck recovery image is the only case today). The guest's
account is the image's fixed built-in user (deck), so no username is asked
for; a password is mandatory because the image ships that account with an
empty password. source is a downloadable raw image (url/version/
format/compression/digest) and seed.template names the backend template
that generates the install script and drives the flow. Readiness is
{ "type": "phone-home" }.
- Allocate a blank zvol.
- Take the user's own Windows ISO from Install Media, or download the URL they pasted — HexOS cannot redistribute Windows media.
- Render
autounattend.xmlwith the computer name and administrator account, and attach it as a CD-ROM. - Attach the VirtIO driver ISO alongside it when the guest uses a VirtIO disk or NIC.
- Boot; Windows Setup runs unattended and installs onto the zvol.
- Install Windows updates, unless the install opted out of them.
- Wait for the first-logon command to phone home, then eject and scrub the answer file — it holds a recoverable password. The user's own ISO is left alone.
The only strategy that requires the user to bring their own media: source is
{ "type": "user-iso" }, answerFile.template names a backend template, and
extraMedia lists additional ISOs (e.g. VirtIO drivers) attached as CD-ROMs.
isoHelpUrl is the vendor link the install dialog shows the user; it is
separate from releasesUrl (which nothing renders) even when both point at the
same page.
Version bumps are a two-field change: source.version and its digest. source.releasesUrl is where you go to make it — the vendor page listing available releases and their published checksums, recorded once per blueprint so nobody has to rediscover it. See Checking for new versions.
Note
sha256 pins the media, not always the outcome. For a net installer the
package payload is fetched from the vendor's CDN at install time, so the hash
never covered what actually lands on disk. One blueprint goes a step further:
CachyOS installs by upgrading cachyos-cli-installer-new from CachyOS's
repo in the live session, because the installer bundled in the ISO is months
stale and cannot install headlessly at all (it has no --config flag and
never formats the root partition). That is a deliberate, accepted trade-off —
its install behavior tracks a repo package rather than the pinned ISO. If a
net-installer blueprint breaks after an upstream release with its hash
unchanged, suspect the vendor's tooling, and verify against the shipped
artifact rather than the vendor's docs — repeatedly this year, published media
has lagged the documented automation it is supposed to provide.
A blueprint may declare setup hooks: scripts in this repo that the HexOS box runs once the guest is ready, after the platform has confirmed that the guest's address answers ARP with the VM's own MAC on the network path a request will take. The hook reaches the guest only through the context it is handed (ctx.fetch, ctx.waitForApp, bound to those verified paths); there is no guest execution channel of any kind, and a script whose source imports, requires or re-exports a module is refused at sync (a check of the source text on a curated repo, not a sandbox around the script). The user consents per hook in the install dialog when the hook declares userOptional, and can rerun, skip or dismiss a setup step from the activity feed afterwards.
"hooksSchema": 1,
"hooks": [
{
"id": "onboarding", // unique within the blueprint, lowercase slug
"title": "Onboarding check", // the consent row's label and the task's name
"description": "…", // the consent row's body unless userOptional has its own
"events": ["onAfterReady"], // the lifecycle firings it handles: onAfterReady once the
// guest is ready, onMediaReconnected when its media mounts
// come back (user-fired verbs come later)
"optional": true, // a failure skips the hook; absent = a failure parks the
// setup until the user retries, skips or dismisses it
"kind": "connect", // a hook that signs the user in somewhere; the parser
// refuses it unless userOptional declares "default": false
"userOptional": { // present = a consent switch in the installer, on by
"description": "…", // default. A hook that signs the user in anywhere MUST
"default": false, // declare "default": false (the parser refuses otherwise)
"link": { "label": "…", "url": "https://example.com/setup" } // optional: a link beside the consent text
},
"guestPort": 80, // where the guest answers; defaults from postInstallUrl
"altPorts": [8123], // other declared ports a redirect may land on (max 4)
"script": "home-assistant-os/haos_setup.ts", // a path from the repo root; the sync inlines the file at
"entrypoint": "run", // one pinned commit, so a box never fetches a script
"inputs": [], // questions or an OAuth flow the user answers first
"timeout": 600, "retries": 1
}
]Scripts live in the blueprint's own folder, beside its screenshots (home-assistant-os/haos_setup.ts, plex-server/plex_setup.ts), as the app catalog keeps each app's scripts in its folder; the sync never reads a directory as a blueprint, so a script is reachable only through a declaration's script, a .ts or .js path from the repo root with no .. segments (the folder is a convention the review keeps, not a rule the parser enforces). They import types only — import type { VMHookContext } from "../_lib/hook_context" — and nothing at runtime: the sync refuses a script whose source carries a runtime import, require or re-export, and so does bun run validate; the check reads the source text, it does not sandbox what a script does with what it is handed. _lib/hook_context.ts is a hand-maintained mirror of the platform's VMHookContext; CI typechecks the script folders against it and runs each script's own tests (bun run test-hooks, the *.test.ts beside each script; a blueprint whose folder gains scripts joins the test-hooks command in _lib/package.json and the include list in _lib/tsconfig.json). A required setup hook (onAfterReady, not optional) whose script is missing or fails the lint makes the sync withhold the whole blueprint (its last good document stays published); an optional hook is dropped with a logged error and the blueprint publishes without it. Either failure is silent to a user, so the validator treats both as errors.
Two rules for authors. A setup hook must never write to a guest it did not install, and must tolerate a guest the user has already set up by hand: confirm state, record what the guest actually establishes (a status page that is not served is "unknown", never proof of anything), and leave account creation to the user (or to a migration that carries that intent). And never put a credential into a checkpoint message or a log line; ctx.secrets keeps what a hook collects, bound to the VM instance and removed with it. The platform snapshots a blueprint's declarations at install and runs from that snapshot, including on a rerun; there is no action today that adopts a later catalog edit, so a fix to a shipped hook reaches new installs only.
The Plex appliance mounts the configured HexOS Media, Movies, Shows, Music, Photos and Videos folders read-only, over virtiofs where the box supports it and otherwise over SMB, as the installation's share account. The hook matches the Plex app: Movies (/mnt/movies), TV Shows (/mnt/shows), Music (/mnt/music), Photos (/mnt/photos) and Videos (/mnt/videos), with the same agents, scanners and languages. Media is mounted at /mnt/media for manual use; it is not an extra library. Before creating libraries the hook disables automatic trash emptying, reuses each existing library by its exact location, and then requests a scan of all five. Existing libraries are never deleted or repointed. Signing in claims the server on the account (only a server whose own identity says it is unclaimed), names it (the answered name, or the VM's own name when the answer is left blank), keeps the server's access token for the platform, and writes the server preferences the hook declares, each read back after the write. A successful section listing does not establish that Plex's agents have finished starting: library creation retries only Plex's explicit HTTP 400 startup refusal, up to eight attempts five seconds apart, checking for an existing library before each attempt. Other refusals and transport exceptions stop setup; an accepted creation is verified by listing the sections again.
The detail sheet renders a gallery of up to 5 screenshots of the desktop (or web UI) the blueprint actually installs, mirroring how the app catalog presents an app.
Images are mirrored into this repo and referenced with repo-relative paths. The platform's
catalog sync rewrites those paths at sync time — to
https://raw.githubusercontent.com/eshtek/hexos-vm-catalog/<branch>/<path> normally, and to
/vm-catalog-assets/<path> when a developer points VM_CATALOG_PATH at a local checkout. Absolute
https:// URLs are accepted by the schema (that is the resolved form) but should not appear in a
catalog file: hotlinking a vendor's CDN means the gallery breaks the day they reorganize it.
Conventions:
- One directory per blueprint:
<blueprint-id>/screenshots/<name>.jpg. - Downscale to 1280px wide and save as JPEG (~quality 82). Source PNGs run 1–2 MB each; the
gallery renders them at thumbnail size and this repo is cloned by CI.
sips -Z 1280 -s format jpeg -s formatOptions 82 in.png --out out.jpgdoes it with no extra tools. - Record provenance for every image in
ATTRIBUTION.md— source, author and licence. Screenshots of GPL software are typically GPL and require attribution. - Only mirror images we have the right to redistribute. Screenshots we captured ourselves from the guest we ship are the cleanest source: no third-party licence, and they show exactly the version this blueprint installs. Vendor marketing images generally are not redistributable — Windows especially.
bun run validate fails on a screenshot path that does not exist in the repo, which is the only
place a typo is catchable: the sync just rewrites the path, and a bad one surfaces as a broken image
in the UI long after the fact.
The authoritative schema is vmBlueprintSchema in hexos-platform/packages/shared/eshtek/vm-blueprints.ts — documents failing it are rejected at sync time. A verbatim copy is vendored here at _lib/vm-blueprint.schema.ts so blueprints can be validated locally and in CI without the private platform package; the server remains the real gate.
One JSON document per app under apps/. A subdirectory rather than root files on purpose: the blueprint sync ingests every non-underscore *.json in the ROOT and ignores directories, so apps ride the same repo without the blueprint sync ever seeing them.
{
"id": "firefox", // unique, lowercase, stable forever
"name": "Firefox",
"description": "…", // one line; the picker shows it under the name
"website": "https://www.mozilla.org/firefox/",
"category": "browsers", // CI-enforced vocabulary; the picker groups by this
"recommended": true, // pre-checked in the picker; keep the set small
"sizeMb": 400, // approximate installed footprint, err high — the
// wizard raises its disk default from the total
"internal": false, // true = never served from the prod branch
"targets": { // at least one required
"winget": { "id": "Mozilla.Firefox", "scope": "machine" }, // scope defaults to "machine"
"flatpak": { "id": "org.mozilla.firefox", "remote": "flathub" } // remote defaults to "flathub"
}
}scope is a property of the package, not a preference: asking for machine scope on a package that only publishes a per-user installer fails with "no applicable installer", and the guest stage can't tell that apart from a real failure without knowing what to expect. Take it from the package's winget manifest.
CI runs on every PR (.github/workflows/validate.yml), but you can check your blueprint before pushing. Requires Bun. All tooling lives under _lib/ — the catalog sync ingests every non-underscore *.json in the repo root, so anything that isn't a blueprint (this tooling, its package.json) is kept out of the root.
cd _lib
bun install
bun run validateThe validator checks each root *.json against the vendored blueprint schema and each apps/*.json against the vendored app schema, parses any setup-hook and widget declarations with the platform's own parser (vendored as _lib/vm-surfaces.ts), checks that every declared script exists and imports nothing at runtime, then applies a few contract checks the schema can't express (_lib/contract.ts):
cloudInit.userDataTemplate/answerFile.template/seed.templatemust name a template the backend actually ships (linux-default,win11-pro,win10-pro,ubuntu-desktop-autoinstall,fedora-workstation-kickstart,fedora-kde-kickstart,opensuse-agama-profile,bazzite-kickstart,mint-preseed,zorin-preseed,pop-live-exec,omarchy-autoinstall,cachyos-headless,steamos-repair, plus the machine-config pairfcos-ignition/flatcar-ignitiontoday) — this is the highest-value check; a typo passes schema validation and only fails at install timefirstBoot.profilemust name a first-boot profile the backend actually ships (openwrt-lan-dhcp,plex-appliance); an unknown name would fail the install loudly, so it is caught here instead- a duplicate
idacross two files is an error (the sync skips the duplicate) - every blueprint must have a row in the README tables above — this is the step that has actually been skipped in practice, so it is an error rather than a convention
apps.runtimemust match the guest the provisioning strategy implies (answer-file is Windows, sowinget); a desktop with noapps.runtimewarns, since it will offer no apps at allsource.releasesUrlmust be present — the schema leaves it optional for admin-authored rows, but a catalog blueprint that doesn't say where its next version comes from is how-latestURLs and guessed digests get in- warnings for an
idthat differs from its filename stem, an off-conventionicon, a missingtruenasVersion(offered on every release) or one with no comparison operator (a no-op gate), an unrecognizedcpuFeaturesflag name (a typo would hide the blueprint on every host),extraMediawith nosha256, or areleasesUrlpointing at a file rather than a listing
On the app side:
categorymust be one of the slugs the picker groups by (browsers,messaging,media,gaming,graphics,documents,developer,utilities) — same reasoning as blueprint categories, enforced here rather than as a schema enum so a new group buckets under "Other" instead of hiding every app in it- at least one runtime target is required (schema-level); an app with none would be structurally valid and completely inert
- warnings for a user-scope winget package (it can't ride the SYSTEM install stage), a flatpak remote other than Flathub (the guest stage only adds that one), a missing
sizeMb(it contributes nothing to the wizard's disk estimate), and — catalog-wide, reported once — how many apps still have no icon and whether the pre-checked set has outgrown what the picker shows comfortably
Errors fail the run; warnings don't.
The vendored schemas are copies, so they can drift as the platform schemas evolve. sync-schema re-vendors all of them (blueprints, apps, test specs, and the surface grammar with its parser) from a local platform checkout, which defaults to a ../hexos-platform sibling; override with HEXOS_PLATFORM:
cd _lib
bun run sync-schemaWhen the backend adds a new provisioning template or first-boot profile, also update the allowlists in _lib/contract.ts.
releases.ts prints every blueprint's pinned version alongside the page that would announce a newer one — the starting point for a bump pass:
cd _lib
bun run releases # id, pinned version, digest algorithm, releases page
bun run releases -- --pending # include the _pending/ drafts
bun run releases -- --check # confirm each page still resolves
bun run releases -- --json # machine-readableNothing fetches releasesUrl at sync or install time and no client renders it, so --check reports and always exits 0 — unlike bun run check-sources, which HEADs the pinned artifacts nightly and fails when one 404s.
Apps have no version to bump, so releases skips them; what goes stale there is the package id, which check-sources resolves on the same nightly run:
cd _lib
bun run check-sources -- --apps # only the app package idsEvery blueprint has a test spec in _tests/spec/<id>.json and the suite-wide settings live in _tests/suite.json. Specs are source: they say how the install dialog is answered under test (as a shape — never a value; the guest password comes from the environment variable suite.json names), the wall-clock ceiling, which assertions apply (offline guest read, stop/start steady state, the seed device the guest should see), and what a reviewer should look for on the console frame when an install soft-completes. The _ prefix keeps the directory invisible to the catalog sync.
cd _lib
bun run tests # every blueprint, its spec, ceiling and checks
bun run generate-tests # write a spec for every blueprint that lacks one (derived from the blueprint)
bun run generate-tests -- --force # regenerate the derivable fields everywhere, keeping the prosebun run validate fails when a blueprint has no spec, a spec names a blueprint that does not exist, or a spec's strategy disagrees with its blueprint; it warns when the derivable fields drift.
Results are not kept in this repo. The sweep harness in hexos-platform (packages/dev/scripts/vm-catalog-sweep/) installs every blueprint on the lab boxes, records evidence (task verdict, readiness, console frame, offline guest read, reboot, delete) and posts it to the HexOS main server, where the admin console's VM Blueprints page shows each blueprint's status per box:
| Status | Meaning |
|---|---|
| 🟢 confirmed | installs and readiness was actively observed |
| 🟡 unmonitored | installs to a login screen, but readiness is unobservable (a reviewer's call on the frame) |
| 🔴 broken | no usable installed system — the candidate for parking |
| ⚪ untested | no result, or the result predates a functional change to the blueprint |
A result records a digest of the blueprint's functional fields (provisioning, guest, resources, requiredCapabilities, truenasVersion, cpuFeatures, and the surface declarations hooksSchema, hooks, widgetsSchema, widgets), so bumping a version or changing a template marks it ⚪ until the sweep is re-run, while copy edits never expire a result. The rule for bumping a pinned version: open the PR, run the sweep against the PR branch, and merge once the blueprint reads 🟢 or 🟡 on every box for the new document — the old pinned version stays in place until then. Red never parks a blueprint by itself: parking is a change to internal in a reviewed PR.
- Fork this repository
- Add your blueprint JSON in the root directory (filename should match the
id), or your app JSON underapps/ - Run
bun run validateand fix any errors - Test it against a staging/dev HexOS environment (sync from your branch, or point
VM_CATALOG_PATHat your checkout) - Submit a pull request including where the image is published, how its checksum was obtained, and any special guest requirements
{ "id": "haos", // unique, lowercase, stable forever "name": "Home Assistant OS", "description": "…", "icon": "_icons/haos.svg", // mirrored IN THIS REPO like screenshots; legacy // "vms/<slug>" keys still resolve against artwork // bundled with the frontend, but can't ship new art "website": "https://www.home-assistant.io/", // product page; the detail sheet's "Website" button. // Distinct from source.releasesUrl (which nothing renders) "screenshots": [ // mirrored IN THIS REPO, repo-relative; the detail sheet "haos/screenshots/dashboard.jpg", // gallery shows the first 5. See "Screenshots" below "haos/screenshots/settings.jpg" ], "category": "appliance", // "server" | "desktop" | "appliance" (CI-enforced; the UI groups by this) "truenasVersion": ">=25.04.2.6", // hidden on servers outside this range "cpuFeatures": ["avx2", "fma"], // optional: /proc/cpuinfo flags the host CPU must have // (case-insensitive; hidden on hosts missing any — e.g. // list the x86-64-v3 flags for distros with that baseline) "internal": false, // true = never served from the prod branch "apps": { "runtime": "flatpak" }, // opt in to the post-install Apps step, naming the // package runtime THIS guest has ("winget" | "flatpak"). // Absent = offers no apps, which is right for servers // and appliances. The runtime also decides WHICH apps // are offered: only those with a target for it (see // "Available Apps"). CI checks it matches the guest the // strategy implies — answer-file is Windows, so winget "provisioning": { "strategy": "image", // "image" | "cloud-init" | "answer-file" | "installer-iso" // | "installer-image" | "machine-config" "source": { "url": "https://…/{version}/disk-{version}.qcow2.xz", // {version} is substituted "version": "18.1", "format": "qcow2", // "raw" | "qcow2" "compression": "xz", // "none" | "xz" | "gz" | "zstd" | "bz2" "sha256": "…", // of the file as downloaded, as published by the vendor // (use "sha512" instead where that is all they publish — // Debian's cloud images, for one; at least one is required) "releasesUrl": "https://…/" // page listing this project's releases and their digests; // required, and what you open to bump the two fields above } }, "resources": { "minMemoryMb": 2048, "recMemoryMb": 4096, "minVcpus": 1, "recVcpus": 2, "diskGb": 40 // DECIMAL GB (40 = 40,000,000,000 bytes). For an image // blueprint it must cover the image's virtual disk size // (`qemu-img info`), which is binary: HAOS ships a 32 GiB // disk, and 32 GiB does not fit in 32 GB }, "guest": { "firmware": "UEFI", "diskBus": "VIRTIO", // "AHCI" | "VIRTIO" "nicModel": "VIRTIO", // "E1000" | "VIRTIO" "tpm": false, "secureBoot": false, "hypervEnlightenments": false, "readiness": { "type": "mdns", "hostname": "homeassistant.local", "port": 80 }, "postInstallUrl": "http://{ip}", // the VM card's Launch link; {ip} is the guest's address "postInstallAltPorts": [8123] // optional: other ports the UI may answer on (max 4). The // platform probes them with postInstallUrl's own port and // links to the one that answers. HAOS 2026.8 serves new // installs on 80; installs from before it keep 8123 }, "hooksSchema": 1, // optional: setup hooks the platform runs after the guest "hooks": [ /* see "Setup hooks" below */ ] // is ready, from scripts in this repo (the blueprint's own folder) }