# Reader brief — give a private AI service its first home on hardware you own **Use this if:** you want to run an AI service for a few trusted testers before you rent anything, and you want the front door to be defensible on day one. **Time:** an afternoon if the machine already runs Linux; a weekend if it does not. **You need:** a machine with a GPU you already own, a domain on Cloudflare, a tailnet, and the willingness to keep the model local. This is the setup behind *The laptop in the closet*: a gaming laptop — i7, 64 GB, RTX 3080 Ti, Fedora — serving a local model to testers through a Cloudflare Tunnel, with the tailnet as the only administrative path. The pack is the deploy runbook, the tailnet runbook, the actual tunnel config and the identity trap that cost a day. ## The prompt ```text You are the operator of a small, private AI service. The human has a machine with a GPU that they already own and a handful of testers. Design and stand up the first version. Optimise for "correct and cheap enough to be wrong", not for scale. CONSTRAINTS (do not negotiate these away) - The model runs on the same machine as the data. Nothing a tester types may cross the network to a model provider. - There is no inbound application port and no public SSH. The only way in is a tunnel the operator controls; administrative access is over the tailnet. - The machine is not a server. It sleeps, throttles and loses power. The product must tell the truth about that (a visible waking state), not pretend to be always-on. - One engine, one data store, one owner per piece of state. DELIVER 1. A one-page topology: machine, model runtime, tunnel, identity provider, operator path. 2. The exact command sequence to install the runtime and load the model locally. 3. The tunnel configuration (ingress rules, which hostnames, which loopback ports) and the DNS records it needs. 4. An identity section that answers, with a test: who is a caller, and what proves it? Enumerate every proxy in the path and say which headers it injects, forwards and strips. 5. A verification script with a non-zero exit: loopback health, tunnel reachability, admin routes refused from remote, and a forged-identity probe (see the acceptance checks). 6. A rollback: the single command that makes the box disappear from the internet while leaving the engine running. ACCEPTANCE CHECKS (all must pass, or the front door is not done) - Public SSH is closed; the only inbound path is the tunnel. - A request that carries a forged identity header does NOT resolve to the operator. It must land in anonymous scope or be rejected, never read the operator's data. - A tester arriving with no identity header is either mapped to a single explicit sentinel or rejected; there is no third behaviour. - Admin/reset/ingest routes return 403/404 from the tunnel. - The model is local: the outbound connections of the service contain no model provider. - Rollback takes one command and does not stop the engine. Report back with the topology, the test output, and the list of things you could not verify. ``` ## Feed it these files | File | What it gives you | |---|---| | `deploy-m2-beta.md` | the full M2 runbook — DB gate, native rebuild, systemd, tunnel, Access, rollback | | `remote-access.md` | the tailnet-first model: what `tailscale serve` injects and strips, and why | | `config/cloudflared.yml` | the actual named-tunnel ingress: four hostnames, one loopback origin, everything else 404 | | `identity-trap.md` | the two-proxy identity failure and the two independent fixes | ## What good looks like - [ ] The model process and the store are on the same machine; the only egress is the tunnel. - [ ] `cloudflared` runs as a service; DNS routes to the tunnel; there is no `:22` open to the world. - [ ] A forged `Tailscale-User-Login` header is **inert** — the request gets anonymous scope, not the owner's history. Verified with `curl`, not reasoned about. - [ ] Anonymous-but-Access-vetted traffic maps to one named sentinel, and no per-tester data is promised to it. - [ ] Admin routes are 403/404 through the tunnel, with and without the forged header. - [ ] The tunnel can be stopped in one command; the engine keeps serving loopback. - [ ] The waking state is visible in the product, because the machine genuinely wakes. ## Failure modes this piece paid for - **Two front doors, one identity model.** The remote-access code was written for the tailnet, where `tailscale serve` injects the identity header and strips a client-sent one. Cloudflare does neither: it sends its own email header and forwards `Tailscale-*` untouched. A tester could send the owner's login and be believed. Fix it in the server (`TRUST_TAILSCALE_IDENTITY=0`) **and** at the edge (a Transform Rule that removes the header). Belt and braces, because either one alone is a single point of trust. - **Assuming a proxy strips what it does not.** Write down, per proxy: injects, forwards, strips. If you cannot fill the row, you have not tested it. - **Mapping anonymous traffic to a per-person account.** Map it to one explicit sentinel that can never equal the operator's identity. - **Promising always-on from a laptop.** A laptop sleeps. Ship a waking state instead of an SLA you cannot meet. - **Exposing an operator dashboard because the tunnel was easy.** The runbook is explicit: only the application port is tunnelled; `4174`/`5173` stay loopback. - **A runbook that leaves a path open.** The cloud image shipped with password SSH enabled. The deploy overrides it and records the check; verify your host's defaults, do not assume them. ## Where to go next in the pack Read `identity-trap.md` first — it is the one lesson that generalises past this project. Then `deploy-m2-beta.md` for the build and `remote-access.md` for the tailnet-first model. `config/cloudflared.yml` is the exact ingress; copy it, replace the tunnel id, and keep the `http_status: 404` fallback. If you later move the always-on door to a VPS and rent the GPU, the topology changes but the rule does not: the public front door is a tunnel, and every proxy in the path has to be named.