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
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.
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.
| Setting | Value | What it governs |
|---|---|---|
MAX_CACHE_BYTES | 512 MB | LRU eviction ceiling — a flood of cold keys cannot evict the warm set |
MAX_CACHE_ENTRIES | 16,384 | Entry count ceiling |
MAX_FILE_BYTES | 32 MB | Largest single cacheable file |
GZIP_MIN_BYTES | 1,024 | Below this, compressing costs more than it saves |
CONTENT_TTL_SECONDS | 0 | No 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.
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:
PrivateDeviceswould hide/dev/kvm, so the guest could not boot at all.ProtectHomewould empty/root, where the Ops image cache lives.MemoryDenyWriteExecutebreaks 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.