Skip to content

Shipping updates to real machines

Nothing here is hosted yet. This records the decisions that are settled, what already exists in the tree, and what has to be true before an image goes onto someone's laptop.

There is no "ISO server"

bootc updates come from an OCI container registry: the machine polls the newest digest of an image ref and stages an atomic reboot into it. The ISO is only ever for the first install. So there are three independent delivery channels, and they have almost nothing in common.

what how it ships size / traffic needs
the OS container registry, bootc upgrade few GB first pull, small deltas after a real registry (/v2/ API) — object storage will not do
category database signed manifest + hash-verified download ~190 MB, re-pulled on refresh dumb static hosting; needs no trust of its own
word lists, site rules, search blocklist inside the signed portal bundle kilobytes the portal
installer ISO static file GBs, pulled rarely dumb static hosting

The list channels matter because filter lists must never need an OS release. catalogsync.py was written for exactly this: the portal signs a manifest (URL, size, SHA-256), the device fetches the database from anywhere at all and checks it against that hash before it goes near the filter. The signature covers the hash, so the download itself can come over plain HTTP from a mirror or a CDN and a byte out of place is caught.

Settled decisions

Distribution is public and the OS is free. Anyone may download and install KosherOS; there is no per-device authentication on the registry and no licence gate on the OS. This is not only a philosophical choice — it keeps the free tier of a public registry available, which is the single largest hosting subsidy the project gets. Gating distribution would mean per-device tokens, a self-hosted registry, and a bandwidth bill.

Zero marginal cost per device is a requirement, not a preference. The whole point of the project is filtered computers for families who should not have to pay hundreds of dollars per device per year, which is what the existing offerings charge. So any design that puts a per-family cost in the update path — metered egress, hosting that scales with installs, a subscription choke point — contradicts the reason the thing exists. Every choice below is made against that constraint, and it is why the pieces that carry real bytes are deliberately placed on free, hash-verified, CDN-backed hosting rather than on a VPS whose bill grows with adoption.

Hosting is GitHub-first, chosen to keep costs down. ghcr.io for the image, GitHub Releases for the ISO, GitHub Pages for the marketing site, GitHub Actions for CI, and one small VPS alongside for the always-on portal and update service. Deliberately not a hyperscaler: this is an egress-dominated product and metered egress is the wrong bill to take on. ECR + S3 + CloudFront + Fargate is the same architecture at many times the price.

Bandwidth is dominated by OS image pulls, so anything that reduces layer churn per release is worth real effort. kosherd and the apps are already the last layers in the Containerfile for this reason; keep it that way.

The signing key does not live at any provider. An offline copy exists before a single machine is fielded. Losing it is the one unrecoverable failure in this design: every fielded machine has pinned it, and no update can be published without it.

What already works

piece state
image build, push, cosign signature .github/workflows/ci.yml — builds on push to master, pushes :latest and :<sha> to ghcr, signs both keyless
automatic update polling bootc-fetch-apply-updates.timer enabled (os-image/Containerfile)
updates and rollback from the daemon CheckUpdate, ApplyUpdate, DeploymentStatus, Rollback in kosherd/src/kosherd/daemon.py
self-healing updates greenboot, with a required check that the filter is enforcing (os-image/files/etc/greenboot/check/required.d/)
signed policy sync sync.py + portal /api/v1/devices/{id}/policy, Ed25519, replay-proof by revision
signed list bundle portal /api/v1/devices/{id}/lists, installed by lists.install_portal_lists
hash-verified category database catalogsync.py — atomic replace, and only after the new database opens and answers a query
dev iteration path just vm-upgrade — a rootless registry on the host, bootc switch + upgrade in the VM, ~1 min

What blocks a real install

In the order it blocks daily-driving the thing.

1. Signature verification is a TODO. os-image/Containerfile says so in as many words — grep for TODO(stage 2) above the kosherd layers. CI signs images; the fielded machine verifies nothing and will boot whatever sits at the ref. This is the tamper-resistance story of the whole product and it must land before real hardware exists. Needs /etc/containers/policy.json plus registries.d/.

Sign with our own cosign keypair, not keyless. Keyless pins trust to a GitHub OIDC identity, so renaming the repo or restructuring the workflow silently breaks updates on every fielded machine, and containers-policy's keyless identity matching is the fussier path. Keep the keyless signature too if it is free; verify against a key we control and can rotate deliberately.

2. An installed machine must point at the public registry — built. ✅ A disk built from localhost/kosher-linux:dev records that as its bootc origin and never finds an update again; every machine installed before this existed is in that state and needs a reinstall to get onto the update path. just release-iso [CHANNEL] pulls ghcr.io/aareman/kosher-linux:stable (or :edge) and builds the ISO from it, so the installed machine's origin is the channel and bootc upgrade — and the timer that runs it — follow it. The ISO is named after the version inside the image. Still to confirm: bootc status on the actual machine, not in a VM.

3. Channels — built. ✅ Two channels, decided by the user: :edge is published by CI on every push to master (with the commit-sha tag and the v<VERSION> tag, plus a GitHub release), and is the maintainer's own daily driver where every build lands first. :stable is never built by a push: the "Promote to stable" workflow (.github/workflows/release-stable.yml, run by hand with a version tag) copies the exact signed image for that version to :stable and :latest, after verifying its cosign signature came from this repository's CI, and marks the GitHub release as the stable one. So what families run is a build somebody chose. The immutable per-version tags are what a rollback names.

