Building ServerMe: The Infrastructure Behind an Open-Source Tunneling Platform

ServerMe started with a simple question: what would it take to build a production-grade ngrok alternative from scratch? Not a toy that forwards HTTP requests, but the full thing - TCP tunneling, TLS passthrough, real-time request inspection, custom domains, team collaboration, and a Railway-style app deployment engine. All open source.

This is the story of how it went from an empty directory to a live platform at serverme.site, and the technical decisions that made it work.

The Architecture

ServerMe is a monorepo with four main components:

Client (Go CLI) Server (Go) | | |── TLS + smux session ──────────────>| |── Auth (JWT / API key) ────────────>| |<── Auth OK ─────────────────────────| |── Request Tunnel (HTTP/TCP/TLS) ──>| |<── Tunnel Created (public URL) ─────| | | | ... external request hits URL ... | |<── Proxy Request ───────────────────| |── Forward to localhost:PORT ──> | |<── Response ────────────────── | |── Response back ───────────────────>|

Why Go

The server and CLI are written in Go. Not because it's trendy, but because the problem demands it. A tunneling server is fundamentally about managing thousands of concurrent connections with minimal overhead. Go's goroutine model maps perfectly to this - each tunnel connection is a goroutine, each proxied request is a goroutine, and the runtime handles scheduling.

The key library is smux - a TCP stream multiplexer. Instead of opening a new TCP connection for every proxied request, the client and server share a single TLS connection and multiplex streams over it. This means:

// Server side: accept a new stream for each proxy request
stream, _ := session.AcceptStream()
go handleProxy(stream, targetConn)

The entire server binary compiles to ~15MB and runs comfortably on a $5/month VPS serving multiple users with sub-millisecond proxy overhead.

The Reconnect Problem

The hardest bug I hit wasn't in the protocol - it was keeping the CLI connected through real-world conditions. Laptop sleep, WiFi switching, coffee shop networks. The initial implementation would exit when the connection dropped.

The fix was a RunWithReconnect loop with a userShutdown flag. The only thing that causes a permanent exit is Ctrl+C. Everything else - network errors, server restarts, TLS failures - triggers a reconnect with exponential backoff:

func (c *Client) RunWithReconnect() {
    for {
        err := c.connect()
        if c.userShutdown {
            return // Only Ctrl+C exits
        }
        c.waitForNetwork()
        backoff()
    }
}

A separate waitForNetwork function polls DNS resolution before attempting reconnect. No point hammering the server when there's no internet.

Request Inspection

Every HTTP request flowing through a tunnel gets captured - method, URL, headers, body (up to 10KB), response status, and timing. This is stored in a ring buffer per tunnel (500 requests) and streamed live via WebSocket to the dashboard.

The inspection middleware sits in the HTTP proxy path. It wraps the response writer to capture the status code, then stores the full request/response pair:

// Capture without blocking the proxy
captured := &CapturedRequest{
    Method:  r.Method,
    URL:     r.URL.String(),
    Headers: r.Header,
    Status:  wrapper.statusCode,
    Latency: time.Since(start),
}
go s.inspector.Store(tunnelID, captured)

The dashboard's traffic inspector is one of the features I use most during development. Being able to see every request hitting your tunnel in real time, with timing and headers, is genuinely useful for debugging webhooks and API integrations.

Custom Domains and TLS

Custom domains work through CNAME verification. A user adds their domain, we give them a CNAME target, they add the DNS record, and we verify it via net.LookupCNAME. Once verified, Caddy handles TLS certificate provisioning automatically via Let's Encrypt with on-demand TLS.

The Caddy configuration is minimal:

https:// {
    tls {
        on_demand
    }
    reverse_proxy localhost:9080
}

This means any subdomain of serverme.site - or any verified custom domain - gets a valid TLS certificate automatically on first request. No certificate management code needed.

The Deploy Engine

The most ambitious feature is ServerMe Deploy - a Railway-style hosting engine built into the platform. Users connect their GitHub account, import a repo, and ServerMe builds and runs it as a Docker container.

The deployment pipeline:

  1. Clone the repo (using GitHub App installation tokens for private repos)
  2. Detect the framework (checks for Dockerfile first, then package.json, requirements.txt, go.mod, etc.)
  3. Generate a Dockerfile if the repo doesn't have one
  4. Build the Docker image with resource limits on the build itself
  5. Allocate a random host port
  6. Start the container with memory/CPU limits and a persistent data volume
  7. Health check and register the route

The framework auto-detection was important. Most users don't want to write Dockerfiles. If we see a next.config.js, we generate a Node.js Dockerfile. If we see requirements.txt, Python. But if the repo already has a Dockerfile, we always use it - this took a few iterations to get right.

// Always prefer existing Dockerfile
if fileExists(buildCtx + "/Dockerfile") {
    framework = "docker"
} else if framework == "" {
    framework = e.detectFramework(buildCtx)
}

GitHub App Integration

Private repo access uses a GitHub App with installation tokens. When a user connects their GitHub account, we store the installation ID. On deploy, we generate a short-lived installation token and inject it into the clone URL:

// Installation tokens auto-refresh and work for private repos
token, _ := github.GetInstallationToken(installationID)
cloneURL := fmt.Sprintf(
    "https://x-access-token:%s@github.com/%s.git",
    token, repoFullName,
)

This is more reliable than user OAuth tokens, which expire in hours.

Container Isolation

Each deployed project runs in its own Docker container with enforced limits:

The persistent volume is the key detail. It means a SQLite database or uploaded files survive redeployments. The deploy engine mounts a host directory into the container, so even when the container is replaced, the data stays.

The Database

PostgreSQL with raw SQL queries via pgx. No ORM. The schema covers users, API keys, projects, deploy logs, domains, teams, subdomains, and captured requests. Migrations are managed with goose.

I considered using an ORM early on, but for a system like this - where the queries are well-defined and performance matters - raw SQL is clearer and faster. The entire database layer is ~600 lines of Go.

The Dashboard

The web dashboard is Next.js with Tailwind and shadcn/ui. It covers:

Authentication supports Google OAuth, email/password, and API keys. The CLI has its own login flow that opens a browser, receives the token via a local HTTP callback, and stores it in ~/.serverme/config.yml.

Production Infrastructure

The entire platform runs on a single VPS:

Five systemd services, one box. It handles the current load comfortably. When it's time to scale, the path is clear: move PostgreSQL to a managed service, add worker nodes for the deploy engine, and put a load balancer in front.

The deploy engine is already designed with this in mind. Container names are scoped by project ID, ports are dynamically allocated, and the routing layer is database-driven. Adding a second server is mostly a matter of SSH-ing Docker commands to a remote host.

What I'd Do Differently

A few things I'd change if starting over:

The Numbers

Some stats on the codebase:

Open Source

ServerMe is MIT licensed and available on GitHub. You can self-host it with a single install script, or use the hosted version at serverme.site. The CLI is available via npm (npm i -g serverme) and Homebrew.

Building infrastructure software in the open forces you to think about clean interfaces, good defaults, and actual documentation. It also means every decision is visible - which keeps you honest about code quality.

The best way to learn how something works is to build it yourself. The best way to build it well is to ship it to real users.
← Back to writing