ITYS I Told You So

The itys.net unikernel

itys.net is not served by a web server on a machine. It is served by a Go program that is the operating system, running as a KVM guest with the entire site compiled into its disk image. Thirty-three sites share it.

Guest OS
Nanos 0.1.55
Built by
Ops 0.1.46
Payload
Go 1.22.2
Resources
4 vCPU · 2 GB
Image
141 MB
Sites
33

What a unikernel is doing here

An ordinary static host runs Linux, then nginx, then reads files from a filesystem. This one collapses those layers. The Go program in /opt/unik-sites/site/server.go — 723 lines — is linked against Nanos, a kernel that supports exactly one process and has no shell, no package manager, no users, and no way to log in. Ops builds that pairing into a bootable image; qemu runs it under KVM.

The attack surface is the argument. There is nothing on the guest to escalate to. A flaw in the request path yields control of a process that already has no neighbours, no persistence, and no writable content — the image is rebuilt from scratch on every start.

How a request arrives

HOST — mia-01 GUEST — Nanos, no shell, no disk writes KVM boundary Browser itys.net :443 nginx stream, SNI map :9443 Terminate TLS 36 certs baked at /certs, re-read every 30s Check Host against allowlist subdirectories of /sites · unknown hosts rejected here Serve from RAM /sites/<host>/dist · gzip + ETag precomputed
nginx never reads a file for these hosts. It works at layer 4 — matching the TLS SNI name and forwarding the still-encrypted stream to loopback, so the certificate is presented by the guest, not the host.

The allowlist check happens before any content lookup. That ordering is deliberate: an attacker sending unknown Host headers cannot force cache entries and evict the warm set, because their request is refused before it can allocate anything.

The one decision everything follows from

Content and certificates are baked into the image at build time by MapDirs in config.json. There are no 9p mounts. Nothing is read from the host while the guest runs.

/var/www/<site> 33 site trees /opt/unik-sites/certs 36 certificates MapDirs ops run builds the image image 141 MB, immutable qemu boot ≈ 2 seconds no path back — the running guest cannot see host changes
Every publish and every certificate renewal is a rebuild. There is no reload, no cache flush, and no way to correct a mistake in place.

Why not mount the directories instead?

Because it crashes. The service unit records the finding plainly: on Nanos 0.1.55, a 9p --mount combined with a baked image page-faults the guest on boot. Baking sidesteps that, and has a second benefit — it keeps the request path off 9p entirely, which was the original performance problem.

Serving from RAM

The server reads each path from its filesystem once, on first request, then answers from memory forever. Concurrent misses for the same path collapse into a single read. Compression happens at that moment, not per request: the gzip variant is stored beside the raw bytes and the hot path just picks one by Accept-Encoding. Responses carry a content-hash ETag, so warm clients get 304s.

SettingValueWhat it governs
MAX_CACHE_BYTES512 MBLRU eviction ceiling — a flood of cold keys cannot evict the warm set
MAX_CACHE_ENTRIES16,384Entry count ceiling
MAX_FILE_BYTES32 MBLargest single cacheable file
GZIP_MIN_BYTES1,024Below this, compressing costs more than it saves
CONTENT_TTL_SECONDS0No periodic flush — baked content cannot change under a running guest

The elaborate caching exists because Nanos’ 9p client serialises round-trips: per-request stat and open against a mounted filesystem pegged the CPU under concurrent HTTPS. That was the wedge that motivated the design. Under the current baked configuration there is no 9p on the request path at all — so the cache is now belt-and-braces rather than load-bearing. Worth knowing before anyone “simplifies” it: the machinery becomes essential again the moment someone reintroduces a mount.

Operating it

Publishing

Get files into /var/www/<site>/dist, then restart. For itys.net that is automated — an hourly timer pulls a tarball from Backblaze B2, verifies its checksum against a published manifest, swaps the tree by rename, and restarts the unit only when the content actually changed.

systemctl restart unik-sites.service

Certificates

The guest re-reads /certs every 30 seconds and skips unchanged files — but /certs is baked, so a renewal on the host is invisible until a rebuild. A certbot deploy hook, sync-unik-certs.sh, restarts the unit for exactly this reason. That restart also re-bakes whatever is currently in /var/www, which is worth remembering if a half-finished change is sitting there.

Hardening, and three deliberate omissions

The unit is tightly confined — ProtectSystem=strict, DevicePolicy=closed with only /dev/kvm and /dev/net/tun allowed, NoNewPrivileges, RestrictNamespaces, LockPersonality. Three obvious directives are left off on purpose, each documented in the unit:

  • PrivateDevices would hide /dev/kvm, so the guest could not boot at all.
  • ProtectHome would empty /root, where the Ops image cache lives.
  • MemoryDenyWriteExecute breaks qemu, which maps writable-executable memory.

Sharp edges


Compiled from the running configuration on mia-01 — the systemd unit, config.json, the header commentary in server.go, and a live test of whether host writes reach the guest. Verified 10 September 2026.