MuxBridge is a self-hosted HTTPS tunnel inspired by Cloudflare Tunnel. It lets you securely expose HTTP services running behind NAT or a firewall to the public internet — no inbound firewall rules or port forwarding required.
Browser ──HTTPS──▶ Edge Server (public) ──gRPC tunnel──▶ Client (private network) ──▶ Local HTTP app
You run an edge server on a public host. Your client — sitting behind NAT, a corporate firewall, or any private network — connects outbound to the edge over a persistent gRPC stream. The edge then forwards incoming HTTP requests through that stream to the client, which answers them using its local HTTP handler. Responses travel back the same way.
No VPN. No open ports on the client machine. Just an outbound gRPC connection.
- Token-based authentication — each client authenticates with a secret token; the edge maps tokens to usernames
- Automatic subdomain routing — a client registered as
demois published atdemo.<public-domain> - WebSocket support — upgraded connections are proxied as bidirectional byte streams
- Automatic TLS — edge uses CertMagic for automatic ACME certificate provisioning, or you can supply your own cert/key
- Multiplexed requests — multiple in-flight HTTP requests share a single gRPC stream, correlated by request ID
- Reconnect on disconnect — clients automatically reconnect with configurable backoff
| Cloudflare Tunnel | MuxBridge | |
|---|---|---|
| Control plane | Cloudflare's network | Your own edge server |
| Protocol | QUIC / HTTP/2 | gRPC over HTTP/2 |
| TLS | Cloudflare-managed | CertMagic (ACME) or static cert |
| Auth | Cloudflare Access | Token → username mapping |
| WebSocket | Yes | Yes |
| Self-hostable | No | Yes |
| Client deployment | Separate cloudflared daemon |
Embeddable Go library — add tunnel.NewClient(...) directly to your app |
Cloudflare Tunnel requires you to install and run a separate cloudflared process on every network where you want to expose a service. MuxBridge ships a Go library so you can embed the tunnel client directly into your own application — no sidecar, no external process, no extra deployment step. Your app dials the edge and serves traffic through its own http.Handler.
Build the binaries with:
make -f makefile allThis produces:
bin/edgebin/demo-clientbin/perf-client
You can also build directly with Go:
go build -o bin/edge ./cmd/edge
go build -o bin/demo-client ./cmd/demo-client
go build -o bin/perf-client ./cmd/perf-clientgo test ./...
go test -race ./...
go test ./... -coverprofile=/tmp/muxbridge.cover.out
go tool cover -func=/tmp/muxbridge.cover.outThe current suite uses real listeners and gRPC streams and keeps total statement coverage above 90%.
- The edge control endpoint is always
edge.<public-domain>. - Each client authenticates with a token.
- The edge maps tokens to usernames with
token=username. - A connected client is published at
<username>.<public-domain>. - Example:
demo-token=demopublishes the client atdemo.example.com. - Username
edgeis reserved and cannot be used. - Usernames must be a single lowercase DNS label containing
a-z,0-9, and interior-. - Request forwarding preserves method, headers, body, query string, and the escaped request path via
raw_path. - The forwarded request scheme comes from the edge connection itself: HTTPS requests arrive as
https, plain HTTP requests arrive ashttp, and client-suppliedX-Forwarded-Protois ignored. - WebSocket upgrade requests are proxied as upgraded byte streams after the HTTP handshake.
Register.tunnel_id is still present in the wire format for compatibility, but routing no longer depends on it.
The edge server listens on:
:80for HTTP redirect and ACME HTTP-01 challenges:443for HTTPS and gRPC
Requests are routed like this:
<public-domain>->MuxBridgh active with uptime <duration>edge.<public-domain>with gRPC over HTTP/2 -> tunnel control planeedge.<public-domain>without gRPC ->404<username>.<public-domain>-> proxied through the authenticated client session- anything else ->
404
If the same user connects again, the newest connection replaces the old one.
By default, the edge server uses CertMagic and manages certificates for:
<public-domain>edge.<public-domain>- every configured
<username>.<public-domain>
Managed certificates use ACME HTTP-01 challenges on port 80. DNS for each managed hostname must point at the edge server, and both ports 80 and 443 must be reachable. TLS-ALPN challenges are disabled.
If you provide both a certificate file and key file, the edge server uses them instead of CertMagic:
--tls-cert-file--tls-key-file
or:
MUXBRIDGE_TLS_CERT_FILEMUXBRIDGE_TLS_KEY_FILE
Both must be provided together.
--public-domain
--client-credential token=username
--tls-cert-file
--tls-key-file
--debug
--client-credential may be repeated.
MUXBRIDGE_PUBLIC_DOMAIN
MUXBRIDGE_CLIENT_CREDENTIALS
MUXBRIDGH_DATA
MUXBRIDGE_MAX_INFLIGHT_PER_SESSION
MUXBRIDGE_MAX_TOTAL_INFLIGHT
MUXBRIDGE_TLS_CERT_FILE
MUXBRIDGE_TLS_KEY_FILE
MUXBRIDGE_DEBUG
MUXBRIDGE_CLIENT_CREDENTIALS uses comma-separated token=username entries:
demo-token=demo,admin-token=admin
Because entries are comma-separated, tokens supplied via MUXBRIDGE_CLIENT_CREDENTIALS must not contain ,. Tokens that contain commas must be provided via repeated --client-credential flags instead.
MUXBRIDGH_DATA sets the single persistent data directory used by the edge. When the edge manages TLS with CertMagic, certificates, ACME account data, OCSP cache entries, and lock files are all stored here. MUXBRIDGE_DATA is also accepted as a compatibility alias.
MUXBRIDGE_MAX_INFLIGHT_PER_SESSION limits how many active proxied requests a single connected client session may hold at once. The default is 128. When the limit is reached, additional public requests for that tunnel are rejected with 503 Service Unavailable instead of continuing to grow memory usage.
MUXBRIDGE_MAX_TOTAL_INFLIGHT limits the total number of active proxied requests across all connected client sessions. The default is 512. When the global cap is reached, the edge rejects additional public requests with 503 Service Unavailable even if an individual client session is still below its per-session limit.
Flag credential entries are appended after environment entries. Credential values are trimmed, usernames are normalized to lowercase before validation, and startup fails on malformed entries, duplicate tokens, duplicate usernames, invalid usernames, usernames with ports such as demo:443, or reserved username edge.
bin/edge \
--public-domain example.com \
--client-credential demo-token=demobin/edge \
--public-domain example.com \
--client-credential demo-token=demo \
--tls-cert-file /etc/ssl/example/fullchain.pem \
--tls-key-file /etc/ssl/example/privkey.pemWhen the edge is started with --debug (or MUXBRIDGE_DEBUG=1), the standard net/http/pprof handlers are exposed on the edge domain under /pprof/:
https://edge.<public-domain>/pprof/ # index of available profiles
https://edge.<public-domain>/pprof/heap
https://edge.<public-domain>/pprof/goroutine
https://edge.<public-domain>/pprof/allocs
https://edge.<public-domain>/pprof/profile # 30 s CPU profile by default
https://edge.<public-domain>/pprof/trace
https://edge.<public-domain>/pprof/cmdline
https://edge.<public-domain>/pprof/symbol
Example:
go tool pprof https://edge.example.com/pprof/heap
curl "https://edge.example.com/pprof/goroutine?debug=2"The endpoints are only mounted when debug mode is enabled and return 404 otherwise.
Security: pprof exposes heap contents, goroutine stacks, and lets callers trigger long-running CPU profiles or execution traces. The edge domain has no built-in authentication, so leaving --debug on in production makes this data world-readable. Restrict access at the network layer (firewall, IP allowlist, reverse proxy with auth) before enabling debug mode on a public deployment.
The demo client serves a small HTTP app locally and connects it to the edge.
Routes served by the demo app:
/-> plain text greeting plus the browser's remote IP/slow-> slow chunked plain-text response/ws-demo-> browser page that exercises a WebSocket through the tunnel/ws-demo/socket-> WebSocket endpoint used by/ws-demo/sse-demo-> browser page that exercises Server-Sent Events through the tunnel/sse-demo/events-> SSE endpoint used by/sse-demo
Defaults:
- TLS enabled
- token:
demo-token - edge address:
edge.<public-domain>:443when--edge-addris not provided - flag and environment string values are trimmed before use
--public-domain
--edge-addr
--token
--debug
MUXBRIDGE_PUBLIC_DOMAIN
MUXBRIDGE_EDGE_ADDR
MUXBRIDGE_CLIENT_TOKEN
MUXBRIDGE_DEBUG
bin/demo-client --public-domain example.com --token demo-tokenWith the matching edge configuration:
demo-token=demo
the demo client becomes available at:
https://demo.example.com/
Additional demo pages are available at:
https://demo.example.com/ws-demo
https://demo.example.com/sse-demo
The perf client serves a purpose-built benchmark app locally through the tunnel and then drives load against the real public hostname. It is meant for end-to-end edge+tunnel+backend measurements rather than synthetic localhost-only benchmarking.
Routes served by the perf app:
/healthz-> readiness probe used before the load phase starts/fast-> small fixed plain-text response/bytes-> fixed-size binary payload response/stream-> chunked streaming response
The load generator keeps a configurable number of workers active for the full test duration. Each worker owns its own HTTP client and keeps traffic on HTTP/1.1 so the test uses real parallel public connections instead of collapsing onto a single HTTP/2 session.
Defaults:
- token:
perf-token - connections:
1000 - duration:
30s - scenario:
mixed - edge address:
edge.<public-domain>:443when--edge-addris not provided
fast-> every request goes to/faststream-> every request goes to/streammixed-> weighted mix of/fast,/bytes, and/stream
The default mixed scenario is intentionally uneven: it spends most requests on /fast, adds a smaller amount of fixed-size /bytes traffic, and keeps a small stream workload in the mix. That gives a more realistic blend of short responses, payload-heavy responses, and chunked responses without making the run entirely CPU-bound or entirely bandwidth-bound.
--public-host
--public-domain
--edge-addr
--token
--connections
--duration
--scenario
--request-timeout
--ready-timeout
--debug
MUXBRIDGE_PUBLIC_HOST
MUXBRIDGE_PUBLIC_DOMAIN
MUXBRIDGE_EDGE_ADDR
MUXBRIDGE_CLIENT_TOKEN
MUXBRIDGE_DEBUG
With the matching edge credential:
perf-token=perf
and DNS pointing perf.example.com at the edge, run:
bin/perf-client \
--public-domain example.com \
--public-host perf.example.com \
--token perf-token \
--connections 1000 \
--duration 30s \
--scenario mixedThe client waits for https://perf.example.com/healthz to return 200 OK, then keeps roughly 1000 HTTP/1.1 public connections active for the configured duration and prints request throughput, response throughput, status counts, and latency percentiles.
The summary includes:
- total requests, successful responses, and request errors
- response status counts
- requests per second and bytes per second
- latency min, average, p50, p95, p99, and max
Example:
performance test summary
host: perf.example.com
scenario: mixed
connections: 1000
duration: planned=30s observed=30.017s
requests: total=48211 success=48211 errors=0
throughput: req/s=1606.20 bytes/s=12483011.44
latency: min=3.411ms avg=18.772ms p50=12ms p95=49ms p99=87ms max=214.118ms
statuses: 200=48211
The edge, demo client, and perf client all support opt-in debug logging via --debug or MUXBRIDGE_DEBUG=1.
Example:
MUXBRIDGE_DEBUG=1 bin/edge \
--public-domain example.com \
--client-credential demo-token=demo
MUXBRIDGE_DEBUG=1 bin/demo-client \
--public-domain example.com \
--token demo-token
MUXBRIDGE_DEBUG=1 bin/perf-client \
--public-domain example.com \
--public-host perf.example.com \
--token perf-token- Point DNS at the edge server for:
edge.example.comdemo.example.com
- Start the edge:
bin/edge \
--public-domain example.com \
--client-credential demo-token=demo- Start the demo client:
bin/demo-client \
--public-domain example.com \
--token demo-token- Open:
https://demo.example.com/
A minimal Alpine-based image is provided. The edge binary is the entrypoint and exposes ports 80 and 443. All persistent edge data, including CertMagic certificates, is stored under MUXBRIDGH_DATA, which defaults to /var/lib/muxbridge in the container image.
Example docker-compose.yml:
services:
muxbridge:
image: ghcr.io/define42/muxbridge:latest
restart: unless-stopped
read_only: true
ports:
- "80:80"
- "443:443"
environment:
MUXBRIDGE_PUBLIC_DOMAIN: example.com
MUXBRIDGE_CLIENT_CREDENTIALS: demo-token=demo
MUXBRIDGH_DATA: /var/lib/muxbridge
volumes:
- ./muxbridge-data:/var/lib/muxbridgeWith read_only: true, the container root filesystem stays read-only and only the mounted data directory remains writable.
Then start the edge with:
docker compose up -dTo expose your own http.Handler instead of the demo app, import the tunnel package:
import "github.com/define42/muxbridge/tunnel"
client, err := tunnel.New(tunnel.Config{
EdgeAddr: "edge.example.com:443",
Token: "my-secret-token",
Handler: myHandler,
})
if err != nil {
log.Fatal(err)
}
if err := client.Run(ctx); err != nil {
log.Fatal(err)
}The client automatically reconnects on disconnect with a configurable backoff (default 2 s).
A complete Go program that serves a Hello World page through the tunnel:
package main
import (
"context"
"fmt"
"log"
"net/http"
"github.com/define42/muxbridge/tunnel"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, World!")
})
client, err := tunnel.New(tunnel.Config{
EdgeAddr: "edge.example.com:443",
Token: "my-secret-token",
Handler: mux,
})
if err != nil {
log.Fatal(err)
}
if err := client.Run(context.Background()); err != nil {
log.Fatal(err)
}
}With the matching edge credential my-secret-token=alice, visiting https://alice.example.com/ returns Hello, World!. No separate process or sidecar required — the tunnel is part of your binary.