<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Venkatakrishna S</title>
    <description>The latest articles on DEV Community by Venkatakrishna S (@venkatakrishna_s).</description>
    <link>https://dev.to/venkatakrishna_s</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3910004%2Ff238e2c9-bb76-444f-a46c-87040d39f857.png</url>
      <title>DEV Community: Venkatakrishna S</title>
      <link>https://dev.to/venkatakrishna_s</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/venkatakrishna_s"/>
    <language>en</language>
    <item>
      <title>mkdev: trusted HTTPS for localhost, mapped by name</title>
      <dc:creator>Venkatakrishna S</dc:creator>
      <pubDate>Mon, 25 May 2026 04:39:36 +0000</pubDate>
      <link>https://dev.to/venkatakrishna_s/mkdev-trusted-https-for-localhost-mapped-by-name-h1n</link>
      <guid>https://dev.to/venkatakrishna_s/mkdev-trusted-https-for-localhost-mapped-by-name-h1n</guid>
      <description>&lt;p&gt;Plain HTTP on localhost breaks things. OAuth callbacks want &lt;code&gt;https://&lt;/code&gt;, &lt;code&gt;Secure&lt;/code&gt; cookies won't set, service workers won't register, and some SDKs just assume TLS. Self-signed certs get you back to the "Proceed (unsafe)" click. &lt;code&gt;mkcert&lt;/code&gt; solves the cert half well, but it stops at handing you a &lt;code&gt;.pem&lt;/code&gt; and a key — you still wire that into every dev server and track which port is which.&lt;/p&gt;

&lt;p&gt;mkdev sits one layer up. You give a route a name, you get trusted HTTPS at that name.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mkdev &lt;span class="nb"&gt;install&lt;/span&gt;                    &lt;span class="c"&gt;# generate a local CA, trust it in the system store&lt;/span&gt;
mkdev add myapp localhost:3000   &lt;span class="c"&gt;# https://myapp.local → localhost:3000&lt;/span&gt;
mkdev serve                      &lt;span class="c"&gt;# run the TLS proxy&lt;/span&gt;
curl https://myapp.local         &lt;span class="c"&gt;# 200, no warnings&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fvenkatkrishna07%2Fmkdev%2Fmain%2Fassets%2Fdemo-add.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fvenkatkrishna07%2Fmkdev%2Fmain%2Fassets%2Fdemo-add.gif" alt="mkdev adding a route and serving HTTPS" width="640" height="406"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;mkdev is a TLS reverse proxy, not a cert file generator. That distinction is the whole design.&lt;/p&gt;

&lt;p&gt;On &lt;code&gt;install&lt;/code&gt; it generates an &lt;strong&gt;ECDSA P-256 root CA&lt;/strong&gt; at &lt;code&gt;~/.mkdev/ca/&lt;/code&gt; (private key written &lt;code&gt;0o400&lt;/code&gt;, owner-read only) and installs it into the OS-native trust store. The trust-store integration — Keychain via &lt;code&gt;security&lt;/code&gt; on macOS, the CA-bundle dirs plus &lt;code&gt;update-ca-*&lt;/code&gt; on Linux, the &lt;code&gt;ROOT&lt;/code&gt; store via &lt;code&gt;crypt32.dll&lt;/code&gt; on Windows — is adapted from &lt;a href="https://github.com/FiloSottile/mkcert" rel="noopener noreferrer"&gt;mkcert&lt;/a&gt; (BSD-3, credited in &lt;code&gt;LICENSE-MKCERT&lt;/code&gt;). That layer is the genuinely OS-specific part and reusing it saved a lot of pain.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;add &amp;lt;name&amp;gt; &amp;lt;target&amp;gt;&lt;/code&gt; does two things: writes the route to a &lt;a href="https://github.com/etcd-io/bbolt" rel="noopener noreferrer"&gt;bbolt&lt;/a&gt; KV at &lt;code&gt;~/.mkdev/state.db&lt;/code&gt;, and appends &lt;code&gt;127.0.0.1 myapp.local&lt;/code&gt; to &lt;code&gt;/etc/hosts&lt;/code&gt; so the name resolves. The hosts write is privileged, so the binary re-invokes itself under &lt;code&gt;sudo&lt;/code&gt; through a hidden &lt;code&gt;hosts-helper&lt;/code&gt; subcommand that edits the file atomically.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;serve&lt;/code&gt; binds TLS on &lt;code&gt;0.0.0.0:443&lt;/code&gt; and, for each incoming SNI hostname, &lt;strong&gt;mints a leaf cert on demand&lt;/strong&gt; signed by the root CA, then reverse-proxies the decrypted request to the mapped &lt;code&gt;host:port&lt;/code&gt;. Your dev server keeps speaking plain HTTP and never knows. The route table is re-read every 2 seconds, so &lt;code&gt;add&lt;/code&gt; and &lt;code&gt;remove&lt;/code&gt; take effect without restarting the proxy — no IPC needed between the CLI and the running server.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;target&amp;gt;&lt;/code&gt; accepts &lt;code&gt;host:port&lt;/code&gt;, or a full &lt;code&gt;http://&lt;/code&gt;/&lt;code&gt;https://&lt;/code&gt; URL with a path. HTTPS upstreams (say, a private GitLab on a VPN) must verify against the system trust store like anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sharing over the LAN
&lt;/h2&gt;

