Stack~
18 October 2026 6 min infrastructure m2 vps tailscale

The laptop in the closet

Before the rented GPU and the $23 server, the system that answers questions about my own corpus ran on a gaming laptop in a room nobody goes in. That was the right first home, not a compromise.

The first public version of this system ran on a laptop in a closet.

Not a metaphor, and not a humblebrag. A 17-inch gaming laptop — i7, 64 GB of RAM, an RTX 3080 Ti, four terabytes of storage — sitting on a shelf in a room with the door shut, running Fedora, serving an AI system to a handful of testers through a Cloudflare tunnel. It stayed there until the end of September, when the whole thing moved onto a rented server in a data centre and the laptop went back to being a laptop.

Every architecture post about “local-first AI” stops at the romance of that image. This one is about the part that actually mattered: why the laptop was correct, what it cost, and what changed when it left.

What was running in the closet

The system is a memory: a store of my own documents, chunked and embedded, with a reader that answers questions against what is actually in the store and refuses when it is not there. A small model writes the answer; a bigger future lives on rented hardware.

The laptop ran all of it locally, including the model:

Value Value
Host a 17" gaming laptop, Fedora/Nobara, i7 / 64 GB / RTX 3080 Ti / 4 TB
Reader local Ollama, granite3.1-dense:8b
Front door Cloudflare Tunnel, plus Cloudflare Access in front of the testers
Names two: the brand home and a second brand, same engine behind both
Operator access the tailnet, not a public SSH port

Two details were load-bearing.

The model was local on purpose. Testers typed personal things into a chat box. With the model on the same machine as the memory, nothing a tester typed crossed the network to a provider. The tunnel carried the page and the answer; the thinking happened in the room.

The front door was a tunnel, not an open port. No inbound SSH, no public application port. Cloudflare terminated TLS and forwarded to a socket bound to 127.0.0.1. For a system holding someone’s notes, “the only way in is through a tunnel I control, and the box has no open door” is worth more than any number of security features inside the app.

Why a laptop, honestly

Three reasons, in order of how much they mattered.

1. It was already paid for. The marginal cost of the first version was zero. A rented GPU with 24 GB of VRAM costs real money per hour; the laptop’s 3080 Ti was sitting there. The first thing an idea needs is not capacity, it is permission to exist cheaply enough to be wrong. I could run the whole system for a month on hardware I already owned, and I did.

2. Physical control is a feature. The data stayed in a room in my house. When I wanted to know what was on the disk, I walked over. Backups were a directory on a machine I could unplug. That is not a scalable property; for the first few months it was the most valuable one.

3. Iteration happened in the same room as the work. The machine that trained, embedded, re-ranked and served was also the machine with the code on it. A change was a save and a restart, not a CI pipeline. Every design decision in this system was made cheap by that loop.

What it cost is equally simple: a laptop is not always on. It sleeps, it thermally throttles, it loses power, and nobody is there to press a key. The first version needed the model and the store up whenever a tester opened the page. On a laptop, “whenever” is a promise you cannot keep. The dashboard had a waking state because the system genuinely had to wake.

The gotcha that only exists when two front doors disagree

The move to a real public beta produced one lesson I would put in front of anyone doing this: Cloudflare Access and the tailnet are different identity models, and they do not compose by accident.

The remote-access code was written for the tailnet, where tailscale serve injects a verified Tailscale-User-Login header and strips a client-sent one. Cloudflare does neither: it sends Cf-Access-Authenticated-User-Email, and it does not strip inbound Tailscale-* headers.

Two consequences, both real:

  1. every tester arrived identity-less, so a guard that demanded a tailnet identity would have locked all of them out — solved by mapping anonymous-but-Access-vetted traffic to a single remote-anonymous sentinel;
  2. worse, a tester could send Tailscale-User-Login: <operator> and the guard would believe it — solved by making that header inert server-side and adding an edge rule that strips it.

The lesson is general: a header is only an identity if the thing in front of you put it there. If two proxies are in the path and only one of them strips it, the header is a suggestion.

What the move changed

