Case Study

Nora

A self-hosted tunnel that gives a local web app a stable public HTTPS address for previews, webhooks and WebSockets, by invitation only.

Period
– present
Role
Founder & sole engineer
  • 1 h 22 min

    First commit to v1.0.0

  • 3 (v1.0.0 to v1.1.0)

    Releases

  • 5 (Linux and macOS on amd64 and arm64, Windows on amd64)

    CLI platforms

  • 34

    Commits

Terminal: nora version, nora status and nora --help

A self-hosted ngrok alternative, from first commit to a released CLI and a live relay in one evening

Problem

Webhooks, preview links and WebSocket clients need a public HTTPS address, and hosted tunnels hand out addresses that change or put traffic through someone else's service. I wanted a stable https://NAME.nora.davblu.dev for any app running on my machine, relayed through my own VPS, with access limited to people I invite.

What was built

Nora is a Go CLI and a relay server. The CLI opens one outbound WebSocket to the relay and carries yamux streams over it, one HTTP exchange per stream. Request and response bodies are streamed, never buffered whole, and a WebSocket upgrade switches its stream to opaque duplex bytes, so large downloads, server-sent events and long-lived sockets pass through unchanged. One session holds a name at a time and is never displaced, reconnects use capped backoff with jitter, and a reservation survives disconnects and server restarts in SQLite.

Access is by single-use invitation, with a separately revocable token per device that never appears in URLs, logs or project files; control-plane access logs are off so invitation codes are never recorded. A reservation also owns every name one level beneath it: the relay obtains a *.NAME certificate from Let's Encrypt through Cloudflare DNS-01 and Traefik passes those connections through by SNI, so apps that pick a tenant from the Host header work without custom domains. A project file can route nested names or path prefixes to different local ports.

Outcome

The relay runs at nora.davblu.dev on my own VPS, deployed with Docker Compose behind the shared Traefik. GitLab CI validates every push with the race detector, releases the CLI with GoReleaser, publishes the downloads and deploys. The CLI installs with one checksum-verified command on Linux, macOS and Windows. The source is private; releases and install scripts are public.

Shipping Timeline

From zero to production.

  1. 01 · Requirements

    Product decisions and backlog written down

    Invitations, per-device tokens, one session per name, deployment on the existing VPS.

  2. 02 · Build

    Protocol, relay and CLI

    HTTP and WebSocket streaming over yamux, reconnect, invitations, revocation and status.

  3. 03 · Deploy

    v1.0.0 released and the relay deployed through GitLab CI

    1 h 22 min after the first commit.

  4. 04 · Build

    Checksum-verified installers, v1.0.1

    One command on Linux and macOS, one on Windows PowerShell.

  5. 05 · Build

    Nested names with per-reservation certificates

    *.NAME certificates via DNS-01, passed through Traefik by SNI.

  6. 06 · Deploy

    v1.1.0: routes to several local targets

    Nested names and path prefixes reach different local ports; slow targets get five minutes.

Engineering

How it is built.

Stack

  • Go
  • SQLite
  • Docker
  • GitLab CI/CD
  • Traefik
  • TLS automation (ACME)
  • Agent skills (Claude Code)

Decisions

A revoked user keeps their reserved names
Revoking a user closes their sessions and blocks sign-in, but their names stay reserved until the Owner releases them. Freeing names automatically would be convenient, but could send existing webhooks and bookmarked URLs to someone else's app.
A reservation owns *.NAME, one level deep
Nested names let multi-tenant apps work without custom domains, and names stay a single label so no nested name can belong to another user. Nora obtains a certificate per reservation instead of editing the shared Traefik configuration, and caps nesting at one level so visitors cannot spend the Let's Encrypt quota for davblu.dev.
One HTTP exchange per stream, nothing buffered
Each yamux stream carries exactly one raw HTTP/1.1 exchange, and transfer framing marks where a body ends. Bodies stream through with no whole-body buffering and no body-wide deadline, so large and streaming responses and WebSocket upgrades use the same path.
Credentials stay out of URLs and logs
Device tokens travel only in the Authorization header, never in URLs, logs, configuration files or stream metadata. Access logs on the control-plane router are disabled so invitation codes are never recorded, and request output carries no query strings, bodies or credential headers.
A live session is never displaced
A second connection for a name already online is rejected with a clear error instead of taking over. Cleanup is compare-and-remove, so an old session shutting down cannot delete its replacement's registration.