&lt;p&gt;Since the proxy already binds &lt;code&gt;0.0.0.0&lt;/code&gt; and mints per-hostname certs, advertising a route over mDNS is cheap. In the TUI, select a route and press &lt;code&gt;s&lt;/code&gt; to mark it shared. That &lt;code&gt;myapp.local&lt;/code&gt; gets broadcast to the local network pointing at your LAN IP, so a phone or a teammate's laptop on the same Wi-Fi can load it over real HTTPS — useful for testing mobile layouts on an actual device.&lt;/p&gt;

&lt;p&gt;The default is closed. Even though the proxy binds &lt;code&gt;0.0.0.0&lt;/code&gt;, a connection-source ACL &lt;strong&gt;403s any non-loopback request to a route not explicitly marked shared&lt;/strong&gt;. Loopback always passes. Caveats: only &lt;code&gt;.local&lt;/code&gt; routes are advertised, multicast is often blocked on corporate/cloud networks, and anyone on the LAN can reach a shared route — don't enable it on untrusted Wi-Fi. Remote devices also need to trust the mkdev CA to connect cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Known limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Firefox&lt;/strong&gt; uses its own NSS store and ignores system trust, so it warns until you import &lt;code&gt;~/.mkdev/ca/rootCA.pem&lt;/code&gt; manually. Chrome, Safari, Edge, &lt;code&gt;curl&lt;/code&gt;, and &lt;code&gt;wget&lt;/code&gt; work out of the box. NSS integration is on the roadmap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port 443 needs root.&lt;/strong&gt; If that's too much friction, set &lt;code&gt;proxy_port = 8443&lt;/code&gt; in &lt;code&gt;~/.mkdev/config.toml&lt;/code&gt; and use &lt;code&gt;https://myapp.local:8443&lt;/code&gt; without sudo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The sudo cache expires&lt;/strong&gt; (~5 min), so &lt;code&gt;mkdev add&lt;/code&gt; may re-prompt. The TUI elevates via &lt;code&gt;osascript&lt;/code&gt; (macOS) or &lt;code&gt;pkexec&lt;/code&gt; (Linux) instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;

&lt;p&gt;Single binary for macOS (Intel + Apple Silicon), Linux (amd64 + arm64), and Windows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;venkatkrishna07/tap/mkdev
&lt;span class="c"&gt;# or&lt;/span&gt;
go &lt;span class="nb"&gt;install &lt;/span&gt;github.com/venkatkrishna07/mkdev/cmd/mkdev@latest

mkdev &lt;span class="nb"&gt;install
&lt;/span&gt;mkdev   &lt;span class="c"&gt;# launches the TUI&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MIT-licensed.&lt;/p&gt;

