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:
- Tunnel Server - Go binary that accepts client connections, manages tunnels, and proxies traffic
- CLI Client - Go binary that connects to the server and forwards local ports
- Dashboard - Next.js web app for managing tunnels, domains, projects, and teams
- Deploy Engine - Docker-based app hosting built into the server
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:
- One TLS handshake per client, not per request
- Connection reuse across hundreds of concurrent requests
- Keepalive and auto-reconnect happen at the session level
// 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:
- Clone the repo (using GitHub App installation tokens for private repos)
- Detect the framework (checks for Dockerfile first, then package.json, requirements.txt, go.mod, etc.)
- Generate a Dockerfile if the repo doesn't have one
- Build the Docker image with resource limits on the build itself
- Allocate a random host port
- Start the container with memory/CPU limits and a persistent data volume
- 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:
- 512MB memory limit
- 0.5 CPU cores
- Restart policy:
unless-stopped - Persistent data volume at
/app/data
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:
- Active tunnels and deployed projects in a unified view
- Live traffic inspector with WebSocket streaming
- Project management - import from GitHub, deploy, redeploy, environment variables
- Domain management with CNAME verification
- Team collaboration with invite links
- Admin panel with platform-wide analytics
- Billing via crypto payments (InventPay integration)
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:
- Caddy - Reverse proxy, TLS termination, wildcard certificates
- ServerMe Server - Tunnel server + REST API + deploy engine
- Next.js - Dashboard frontend (standalone build)
- PostgreSQL - Primary database
- Docker - Container runtime for deployed projects
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:
- Host the database externally from day one. Having PostgreSQL on the same VPS as everything else is a single point of failure. Railway or Neon would have been the right call.
- Build the project import as a single API call. The original two-step flow (create project, then set repo URL) caused subtle bugs when the second call failed silently. Always make critical operations atomic.
- Add structured logging from the start. Debugging production issues without proper log correlation is painful. I use zerolog now, but retrofitting it took time.
The Numbers
Some stats on the codebase:
- Server - ~4,000 lines of Go across 25 files
- CLI - ~1,500 lines of Go
- Dashboard - ~5,000 lines of TypeScript/React
- Database migrations - 9 migration files
- Server binary size - ~15MB (statically linked)
- Build time - ~8 seconds for the server
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