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:Openwidensdb/-wal/-shmto 0660. - Empty working tree on owner clone:
git init --bareleft HEAD dangling atmasterwhile clients pushmain; 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.