&lt;p&gt;Code: &lt;strong&gt;&lt;a href="https://github.com/venkatkrishna07/mkdev" rel="noopener noreferrer"&gt;https://github.com/venkatkrishna07/mkdev&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>webdev</category>
      <category>devtools</category>
      <category>cli</category>
    </item>
    <item>
      <title>rift: expose private services to the internet without inbound networking</title>
      <dc:creator>Venkatakrishna S</dc:creator>
      <pubDate>Mon, 04 May 2026 13:31:45 +0000</pubDate>
      <link>https://dev.to/venkatakrishna_s/rift-expose-private-services-to-the-internet-without-inbound-networking-4km3</link>
      <guid>https://dev.to/venkatakrishna_s/rift-expose-private-services-to-the-internet-without-inbound-networking-4km3</guid>
      <description>&lt;p&gt;A common problem with a familiar shape: a process can dial outbound to the internet, but nothing on the internet can dial it back. Your dev server on a laptop. A service in a private VPC. A homelab app behind your router. A container in a pod with no ingress. Same shape every time — outbound works, inbound doesn't.&lt;/p&gt;

&lt;p&gt;rift is a small Go binary I built to solve that. Run it as a server on a VPS you own, run it as a client wherever the private service lives, and the service becomes reachable from the public internet over HTTPS. Same shape as ngrok, frp, or bore — different transport underneath.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/venkatkrishna07" rel="noopener noreferrer"&gt;
        venkatkrishna07
      &lt;/a&gt; / &lt;a href="https://github.com/venkatkrishna07/rift" rel="noopener noreferrer"&gt;
        rift
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      rift — self-hosted ngrok alternative built on QUIC.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;rift&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;A self-hosted tunnel for local development. One binary, one VPS, no accounts.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Expose localhost to the internet over a single QUIC connection — on infrastructure you fully own. Built for sharing dev servers, testing webhooks, and demoing work in progress.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://go.dev" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/099469f6af215f4e678c3efd1d47720bf3acd46fbf35298ac5ef436c83fc3bf7/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f676f2d312e32322b2d3030414444383f6c6f676f3d676f" alt="Go Version"&gt;&lt;/a&gt;
&lt;a href="https://github.com/venkatkrishna07/rift/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/b8cadaa967891081f8f165695470689986c028821dd8a040132f6e661795dc0d/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d626c7565" alt="License"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;  localhost:3000  ──── QUIC ────▶  https://myapp.tunnel.example.com
  localhost:5432  ──── QUIC ────▶  tunnel.example.com:10247
  localhost:9090  ──── QUIC ────▶  mcp.example.com (MCP server)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;rift client --server tunnel.example.com --expose 3000:http:myapp
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; → tunnel ready  https://myapp.tunnel.example.com&lt;/span&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;That's it. Your local dev server is now reachable on the internet, over HTTPS, through a server you run.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Where rift fits&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Self-hosted tunnels already exist — &lt;a href="https://github.com/fatedier/frp" rel="noopener noreferrer"&gt;frp&lt;/a&gt;, &lt;a href="https://github.com/ekzhang/bore" rel="noopener noreferrer"&gt;bore&lt;/a&gt;, &lt;a href="https://github.com/jpillora/chisel" rel="noopener noreferrer"&gt;chisel&lt;/a&gt;. They all ride on TCP. rift is the same idea, but built on QUIC, which gives you three things you can't get over TCP:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No head-of-line blocking between tunnels.&lt;/strong&gt; On TCP, a lost packet on one multiplexed stream stalls every other stream on the same connection until it's…&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/venkatkrishna07/rift" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;This post walks through how it's put together and the design decisions that shaped it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;One binary, two roles. Run it as a server on a VPS, run it as a client wherever the private service lives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rift client &lt;span class="nt"&gt;--server&lt;/span&gt; tunnel.example.com &lt;span class="nt"&gt;--expose&lt;/span&gt; 3000:http:myapp
&lt;span class="c"&gt;# → tunnel ready  https://myapp.tunnel.example.com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That's the entire user-facing surface for a single HTTP tunnel. Multiple tunnels work the same way — pass &lt;code&gt;--expose&lt;/code&gt; more than once. TCP tunnels swap &lt;code&gt;http&lt;/code&gt; for &lt;code&gt;tcp&lt;/code&gt; and get a public port instead of a subdomain.&lt;/p&gt;