At the end of September the system moved off the laptop onto a CPU-only virtual server — about $23 a month, 22 GB of RAM, no GPU. The expensive reading work moved to rented GPU hardware on demand. Testers, threads and memories moved with zero conflicts; every entry was re-embedded and every hash matched before M2 was allowed to go quiet.

What that bought was not sophistication. It was always-on: a box that answers at 3 a.m. because it is a service, not a convenience. What it cost was the property that made the first version easy to reason about — the machine is no longer in my house, so the data is somewhere else.

Which is why the split that emerged is the design, not a stopgap:

Machine Job Why there
The laptop build, studio work, the design loop it has a GPU and it is where I am
The rented GPU reading at speed, on demand burst work, paid by the hour
The server the always-on door cheap, boring, up at 3 a.m.

Three machines, three jobs, none of them pretending to be the others. The laptop was not a prototype I escaped. It was the cheapest possible way to learn what the other two machines needed to be.

The part I would tell you to copy

If you are starting something like this and you own a computer with a GPU: start there. Do not rent capacity to find out whether the idea is real. Put the first version on the machine you already have, behind a tunnel you control, with the model local, and make the only promise you can keep — that it works while it is awake.

Then, when the promise you need to make becomes “it is awake when someone opens the page,” move the door to a box that never sleeps and rent the GPU only for the minutes you actually need it.

The closet was never the architecture. It was the cheapest correct answer to the question the project was actually asking at the time: what is this, and is it worth spending on?

Receipts — every primary source used

Every claim in the piece maps to a runbook, a config or a capture in this pack. Files in backticks are in the pack.

Claim Primary source
17" gaming laptop, i7 / 64 GB / RTX 3080 Ti / 4 TB, Fedora, serving testers deploy-m2-beta.md (header and decisions)
Local reader (granite3.1-dense:8b via Ollama), zero egress of tester input deploy-m2-beta.md §2 and the “Decisions baked into this runbook” table
One engine, two brands, one origin (cube face + doc-nor face) deploy-m2-beta.md; config/cloudflared.yml (four hostnames, one loopback origin)
Cloudflare Tunnel + Access as the only front door; no public SSH deploy-m2-beta.md §6; config/cloudflared.yml ingress
Tailnet identity model: tailscale serve injects Tailscale-User-Login and strips client copies remote-access.md §Topology and §Env reference
Cloudflare sends Cf-Access-Authenticated-User-Email and does not strip Tailscale-* deploy-m2-beta.md §The one architectural gotcha; identity-trap.md
The two-part fix: TRUST_TAILSCALE_IDENTITY=0 server-side + an edge Transform Rule deploy-m2-beta.md §The one architectural gotcha and §6
Anonymous-but-Access-vetted traffic maps to one remote-anonymous sentinel deploy-m2-beta.md §Decisions and §The one architectural gotcha
The forged-header test identity-trap.md §The test that proves it; deploy-m2-beta.md §7
Rollback is stopping the tunnel deploy-m2-beta.md §9
The move to the $23 server and what it bought the next piece’s pack (Moving the whole thing to a $23 VPS); this pack stops at the laptop

Capture date: the M2 beta runbook is dated 2026-07-12; the move off the laptop happened at the end of September 2026. The runbook is reproduced from the workspace with hostnames and addresses genericised; the placeholders are listed in the brief.

Attachments

Files that go with this piece — 6 files, 35 KB.

Full sha256 hashes
  • 00-reader-brief.md 88704c33afbaeeca5f7f5a9854ee6450bb751c72aeeadaea1e449d0a22940980
  • config/cloudflared.yml cc86d71413724151a7fbef5f1d1c185114f5f53cfd84b1eaef73c73fb741d6c7
  • deploy-m2-beta.md 49b71665441a194033ad99c51a73c2976ccb8ea265c91cf996479c7f5a5e6e8e
  • identity-trap.md 543367a9b9a8a4a855cf49d57bfce26348cec37119f67d4ddd53e35e10d91e16
  • licence.md cd1f7f1cf921b53e8ebfaa6ad3ab24b60912a1ad44c05d40bb20cd9c73997e60
  • remote-access.md 6145a8b4612f6f5843a241169e308328d0ceccdd59924c301e515d3ac3909ea5