Versions are sequential, and the tag is the version. Nothing in the tree records the number: scripts/version.py counts it from the last tag reachable from a commit plus what the commits since it say (a feat moves the minor, anything else the patch). The image job builds the pushed commit with that number in it, and the release job, after the tests and the build have passed, pushes the tag, copies the image to the version's tag and signs it, then creates the release on the tag (scripts/publish-releases.py). The number is spent only when the tag lands, so a run that fails before that spends nothing and the next merge takes the same number. A run that dies after it leaves a tag with no release, and the next run finishes it.

It used to be a VERSION file that CI committed to master before building. Every failure downstream burned a number, and the release step failed for good whenever a commit touching a workflow file landed before it ran: GitHub demands a scope the Actions token cannot hold to release an explicit commit in that case. 0.3.0, 0.4.2, 0.5.0 and 0.6.0 were spent that way and tagged by hand afterwards.

A machine can move between them.ListChannels says which channels exist, which one this computer follows and what each one would switch it to; SetChannel runs bootc switch onto the other tag of the same repository, so a fork or a locally built image switches within itself and this can never point a computer somewhere else. It is the same pull as an update, on the same progress signals, staged for the next restart, with accounts, settings and files untouched — and it is the way a machine pinned to one version rejoins a channel. Guardian-gated and written to the activity log, because putting the family computer on edge means builds nobody has tried yet. The admin app's Updates page offers the two channels as rows that say what each one means, with a check on the one in use; kosherctl system channel lists them and kosherctl system channel edge switches. A bootc switch does not move the running deployment, only the one queued for the next restart, so both surfaces read the staged image as well and say "at the next restart" rather than claiming nothing happened. A machine on no channel at all — a pinned version, a digest, or anything installed before channels existed — is shown as exactly that, with the note that it will not update on its own.

4. Rollback — built. ✅ The daemon exposes DeploymentStatus (which image is booted, and what "go back" would return to) and Rollback, with kosherctl system status|check|update|rollback|channel over them and a "Go back to the previous version" control on the admin app's Updates page, which names the version it would return to and is disabled with a reason when there is nothing to go back to. Rollback needs the update right but not the guardian password: the image is one this machine already ran, and greenboot has to be able to do the same thing with no password at all, so gating it would mainly risk a machine nobody present can repair. Changing which image the machine runs is written to the activity log in both directions — a rollback especially, since it can restore an older filter.

And the machine puts itself back. greenboot is installed and greenboot-healthcheck.service enabled (its own [Install] carries Also=greenboot-set-rollback-trigger.service, so enabling one arms both — verified against the unit in fedora-bootc:44, and the build now asserts is-enabled for both so a rename fails the build rather than shipping a machine with no floor). The required check at os-image/files/etc/greenboot/check/required.d/10-kosher-filter.sh asserts kosherd is up, the resolver is up and answering on loopback, the inet kosher nftables table is loaded, and — only where somebody is actually in filtered mode — that the proxy is running. If a newly staged image fails that, the boot counter runs down and the machine returns to the deployment it came from.

Every assertion in that check is deliberately local. It must never test whether the internet works: a family's router being off would otherwise roll the operating system back, which is both useless and alarming. There is a test asserting the script reaches no network.

Still open here: a decision on what the auto-update timer does — staging silently and taking effect at the next natural reboot is the recommendation, rather than nagging a parent. And greenboot has never actually fired: the unit name is verified and the check is unit-tested, but watching a deliberately broken image roll itself back needs a VM boot, which needs sudo and is a person's job.

5. List updates must not require enrolment. Today the only path to fresh lists is a family enrolling in a portal. For a shipped product the category database and word lists have to refresh on every machine out of the box — requiring a non-technical parent to enrol somewhere before their lists stop going stale is the same failure as requiring them to curate the lists themselves. Split the portal's two roles: a public update service (unauthenticated GET of the signed bundle and manifest, public key pinned into the image at build time) and the per-family portal (enrolment, policy, remote support). Same codebase, two deployments.

This is the central promise rather than a convenience. A device that has never been enrolled, never registered and never paid for anything still gets current filter lists forever. Anything less recreates the per-device subscription this project exists to replace.

6. The list build belongs in CI, not the image build. os-image/Containerfile:146 runs fetch-categories.py with || echo "WARNING: category import failed", so one bad upstream day ships an image with the seed list and nobody notices. Move it to a nightly job that builds the database, puts it behind a CDN, and publishes the signed manifest. The image then carries the last good snapshot and machines pull newer on their own.

7. Marketing site and downloads. Static site; a download page with the ISO, its SHA-256, its signature, and instructions for checking them; a page on what is actually filtered; a support page. Lowest risk, and genuinely the last thing that has to be right.

Costs

Near zero, by construction, and it has to stay that way as adoption grows.

ghcr is free for public images with no meaningful egress cap, and bootc pulls only changed layers. GitHub Pages and Releases are free.

The 190 MB category database should be a GitHub Release asset, not a file on the VPS. This falls out of catalogsync.py's design rather than being a trick: the portal signs a manifest carrying the URL and the SHA-256, and the device verifies the hash before the database goes near the filter — so the download itself needs no trust and can come from anywhere. GitHub Releases are free, CDN-backed, need no authentication for a public repository, and take files far larger than this. The nightly job that rebuilds the database publishes it as a release asset and hands the portal a manifest pointing at it.

That leaves the VPS serving only the signed manifest and the policy endpoints — kilobytes per device per day. The recurring bill is one small VPS regardless of whether ten families or ten thousand are running KosherOS, which is the property the project needs.

The number worth measuring before committing to anything: the compressed size of the image itself. The qcow2 is 4.77 GB, so a few GB compressed is the working estimate, and it drives every other figure here.