&lt;p&gt;As long as the rift client can dial the rift server outbound, the server can route traffic back. No inbound firewall rules, no port forwarding, no public IP on the client side. No accounts, no hosted control plane, no telemetry. The server is yours, the data path is yours, the tokens are yours.&lt;/p&gt;
&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   visitor          rift server          rift client          private app
  ─────────         ───────────         ───────────         ──────────────

  HTTPS  ─────►   :443 (HTTPS)
  request         ─ subdomain
                    routing               QUIC
                  ─ TLS termination  ◄──── connection ───►
                                          (one per client)

                  ─ open new
                    QUIC stream     ────► stream  ────►  TCP
                                                          dial
                                                          :3000
                                                            ↓
                                                          private app
                                                          response
                                                            ↓
                  ◄──── stream  ◄──── stream  ◄────

  HTTPS  ◄─────
  response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The client opens &lt;strong&gt;one&lt;/strong&gt; QUIC connection to the server and authenticates with a bearer token. Each &lt;code&gt;--expose&lt;/code&gt; flag registers a tunnel — the server assigns either a subdomain (HTTP) or a port from a configurable range (TCP) and stores the mapping.&lt;/p&gt;

&lt;p&gt;When a visitor hits the public URL, the server figures out which tunnel they're trying to reach (subdomain for HTTP, port for TCP), opens a fresh QUIC stream to the right client, and forwards the request along it. The client receives the stream, dials the local port, and pipes bytes both ways. The response flows back along the same stream. When the request is done, the stream closes. The QUIC connection itself stays up.&lt;/p&gt;

&lt;p&gt;So the server is doing three things at once: terminating public TLS, multiplexing per-request streams onto a long-lived QUIC connection, and tracking which tunnel belongs to which client.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why QUIC
&lt;/h2&gt;

&lt;p&gt;Most self-hosted tunnels run on TCP. QUIC gave rift three properties that matter for this kind of tool:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stream isolation.&lt;/strong&gt; QUIC carries multiple independent streams inside one connection, each with its own ordering and reliability state. A lost packet on one tunnel doesn't stall the others. On TCP this is impossible at the application layer — the kernel guarantees in-order delivery for the whole connection, so a hiccup on the Postgres tunnel will freeze the HTTP tunnel until retransmission catches up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Connection migration.&lt;/strong&gt; A QUIC connection is identified by a connection ID inside each packet, not by the four-tuple of IP and ports. Switch from Wi-Fi to a hotspot, toggle a VPN, change networks mid-session — the connection survives. The client doesn't reconnect, doesn't re-authenticate, doesn't drop tunnels. This was the property I personally cared about most while developing on the move.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TLS in the handshake.&lt;/strong&gt; QUIC's handshake &lt;em&gt;is&lt;/em&gt; TLS 1.3. There's no separate "now negotiate TLS" round trip after the transport comes up. Encrypted from the first byte, fewer round trips to first useful data.&lt;/p&gt;

&lt;p&gt;Rift uses &lt;a href="https://github.com/quic-go/quic-go" rel="noopener noreferrer"&gt;&lt;code&gt;quic-go&lt;/code&gt;&lt;/a&gt; for the QUIC implementation. The server listens on UDP/443 for QUIC and TCP/443 for HTTPS on the same address — useful because public visitors arrive over plain HTTPS while clients connect over QUIC.&lt;/p&gt;
&lt;h2&gt;
  
  
  Auth: tokens and the admin API
&lt;/h2&gt;

&lt;p&gt;Authentication is a single bearer token per client. Tokens default to a 1-hour TTL — when a token expires, the connected client is disconnected. You can set &lt;code&gt;--token-ttl 0&lt;/code&gt; for tokens that never expire if you'd rather manage rotation yourself.&lt;/p&gt;

