My friend is too cheap to pay for Plex’s remote-play feature. He still wanted to watch off my server. That’s the whole reason I learned OpenTofu.
I run a Plex server at home — the box itself is a story for another day. Sharing it over the internet the easy way means either paying Plex or punching a hole in my home network, and I know exactly how bad an idea the second one is. Dropping a friend, even a trusted one, onto your home LAN means trusting his laptop, his torrents, and whatever’s already living on them, with everything else you’ve got running at home.
So I didn’t open the network. I put him on my Tailscale tailnet instead — but scoped, in code. I wrote a Tailscale ACL in OpenTofu that grants him exactly one thing: the Plex box, and nothing else on the tailnet. The box wears a tag, tag:plex, and the policy says his account can reach that tag and stops there.
That was my first real infra-as-code change. It worked on the first apply, he got his movies, and my network stayed shut. And it quietly rewired how I looked at everything else I’d been managing by hand.
The snowball
One clean win and I couldn’t unsee the rest of it. The next thing on the list was GitHub. I had years of repos with settings I’d clicked into existence one at a time and couldn’t reconstruct. So I started codifying it: standardized repo settings as Tofu-managed rules, a release-tag process fanned across every repo, and — the part I like most — creating a new repo by asking Claude for it instead of hand-rolling the same settings dance every single time.
Then it got out of hand and I pulled in Cloudflare — R2, Pages, a couple of Workers, the DNS. It slotted right in for basically everything a web developer touches. At that point it stopped being “my homelab” and started being my whole control plane, in one repo, behind pull requests.
The longest night was release-please
The night that nearly broke me was the release process.
I ship updates to my TUIs and personal repos a couple of times a month, and every release I used to hand-roll: bump a Homebrew formula if the project’s a tap, publish to npm if it’s a package, or just cut a new version on main if it’s neither. Same ceremony, re-derived per repo, forever. I got tired of it and decided to build one release-please template and fan it out to every repo — the workflow, config, and manifest get pushed into each repo by Tofu (github_repository_file), not hand-committed.
The release-please part was the easy half. The painful half was that every existing repo already had its own bespoke deploy, and I had to fold all of them into one shape without breaking any of them. That’s where the pattern that saved it fell out: release-please owns versioning (feat:/fix: PR → it opens and self-merges a release PR → tag), and a separate, ad-hoc release action owns the actual shipping — fan out to a brew formula, push to npm, or do nothing, depending on the repo. Two jobs, cleanly split, instead of one tangled script per project.
And release-please has teeth. Its parser reads the entire squash commit — your title plus the bullet list GitHub staples on underneath — and if one bullet trips it (a stray feat(scope):, a {a,b} inside a fenced block), it soft-fails in total silence: no error, no release, even though the PR title plainly says feat:. PR #10 ate a release exactly that way, and I had to force it out with a Release-As: footer. Not in any docs I’d read — just a scar.
One brutal night. But the gains are massive: I haven’t hand-rolled a release since.
How it’s wired
It’s one root module, one state file, and a few rules that do more than they look like they should.
State lives in a Cloudflare R2 bucket that Tofu itself manages — imported, never created, with prevent_destroy so a careless plan can’t recreate it and orphan the state. Native S3 lockfile locking; no DynamoDB table to babysit.
The Tailscale policy is self-healing. The tailscale_acl resource sets overwrite_existing_content = true, so if I fat-finger a rule in the admin console at 1am, the next apply clobbers it back to what’s in acl.hujson. The friend’s access is pinned the same way: a tailscale_device_tags resource keeps tag:plex on the box, so if the tag ever gets stripped in the UI, the next apply puts it right back. The thing I built to keep him in his lane defends itself.
GitHub is in here too — repo settings, Actions secrets, the release-please collateral, even an archived-vs-active split (archived repos 403 any settings change, so Tofu manages only their archived flag and ignores the rest).
And the hack I’ll defend: two github provider blocks, one App-authenticated and one aliased to a fine-grained PAT. The repository resource runs on the PAT because the github provider v6 has a bug where, under App auth on a personal account, it decides you’re not an org, and the read path quietly falls apart. Ugly? A little. But it’s a one-line escape hatch with a comment explaining exactly why, and the alternative was blocking on an upstream fix.
The discipline is all manual, which is the funny part: branch protection isn’t even enforced (rulesets want GitHub Pro on private repos), so nothing actually stops me from pushing to main. What stops me is the wiring — plan.yml posts the diff on every PR, apply.yml runs on merge — and a rule I keep for myself: read the plan, then merge. A green check only proves Tofu reached the providers; it doesn’t prove the diff is what I meant.
What I codify, and what I don’t
The most ridiculous part I added for no practical reason whatsoever: my GitHub profile — the victorstein/victorstein repo whose README is the first thing anyone sees on my page — is managed by OpenTofu too, from the same stein-infra repo. The README itself gets written on apply by a github_repository_file block. Is there a single practical reason to render my own profile through OpenTofu? None at all. ‘Cause I can. lol.
The one thing I leave manual on purpose is the invite. My friend still got added to the tailnet by hand — that’s a human decision and I want it to stay one. But everything after the invite — what he can reach, which is Plex and only Plex — is code, reviewed in a PR, and self-healing if anyone touches it.
The server itself is a story for another day. But the rule that lets a cheap friend watch movies without owning one byte more of my network than he needs? That one I can git blame. And if I can’t git blame it, I’ll get around to it.