Architecture
A technical look at how raba is actually built, for readers who want more than "it's a tunnel." (This is a public-facing summary — the full internal spec this is drawn from covers implementation-level detail this page deliberately leaves out.)
Monolithic, not microservices
One Rust binary, one Docker image, SQLite for storage. TLS termination, subdomain routing,
and static asset serving all happen inside that one binary — there's no nginx or reverse
proxy in front by default (though nothing stops you from putting one there if you're sharing
a host's :443 with other services — see the shared-VPS deployment notes in the repo).
Internally, it's modular and independently testable; externally, it's one thing to deploy and
keep running.
The tunnel protocol
Server and Client (the CLI, on your machine) talk over a single persistent, TLS-wrapped TCP
connection — not WebSocket, not a second HTTP layer, a custom binary frame protocol designed
for exactly this job. Every tunneled connection (an HTTP request, a TCP session, a UDP
pseudo-session) becomes a logical stream multiplexed over that one connection: a
StreamOpen frame announces it, StreamData frames carry the bytes in both directions, and
StreamClose ends it. A stream_id tags every frame so many concurrent tunneled connections
can share the one underlying TCP connection without interfering with each other.
This is also where byte-level forwarding comes from structurally:
the frame protocol carries opaque payload bytes: the server's job is moving StreamData
frames between the visitor's connection and the Client's connection, not understanding
what's inside them.
Three separate auth systems
Deliberately three, each scoped to one actor, none interchangeable:
| Credential | Who uses it | Scope | Lifetime |
|---|---|---|---|
| JWT session cookie | Browser (dashboard) | Dashboard only | Short-lived |
| Personal Access Token (PAT) | CLI | Management API only (/api/projects, etc.) | Long-lived, revocable |
| Project secret | Client (tunnel connection) | Tunnel handshake only | Long-lived |
A PAT can never authenticate a tunnel handshake; a project secret can never call the management API. All three are CSPRNG-generated and stored only as a hash — shown to you in plaintext exactly once, at creation time.
TLS and domain resolution
Certificates are resolved per-connection by SNI (the hostname the client's TLS handshake asks for), against an in-memory store kept in sync with what's on disk and in the database. Both the instance's own domain and every team's verified custom domain go through the same ACME DNS-01 issuance and renewal path — one code path regardless of who owns the domain, not two parallel mechanisms. See Teams and custom domains for how the delegated-DNS model avoids ever needing a team's DNS provider credentials.
Multi-tenancy
Teams, not individual users, own projects and domains. Any member of a team can act on that
team's projects, gated by a per-team role tier (member/admin/owner) — not just whoever
happened to create a given project. Every account gets a personal team automatically on
signup, so there's always at least one team to work in without any setup.
Instance-wide admin authority (managing every team's projects, instance settings, user
accounts) is derived from this same team system rather than being a second, parallel
permission model: one team is flagged as the instance team (the first signup's personal team,
by default — enforced at the database level so only one team can ever hold that flag), and
owner/admin-tier membership of it is instance-wide admin access. See
Teams and custom domains for
how that's actually granted.
Storage
SQLite, one file, embedded — no separate database service to run or configure. Chosen deliberately for a self-hosted single-binary deployment: one less moving part, one less thing that can be misconfigured or need its own backup story separate from the rest of the instance's data.