&lt;p&gt;There are two ways to provision a token. &lt;strong&gt;Offline&lt;/strong&gt;, against a stopped server with direct access to the BadgerDB data directory:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rift server &lt;span class="nt"&gt;--db&lt;/span&gt; /var/lib/rift/db &lt;span class="nt"&gt;--add-token&lt;/span&gt; alice
&lt;span class="c"&gt;# → rift_4a7f...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;Online&lt;/strong&gt;, through a loopback-only admin endpoint:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$RIFT_ADMIN_SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"http://localhost/_admin/tokens?name=alice&amp;amp;ttl=168h"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The &lt;code&gt;/_admin/tokens&lt;/code&gt; endpoint deliberately binds only to &lt;code&gt;127.0.0.1&lt;/code&gt; and &lt;code&gt;::1&lt;/code&gt;, and rate-limits at 5 req/min/IP. To provision a token from elsewhere, you SSH in first. The motivation is plain: the admin endpoint is a token factory, and a token factory exposed on the public internet is a bad idea no matter how good the auth is. Loopback-only removes that whole class of risk.&lt;/p&gt;

&lt;p&gt;One QUIC-specific detail in the auth path: tokens are never accepted in 0-RTT data. QUIC allows clients to send application data inside the very first handshake packet using cached crypto state from a previous session, which is great for latency but also means an on-path attacker can replay that packet. Auth happens after the full 1-RTT handshake completes, so a captured handshake can't be replayed to log in as someone else.&lt;/p&gt;

&lt;p&gt;The token store itself is BadgerDB — chosen because it's an embedded key-value store with no separate process to run, no network port to secure, and good enough performance that token lookups are not the bottleneck.&lt;/p&gt;
&lt;h2&gt;
  
  
  HTTP routing and automatic TLS
&lt;/h2&gt;

&lt;p&gt;HTTP tunnels are routed by subdomain. A tunnel registered as &lt;code&gt;myapp&lt;/code&gt; becomes &lt;code&gt;myapp.tunnel.example.com&lt;/code&gt;. This requires a wildcard DNS record (&lt;code&gt;*.tunnel.example.com → &amp;lt;server-ip&amp;gt;&lt;/code&gt;) and a wildcard TLS certificate.&lt;/p&gt;

&lt;p&gt;Rift handles the certificate piece automatically via Let's Encrypt. If you already have a wildcard cert from somewhere else, &lt;code&gt;--cert&lt;/code&gt; and &lt;code&gt;--key&lt;/code&gt; skip the ACME flow entirely.&lt;/p&gt;

&lt;p&gt;WebSockets work over the same HTTP tunnels with no extra configuration — the server detects the upgrade and lets the connection through transparently. This was important to get right because a lot of the things people actually tunnel for (real-time previews, hot-reload, dev servers with HMR, self-hosted apps with live UIs) depend on WebSockets working without ceremony.&lt;/p&gt;
&lt;h2&gt;
  
  
  TCP tunnels
&lt;/h2&gt;

&lt;p&gt;TCP tunnels skip the HTTP layer entirely. The server allocates a port from a configurable range (&lt;code&gt;--tcp-port-min&lt;/code&gt; to &lt;code&gt;--tcp-port-max&lt;/code&gt;, defaults 10000–65535), and incoming TCP connections on that port are forwarded over a QUIC stream to the client.&lt;/p&gt;

&lt;p&gt;Two design choices worth calling out:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No tunnel-layer auth on TCP.&lt;/strong&gt; Once a TCP tunnel is open, anyone who can reach the public port can reach your service. The tunnel is dumb pipe. You're expected to use the application's own auth (database passwords, mTLS, whatever) or restrict access at the firewall. Adding auth at the tunnel layer for arbitrary TCP would mean either terminating and re-encrypting (breaks everything) or some kind of port-knock scheme (security theatre). Neither felt right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A blocked-ports list on the local side.&lt;/strong&gt; The client refuses to expose &lt;code&gt;25, 53, 135, 139, 445, 465, 587, 3389&lt;/code&gt; — the ports an open relay or accidental SMB exposure would live on. This protects against a footgun, not a determined attacker, but the footgun is real.&lt;/p&gt;
&lt;h2&gt;
  
  
  Reconnection
