rentfree.site is live: static hosting where ownership is a key

rentfree.site went live this week. Sites have no account behind them — no username/password pair, no dashboard login, no password reset. A Site is created on the box, or with a single-use invite code the operator hands out, and from then on the deploy path is the account: git push over SSH, no upload form, no HTTP endpoint. Loss of the key is fatal by design.

It runs on a NixOS VM on the home cluster, hardware I already own rather than a rented VPS. Three processes: the control plane (SSH, deploy hooks, quotas, Site serving), the frontend (landing, /wall, /memes, /docs, /dashboard), and Caddy with exactly two rules.

One tenant, not many

The plan started out public — creation ceremony, badge, automated expiry, an abuse posture, a Public Suffix List submission — all machinery sized for a population of anonymous strangers. The pivot cut it: multi-tenant became single-tenant, and the public machinery went with it. Expiry has nothing to recycle; badges have no population to segment; abuse tooling solves a problem that no longer exists. Everything downstream follows from that one cut — no accounts, no expiry, no abuse tooling — and the sections below are that bet under test.

The key is the account

Identity lives in sshd's authorized_keys. Each known key gets one line at auth time:

command="/opt/rentfree/bin/git-shell <pubkey> <db> <repos> <docroot> <bin>",restrict <pubkey>

git-shell takes the requested Site name from the client's command and checks the database: if the key owns that Site, proceed; if not, denied. The client can lie about the path — it cannot lie past the ownership check. One key can own many Sites without introducing an owner entity, because the lookup is Site-to-pubkey, not key-to-account.

The obvious alternative was a forge's pages product; I rejected it deliberately. Every forge hangs pages off its user-account database, and ours is the one thing the platform is about: there is no user database to translate to. The hooks we'd have to write as plugins anyway — quota checks, the atomic docroot swap, keeping full history — are the substrate. Single tenancy removes the rest of the dependency's justification; we'd inherit a forge's schema migrations and admin UI to serve one operator's hardware.

The tradeoff that makes key-based ownership possible is that there is no recovery. Lose the key and the Site is orphaned — nothing restores it, nothing deletes it. The one way out is ssh git@rentfree.site delete <sitename>, typed with the Site name as confirmation, one irreversible call that purges the docroot, the repo, the database row, and the metering.

The endpoint that almost shipped anyway

The near-miss: the design said Sites are created by the operator over SSH/CLI, and a leftover endpoint was described as "the operator's own key-minting tool." No code ever enforced that. /api/sites sat on the public mux behind the wildcard rule with no authentication, no host check, and no bind restriction, and its handler went straight from method check to key generation. Anyone who found it got a working deploy credential minted by the server. The invite path that survived at least required a single-use code to redeem; this endpoint demanded nothing — its only control was a rate limit sized as a backstop, not a gate.

I deleted it rather than securing it. Site creation is rentfree admin create, which binds the Site to a public key you supply; the server never generates private key material on any path. The cost is one ssh-keygen before the first deploy; there is no second credential model left to drift.

Key-based ownership kept paying costs on the way to live

The costs, in the order they surfaced:

  • Readonly database on first deploy: attempt to write a readonly database. The data directory used a setgid group and umask 002 so multiple processes could share it, but modernc.org/sqlite creates files 0644 regardless of umask — the dance wasn't load-bearing, and whichever process created the WAL sidecar locked the other out. Fix: Open widens db/-wal/-shm to 0660.
  • Empty working tree on owner clone: git init --bare left HEAD dangling at master while clients push main; serving never looked at HEAD, but clone does, and the rollback path — re-pushing an old state from the owner's local clone — was quietly broken. Found by the SSH integration gate the first time it ran against a real sshd. Fix: post-receive points HEAD at the branch it just deployed.
  • Guest hangs at boot: 46 MB of reads, zero writes. Moving to nixpkgs's disk-image module dropped the qemu-guest profile nixos-generators had pulled in implicitly; the initrd lacked virtio_pci, so the guest couldn't enumerate its own disk and hung in stage 1. Fix: import the profile and give the headless domain a serial console.
  • Deploys fail from one source address: every connection arrives through the host NAT forward and shares the libvirt gateway's address; WAN scanner noise tripped sshd's PerSourcePenalties and collateral-blocked legit pushes. Fix: pubkey-only, no root login, only the git deploy user — the penalty box is off because there's exactly one login path to penalize.

All the control-plane state is two directories plus one SQLite file; rsync copies it. The limits fire in pre-receive before anything lands: a tree over 100 MB is rejected, the 21st deploy of the day is refused, and bandwidth is metered with no enforcement arm — deliberate, on the same logic as the pivot: there's no population to police.

The state of it

Three weeks from locked decisions to a running VM — the pivot first among them. No accounts, no recovery, one deploy path: those stayed locked through every bug above. Full history is kept server-side, so git log and git revert work as rollback. The platform hosts its own frontend now — the wall, the meme generator, the docs, the dashboard. Everything above is checkable at rentfree.site/docs, five pages, no login.