The single most common complaint about ngrok's free plan is the URL rotation: every time you restart the agent, your public URL changes. Stripe, GitHub, Auth0, Discord — every webhook integration breaks until you re-paste the new URL into their dashboard.
ngrok's answer is "upgrade to Pro for static domains, $20/mo." That's a reasonable price point — for a freelancer doing this every day. For a hobbyist, a student, an OSS contributor, or a dev exploring a new framework on the weekend, $20/mo for one feature is hard to swallow.
lrok's answer: free plan includes one reserved subdomain that lives forever. You set the integration URL once, and it stays put.
The implementation
Reservations live in SQLite. One row per claim:
CREATE TABLE reservations (
user_id TEXT NOT NULL,
subdomain TEXT NOT NULL,
description TEXT,
created_at INTEGER NOT NULL,
PRIMARY KEY (subdomain),
FOREIGN KEY (user_id) REFERENCES users(clerk_id)
);
When the agent registers a tunnel with --hint mysite, the tunnel server checks: does mysite exist in reservations? If yes and the user_id matches, allow. If yes and the user_id is someone else's, reject. If no row, treat the hint as ephemeral (first-come).
That's 200 lines of Go including the API endpoints to create / list / delete reservations from the dashboard.
Why it can be free
Reserved subdomains cost nothing on our side. They're TEXT in a SQLite database. The cost ngrok charges for them is positioning — "Pro tier benefit" — not infrastructure cost.
The actual costs of running a tunnel service are: bandwidth, CPU, support. None scales with reservations; they scale with active traffic. Charging $9/mo flat for Pro lets us cover those without needing to nickle-and-dime the free tier on a feature that costs us $0.
Implementation gotchas worth mentioning
Subdomain squatting. You don't want one user grabbing every word in the dictionary. We cap free-plan reservations at 1 (Pro is unlimited). For legitimate dev needs, 1 is plenty. For squatting, 1-per-account makes the economics not work.
Releasing. Users sometimes reserve a name, abandon the project, and never release it. Two policies that worked: a description field surfaced on the dashboard so the user remembers what the name is for, and a "release" button right next to it. We don't auto-expire reservations — it'd break dormant integrations that the user still cares about.
Conflict with running tunnels. If user A has a reservation for mysite and user B (somehow) has an active tunnel using the same subdomain, the active tunnel keeps running. New tunnels can't claim the name. This means deleting a reservation doesn't kick off active sessions — they keep going until the agent disconnects. Saner default; matches the "release the name" verb.
What you can do with one free subdomain
A lot. For most devs, one Stripe + GitHub + Auth0 webhook URL all live behind the same tunnel:
$ lrok reserve mysite
$ lrok http 3000 --hint mysite
Then in the dashboards:
- Stripe:
https://mysite.lrok.io/webhooks/stripe - GitHub:
https://mysite.lrok.io/webhooks/github - Auth0 callback:
https://mysite.lrok.io/auth/callback
One tunnel, three integrations, all routed by your local app's path matching.
Try it
$ curl -fsSL https://lrok.io/install.sh | sh
$ lrok login
$ lrok reserve mysite
$ lrok http 3000 --hint mysite
The walkthroughs for Stripe webhooks, GitHub webhooks, and the broader integration list all assume you've reserved a name first.