&lt;/h2&gt;

&lt;p&gt;The client reconnects on transient network failures with exponential backoff from 1s to 30s. Permanent errors — invalid token, expired token, IP blocked by the server — exit the client immediately rather than spinning in a reconnect loop. The distinction matters because exponential backoff against an auth failure just produces noise in your logs and load on the server without ever recovering.&lt;/p&gt;

&lt;p&gt;Tokens are cached in &lt;code&gt;~/.local/share/rift&lt;/code&gt; after first use, so subsequent connections to the same server pick them up automatically without &lt;code&gt;--token&lt;/code&gt; on every invocation.&lt;/p&gt;
&lt;h2&gt;
  
  
  What's solid, what's not
&lt;/h2&gt;

&lt;p&gt;HTTP and TCP tunneling, automatic TLS, token auth, reconnection, WebSockets, and connection migration are all working and stable for personal and small-team use. UDP tunnels are work-in-progress.&lt;/p&gt;

&lt;p&gt;The honest caveat for QUIC-based tunnels in general: some networks (corporate firewalls, certain hotel and café Wi-Fi) block or rate-limit UDP/443. If your client environment lives behind one of those, a TCP-based tunnel like frp or chisel will be more reliable. On a normal connection, rift's behavior under multi-tunnel load and across network changes is where the QUIC choice pays off.&lt;/p&gt;
&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Terminal 1 — server, dev mode, self-signed cert&lt;/span&gt;
rift server &lt;span class="nt"&gt;--dev&lt;/span&gt; &lt;span class="nt"&gt;--listen&lt;/span&gt; :4443

&lt;span class="c"&gt;# Terminal 2 — client&lt;/span&gt;
rift client &lt;span class="nt"&gt;--server&lt;/span&gt; localhost:4443 &lt;span class="nt"&gt;--insecure&lt;/span&gt; &lt;span class="nt"&gt;--expose&lt;/span&gt; 3000:http:myapp
&lt;span class="c"&gt;# → https://myapp.tunnel.localhost&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Swap &lt;code&gt;--dev&lt;/code&gt; for a real domain and a VPS to go public. Setup details, the systemd unit, the full CLI reference, the comparison table against other self-hosted tunnels, and the &lt;code&gt;/_admin/tokens&lt;/code&gt; API are all in the &lt;a href="https://github.com/venkatkrishna07/rift" rel="noopener noreferrer"&gt;README&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Issues, PRs, and arguments in the comments all welcome.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/venkatkrishna07" rel="noopener noreferrer"&gt;
        venkatkrishna07
      &lt;/a&gt; / &lt;a href="https://github.com/venkatkrishna07/rift" rel="noopener noreferrer"&gt;
        rift
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      rift — self-hosted ngrok alternative built on QUIC.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;rift&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;A self-hosted tunnel for local development. One binary, one VPS, no accounts.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Expose localhost to the internet over a single QUIC connection — on infrastructure you fully own. Built for sharing dev servers, testing webhooks, and demoing work in progress.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://go.dev" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/099469f6af215f4e678c3efd1d47720bf3acd46fbf35298ac5ef436c83fc3bf7/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f676f2d312e32322b2d3030414444383f6c6f676f3d676f" alt="Go Version"&gt;&lt;/a&gt;
&lt;a href="https://github.com/venkatkrishna07/rift/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/b8cadaa967891081f8f165695470689986c028821dd8a040132f6e661795dc0d/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d626c7565" alt="License"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;  localhost:3000  ──── QUIC ────▶  https://myapp.tunnel.example.com
  localhost:5432  ──── QUIC ────▶  tunnel.example.com:10247
  localhost:9090  ──── QUIC ────▶  mcp.example.com (MCP server)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;rift client --server tunnel.example.com --expose 3000:http:myapp
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; → tunnel ready  https://myapp.tunnel.example.com&lt;/span&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;That's it. Your local dev server is now reachable on the internet, over HTTPS, through a server you run.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Where rift fits&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Self-hosted tunnels already exist — &lt;a href="https://github.com/fatedier/frp" rel="noopener noreferrer"&gt;frp&lt;/a&gt;, &lt;a href="https://github.com/ekzhang/bore" rel="noopener noreferrer"&gt;bore&lt;/a&gt;, &lt;a href="https://github.com/jpillora/chisel" rel="noopener noreferrer"&gt;chisel&lt;/a&gt;. They all ride on TCP. rift is the same idea, but built on QUIC, which gives you three things you can't get over TCP:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No head-of-line blocking between tunnels.&lt;/strong&gt; On TCP, a lost packet on one multiplexed stream stalls every other stream on the same connection until it's…&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/venkatkrishna07/rift" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>go</category>
      <category>networking</category>
      <category>selfhosted</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Caddy-mcp: tunnel private MCP servers through Caddy over QUIC</title>
      <dc:creator>Venkatakrishna S</dc:creator>
      <pubDate>Sun, 03 May 2026 11:20:27 +0000</pubDate>
      <link>https://dev.to/venkatakrishna_s/caddy-mcp-tunnel-private-mcp-servers-through-caddy-over-quic-4iag</link>
      <guid>https://dev.to/venkatakrishna_s/caddy-mcp-tunnel-private-mcp-servers-through-caddy-over-quic-4iag</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/venkatkrishna07/caddy-mcp" rel="noopener noreferrer"&gt;caddy-mcp&lt;/a&gt; is a Caddy plugin for exposing MCP servers that live on private networks. The private box dials out to Caddy over QUIC, Caddy serves it as a normal HTTPS endpoint. No inbound ports, no third party in the request path.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;          Public Internet
                |
                v
     +--------------------+
     |   Caddy :443       |   TLS, routing, middleware
     |   reverse_proxy    |
     +--------+-----------+
              |
              v
     +--------------------+
     |  caddy-mcp plugin  |   QUIC listener :4443
     |  tunnel registry   |   token store
     |  policy engine     |   MCP-aware ACLs
     |  audit logger      |   structured logging
     +--------+-----------+
              |
              | QUIC connection (TLS 1.3, multiplexed streams)
              v
     +--------------------+
     |   rift client      |   runs on private network
     |   --protocol mcp   |   dials out — no inbound ports
     +--------+-----------+
              |
              v
     +--------------------+
     |   MCP server       |   tools, resources, prompts
     |   localhost:9090   |
     +--------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The dial-out side runs &lt;a href="https://github.com/venkatkrishna07/rift" rel="noopener noreferrer"&gt;rift&lt;/a&gt; with &lt;code&gt;--protocol mcp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two modes per tunnel:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transparent&lt;/strong&gt; — Caddy forwards bytes untouched. The MCP server handles its own session and auth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Aware&lt;/strong&gt; — Caddy parses the JSON-RPC and enforces policy before anything reaches the upstream:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reverse_proxy mcp-tunnel {
    transport mcp {
        tunnel code-server
        mode aware
        allow_tools read_file list_files search
        deny_tools execute_command shell_exec
        allow_resources "file:///repo/*"
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Denied calls get a JSON-RPC error back. There's per-user ACLs via a policy file (effective permissions are the intersection of tunnel and user ACLs), and a structured audit log of every call — token, user, tool, resource, decision, latency.&lt;/p&gt;

&lt;p&gt;Status: beta, single Caddy instance, no HA. Works for personal and small-team setups.&lt;/p&gt;

&lt;p&gt;Most of the details are in the repo: &lt;a href="https://github.com/venkatkrishna07/caddy-mcp" rel="noopener noreferrer"&gt;github.com/venkatkrishna07/caddy-mcp&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>go</category>
      <category>caddy</category>
      <category>quic</category>
    </item>
  </channel>
</rss>
