← Back to Blog

Building a Self-Hosted Tunnel Service as an npm Package in TypeScript

View on npm →
Share

Why Build Your Own Tunnel

If you have ever tried to show a localhost project to someone, you know the pain. Your app runs on port 3000, but nobody outside your network can reach it. Tools like ngrok and localtunnel solve this, but they come with trade-offs. Ngrok's free tier rate-limits you and randomizes URLs. Localtunnel's public server is unreliable and occasionally down. Both route your traffic through infrastructure you do not control.

I already had an EC2 instance, a domain (gagandeep023.com), and a reason: I wanted to expose my portfolio's backend during development without deploying every change. So I built @gagandeep023/expose-tunnel, a self-hosted tunnel service published as an npm package. It runs a relay server on your own VPS, assigns you a subdomain like myapp.tunnel.gagandeep023.com, and forwards all traffic to your local machine over a persistent WebSocket connection.

Architecture Overview

The system has three layers: a relay server that runs on a public VPS, a tunnel client that runs on your local machine, and the WebSocket protocol that connects them. Every HTTP request from the internet gets serialized into JSON, piped through the WebSocket to your machine, proxied to your local server, and the response flows back the same path.

End-to-End Request Flow
Internet User
     |
     |  HTTPS: abc123.tunnel.gagandeep023.com/api/users
     v
+-----------+
|  Nginx    |  SSL termination (wildcard cert)
|  :443     |  proxy_pass to localhost:4040
+-----------+
     |
     v
+-----------+
|  Relay    |  1. Extract subdomain from Host header
|  Server   |  2. Look up tunnel connection by subdomain
|  :4040    |  3. Serialize HTTP request to JSON
|           |  4. Send over WebSocket to client
+-----------+
     |
     |  WebSocket (persistent, bidirectional)
     v
+-----------+
|  Tunnel   |  5. Receive JSON request
|  Client   |  6. Reconstruct HTTP request
|  (local)  |  7. Forward to localhost:PORT
|           |  8. Serialize response, send back
+-----------+
     |
     v
+-----------+
|  Your     |  9. Process request normally
|  Local    |  10. Return response
|  Server   |
+-----------+

The key insight is that the relay server never needs to understand what your application does. It is a dumb pipe. It takes bytes in, ships them through a WebSocket, and writes the response bytes back. This means it works with any HTTP-based application: REST APIs, GraphQL, static file servers, SSR frameworks, anything.

The WebSocket Protocol

The relay server and tunnel client communicate using a JSON-based message protocol over WebSocket. There are exactly six message types, and each one has a specific role in the tunnel lifecycle.

typescript
export type WSMessage = | { type: 'tunnel-assigned'; subdomain: string; url: string } | { type: 'tunnel-request'; request: TunnelRequest } | { type: 'tunnel-response'; response: TunnelResponse } | { type: 'tunnel-error'; message: string } | { type: 'ping' } | { type: 'pong' };

When a client connects, the server immediately sends tunnel-assigned with the subdomain and public URL. From that point, every incoming HTTP request becomes a tunnel-request message, and the client responds with tunnel-response. The ping/pong pair handles keepalive detection. tunnel-error is for server-side error reporting.

Request/Response Serialization

HTTP requests and responses need to survive a round trip through JSON. The challenge is that HTTP bodies can be binary (images, protobuf, gzip). JSON cannot represent raw bytes. The solution: base64 encode the body.

typescript
export interface TunnelRequest { id: string; // UUID for matching responses method: string; // GET, POST, PUT, etc. path: string; // /api/users?page=2 headers: Record<string, string>; // Flattened headers body: string | null; // Base64-encoded body } export interface TunnelResponse { id: string; // Same UUID from the request status: number; // HTTP status code headers: Record<string, string>; // Response headers body: string | null; // Base64-encoded body }

Every request gets a UUID. The relay server stores a pending request map keyed by that UUID. When the client sends back a tunnel-response with the same ID, the relay matches it to the waiting HTTP response object and writes the result. This decouples request and response handling, which matters because WebSocket messages can arrive in any order if multiple requests are in flight simultaneously.

The Relay Server: Routing by Subdomain

The relay server is the central component. It runs on a public VPS (EC2 in my case), listens for both HTTP requests and WebSocket upgrade requests on the same port, and maintains a map of active tunnels.

typescript
export class RelayServer { private tunnels: Map<string, TunnelConnection> = new Map(); private pendingRequests: Map<string, PendingRequest> = new Map(); private server: http.Server; private wss: WebSocketServer; constructor(config: RelayServerConfig) { this.server = http.createServer((req, res) => { this.handleHttpRequest(req, res); }); this.wss = new WebSocketServer({ noServer: true }); this.server.on('upgrade', (req, socket, head) => { // Only upgrade on /tunnel path // Validate API key from x-api-key header // Then: this.wss.handleUpgrade(req, socket, head, ws => ...) }); } }

The noServer mode for WebSocketServer is critical. It lets us intercept the upgrade request before the WebSocket handshake completes. This is where we validate the API key and reject unauthorized connections with a 401 before they ever become WebSocket connections.

Subdomain Extraction and Lookup

When an HTTP request arrives, the server extracts the subdomain from the Host header. If the host is abc123.tunnel.gagandeep023.com, the subdomain is abc123. The server looks it up in the tunnels map. If found, the request gets serialized and forwarded. If not, the client gets a 404.

typescript
private extractSubdomain(host: string): string | null { const hostname = host.split(':')[0]; // Remove port const domain = this.config.domain; if (hostname === domain) return null; if (hostname.endsWith(`.${domain}`)) { return hostname.slice(0, -(domain.length + 1)); } return null; }

The extraction handles edge cases: bare domain requests (for the health endpoint), port numbers in the Host header (development environments), and invalid hostnames that do not match the configured domain.

Subdomain Assignment

When a tunnel client connects, it can optionally request a specific subdomain via the x-subdomain header. The server honors it if the name is valid (3-63 chars, lowercase alphanumeric plus hyphens) and not already taken. Otherwise, it generates a random 8-character string.

typescript
// Random subdomain: 8 chars from [a-z0-9] export function generateSubdomain(): string { const bytes = crypto.randomBytes(8); let result = ''; for (let i = 0; i < 8; i++) { result += CHARS[bytes[i] % CHARS.length]; } return result; } // Validation: RFC 1123 hostname rules export function isValidSubdomain(subdomain: string): boolean { if (subdomain.length < 3 || subdomain.length > 63) return false; return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(subdomain); }

Using crypto.randomBytes instead of Math.random ensures cryptographically secure subdomain generation. The modulo operation maps each byte to one of 36 characters. With 8 characters, there are 36^8 (about 2.8 trillion) possible subdomains, making collisions negligible.

The Request Pipeline: From HTTP to WebSocket and Back

The full lifecycle of a single request involves seven steps. Understanding each step is important because any bug in serialization, routing, or timeout handling breaks the entire tunnel.

Step 1: Receive and Buffer the HTTP Body

typescript
const chunks: Buffer[] = []; let bodySize = 0; req.on('data', (chunk: Buffer) => { bodySize += chunk.length; if (bodySize > MAX_BODY_SIZE) { // 10MB limit res.writeHead(413); res.end(JSON.stringify({ error: 'Request body too large' })); req.destroy(); return; } chunks.push(chunk); });

The body is streamed as chunks and accumulated into a Buffer array. A 10MB hard limit prevents memory exhaustion from large uploads. The size check happens on every chunk, not just at the end, so the connection is killed early if the limit is exceeded.

Step 2: Serialize and Forward

Once the body is fully read, the request is serialized into a TunnelRequest with a UUID, method, path, headers (flattened from arrays to comma-separated strings), and base64-encoded body. The relay stores the pending request (the original HTTP response object and a 30-second timeout), then sends the serialized request over the WebSocket.

Step 3: Client Proxies to Local Server

typescript
private proxyToLocal(request: TunnelRequest): Promise<TunnelResponse> { return new Promise((resolve, reject) => { const url = `http://${this.options.localHost}:${this.options.port}${request.path}`; const parsedUrl = new URL(url); const headers: Record<string, string> = { ...request.headers }; headers['host'] = `${this.options.localHost}:${this.options.port}`; delete headers['connection']; delete headers['upgrade']; const proxyReq = http.request({ hostname: parsedUrl.hostname, port: parsedUrl.port, path: parsedUrl.pathname + parsedUrl.search, method: request.method, headers, }, (proxyRes) => { // Collect response chunks, base64 encode, resolve }); if (request.body) { proxyReq.write(Buffer.from(request.body, 'base64')); } proxyReq.end(); }); }

The client reconstructs the HTTP request and sends it to the local server. Three header modifications are critical: the Host header must point to the local server (not the tunnel domain), and the connection and upgrade headers are removed because they are hop-by-hop headers that do not apply to the proxied connection. The body is base64-decoded back into a Buffer before writing.

Step 4: Response Matching

When the response arrives back at the relay server, it is matched to the pending request by UUID. The timeout is cleared, headers are copied (minus transfer-encoding, which Node handles), and the body is base64-decoded and written to the original HTTP response. If the 30-second timeout fires before the response arrives, the client gets a 504 Gateway Timeout.

Heartbeat and Connection Health

WebSocket connections can silently die. A client might lose WiFi, their laptop might sleep, or a NAT gateway might drop the connection. Without active health checking, the relay server would keep the tunnel entry in its map but never deliver requests. The heartbeat system prevents this.

typescript
const conn: TunnelConnection = { ws, subdomain, alive: true, heartbeat: setInterval(() => { if (!conn.alive) { logger.info(`Tunnel ${subdomain} failed heartbeat, disconnecting`); clearInterval(conn.heartbeat); ws.terminate(); // Hard kill, not graceful close return; } conn.alive = false; ws.send(JSON.stringify({ type: 'ping' })); }, 30_000), };

Every 30 seconds, the server sets alive to false and sends a ping. If the client responds with pong before the next interval fires, alive is set back to true. If not, the connection is terminated with ws.terminate() (a hard kill, not a graceful close). This gives dead connections a maximum lifetime of 60 seconds: 30 seconds for the ping to be sent, and 30 more for the next check to detect the missing pong.

Reconnection with Exponential Backoff

The tunnel client handles disconnections automatically. When the WebSocket closes unexpectedly (not from an explicit close() call), the client attempts to reconnect up to 5 times with exponential backoff: 1 second, 2 seconds, 4 seconds, 8 seconds, 16 seconds.

typescript
private reconnect(): void { if (this.closed || this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { logger.error('Max reconnect attempts reached. Giving up.'); this.emit('close'); } return; } this.reconnectAttempts++; const delay = RECONNECT_BASE_DELAY * Math.pow(2, this.reconnectAttempts - 1); setTimeout(() => { if (this.closed) return; // Reconnect with stored subdomain to reclaim the same URL const headers = { 'x-api-key': this.options.apiKey }; if (this.subdomain) headers['x-subdomain'] = this.subdomain; this.ws = new WebSocket(wsUrl, { headers }); // ... }, delay); }

A key detail: when reconnecting, the client sends the previously assigned subdomain in the x-subdomain header. If no other client has claimed that subdomain in the meantime, the same public URL is preserved. This means brief network interruptions do not change your tunnel URL. The reconnect counter resets to zero on successful reconnection.

Authentication: API Keys

The relay server is exposed to the internet, so it needs authentication. I went with API key validation. It is simple, stateless, and does not require a database. The server loads a comma-separated list of valid keys from the API_KEYS environment variable. The client sends its key in the x-api-key header during the WebSocket upgrade handshake.

typescript
// Server-side: reject before WebSocket handshake completes this.server.on('upgrade', (req, socket, head) => { const apiKey = req.headers['x-api-key'] as string | undefined; if (!validateApiKey(apiKey, this.config.apiKeys)) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return; } this.wss.handleUpgrade(req, socket, head, (ws) => { this.handleWebSocketConnection(ws, req); }); });

The validation happens on the raw socket before the WebSocket upgrade completes. This is important: if we validated after the upgrade, an attacker could hold open a WebSocket connection and consume server resources. By rejecting at the HTTP level, unauthorized clients never consume a WebSocket slot.

Infrastructure: Wildcard DNS and SSL

The tunnel system requires two pieces of infrastructure that most tutorials never cover: wildcard DNS records and wildcard SSL certificates. Every tunnel gets a unique subdomain, so you cannot create individual DNS records or certificates for each one.

DNS Setup

Two A records are needed. The first, tunnel.gagandeep023.com, points to the EC2 instance IP and handles the base domain (health checks, WebSocket upgrades). The second, *.tunnel.gagandeep023.com, is a wildcard that catches all tunnel subdomains. Both point to the same IP. Without the wildcard record, browsers would get DNS_PROBE_FINISHED_NXDOMAIN for tunnel URLs.

Wildcard SSL with Let's Encrypt

Let's Encrypt issues wildcard certificates, but only via DNS-01 challenge. Unlike the standard HTTP-01 challenge (where certbot places a file on your server), DNS-01 requires you to create a TXT record proving you control the domain. The command:

bash
sudo certbot certonly \ --manual \ --preferred-challenges dns \ -d "tunnel.gagandeep023.com" \ -d "*.tunnel.gagandeep023.com"

Certbot prompts you to add a TXT record at _acme-challenge.tunnel.gagandeep023.com. The gotcha: DNS propagation. Let's Encrypt does not query your authoritative nameserver directly. It queries multiple resolvers, and if any of them have not picked up the new TXT record yet, validation fails. I learned this the hard way after burning through several failed attempts. The fix was simple: wait 2 full minutes after adding the TXT record before telling certbot to continue. Checking with dig +short TXT _acme-challenge.tunnel.gagandeep023.com @8.8.8.8 from multiple resolvers before proceeding also helps.

Nginx Configuration

Nginx sits in front of the relay server handling SSL termination and WebSocket proxying. The critical directives are proxy_set_header Upgrade and Connection for WebSocket support, and long timeouts (86400s) so idle tunnel connections are not killed by Nginx before the application-level heartbeat detects them.

nginx
server { listen 443 ssl; server_name tunnel.gagandeep023.com *.tunnel.gagandeep023.com; ssl_certificate /etc/letsencrypt/live/tunnel.gagandeep023.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/tunnel.gagandeep023.com/privkey.pem; location / { proxy_pass http://127.0.0.1:4040; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 86400s; proxy_send_timeout 86400s; } }

The Build System: Three Entry Points

The package ships three separate entry points, each built by tsup with different configurations. This is because a tunnel client, a CLI tool, and a relay server have very different requirements.

typescript
// tsup.config.ts export default defineConfig([ { entry: ['src/index.ts'], // Library: CJS + ESM + types format: ['cjs', 'esm'], dts: true, }, { entry: ['src/cli.ts'], // CLI: CJS only, with shebang format: ['cjs'], banner: { js: '#!/usr/bin/env node' }, }, { entry: ['src/server/index.ts'], // Server: CJS only format: ['cjs'], outDir: 'dist/server', }, ]);
  • src/index.ts exports the TunnelClient class and exposeTunnel() convenience function for programmatic use. It builds to both CJS and ESM with TypeScript declarations.
  • src/cli.ts is the CLI entry point using Commander.js. It only needs CJS (Node.js execution) and gets a #!/usr/bin/env node shebang banner.
  • src/server/index.ts is the relay server entry point. It reads environment variables and starts the RelayServer. CJS only, output to dist/server/ to match the package.json exports map.

Programmatic API

The package works as both a CLI tool and a Node.js library. The programmatic API is a single function call that returns a tunnel instance with event emitters.

typescript
import { exposeTunnel } from '@gagandeep023/expose-tunnel'; const tunnel = await exposeTunnel({ port: 3000, apiKey: process.env.EXPOSE_TUNNEL_API_KEY, subdomain: 'myapp', // Optional: request specific subdomain server: 'wss://tunnel.gagandeep023.com', }); console.log(`Tunnel URL: ${tunnel.url}`); // https://myapp.tunnel.gagandeep023.com tunnel.on('request', (req) => { console.log(`${req.method} ${req.path}`); }); tunnel.on('error', (err) => { console.error('Tunnel error:', err); }); // Later: close the tunnel await tunnel.close();

The exposeTunnel function creates a TunnelClient, calls connect(), and returns a TunnelInstance. The TunnelClient extends EventEmitter, so you can listen for request, error, and close events. The close() method sends a graceful WebSocket close, clears the heartbeat interval, and sets a flag that prevents reconnection.

Testing Strategy

The package has 33 tests across 4 test files using Vitest. The challenge with testing a tunnel is that you need real HTTP servers, real WebSocket connections, and real port bindings. Mocking would miss the integration bugs that matter most.

Integration Tests

The tunnel-client tests spin up a real relay server and a real local HTTP server on ephemeral ports. Then they create a real TunnelClient, connect it, and make actual HTTP requests through the tunnel to verify end-to-end behavior.

typescript
// tunnel-client.test.ts (simplified) beforeEach(async () => { const port = RELAY_PORT++; relayServer = new RelayServer({ port, apiKeys: ['test-key'], domain: `localhost:${port}`, }); await relayServer.start(); localServer = http.createServer((req, res) => { if (req.url === '/hello') { res.end('Hello from local!'); } }); localServer.listen(LOCAL_PORT); }); it('proxies GET request through tunnel', async () => { const tunnel = await exposeTunnel({ port: LOCAL_PORT, apiKey: 'test-key', server: `ws://localhost:${currentRelayPort}`, }); const res = await fetch( `http://localhost:${currentRelayPort}/hello`, { headers: { host: `${tunnel.subdomain}.localhost:${currentRelayPort}` } } ); expect(await res.text()).toBe('Hello from local!'); await tunnel.close(); });

The trick is setting the Host header manually. Since we are making requests to localhost (not real subdomains), the relay server needs the Host header to contain the subdomain for routing. In production, DNS handles this automatically.

Port Management

A painful lesson: tests that bind to fixed ports fail when run in parallel or in quick succession, because the OS does not release ports instantly. The fix was incrementing the port number for each test. Each beforeEach bumps the port counter, so no two tests ever compete for the same port.

Error Handling at Every Layer

A tunnel has many failure modes. The local server might be down. The WebSocket might drop. The request body might be too large. The response might never come. Each failure needs its own HTTP status code so the person making the request gets a meaningful error.

  • 401 Unauthorized: Invalid or missing API key during WebSocket upgrade
  • 404 Not Found: No tunnel exists for the requested subdomain
  • 413 Payload Too Large: Request body exceeds 10MB limit
  • 502 Bad Gateway: Tunnel client is connected but local server is unreachable
  • 503 Service Unavailable: Relay server is shutting down
  • 504 Gateway Timeout: Tunnel client did not respond within 30 seconds

Deployment to EC2

The relay server runs on a t3.micro EC2 instance in ap-south-1 (Mumbai). The deployment is straightforward: npm install the package, create a start.js that loads environment variables and requires the server module, and manage it with PM2.

javascript
// start.js on EC2 const { readFileSync } = require('fs'); const { join } = require('path'); // Load .env manually (no dotenv dependency) const envPath = join(__dirname, '.env'); readFileSync(envPath, 'utf-8').split('\n').forEach(line => { const [key, ...rest] = line.split('='); if (key && rest.length) { process.env[key.trim()] = rest.join('=').trim(); } }); require('./node_modules/@gagandeep023/expose-tunnel/dist/server/index.js');

PM2 handles auto-restart on crashes, log management, and startup persistence across reboots. The relay server process uses minimal memory (around 30MB) since it is just routing messages between WebSockets and HTTP connections.

Lessons Learned

WebSocket noServer Mode is Essential

Running the WebSocket server in noServer mode (where you manually handle the upgrade event) is the only way to share a single port between HTTP and WebSocket traffic while maintaining control over authentication. The default mode where WebSocketServer binds to a port directly would not let you inspect or reject the upgrade request before the handshake.

DNS Propagation is the Real Bottleneck

The most time-consuming part of the entire project was getting the wildcard SSL certificate. Not because certbot is hard, but because DNS propagation timing is unpredictable. Let's Encrypt queries multiple resolvers, and if even one of them has not seen your TXT record yet, the validation fails. Worse, every failed validation counts toward a rate limit of 5 failures per hour. The lesson: always verify TXT records from multiple resolvers (Google's 8.8.8.8, Cloudflare's 1.1.1.1) before telling certbot to proceed.

Base64 is Good Enough

Base64 encoding adds 33% overhead to body sizes. For a tunnel that primarily handles JSON API requests (which are text-based and typically under 1MB), this is a non-issue. For very large binary payloads, you could switch to a binary WebSocket frame protocol, but the added complexity is not worth it for most use cases. The 10MB body limit also naturally caps the overhead.

Port Conflicts in Tests are Subtle

Even after calling server.close() in afterEach, the OS may take a few hundred milliseconds to release the port (TIME_WAIT state). Running tests in sequence with the same port causes intermittent EADDRINUSE errors. The fix is trivial: increment the port for every test. But the bug is hard to diagnose because it only manifests when running the full test suite, not individual test files.

What is Next

  • WebSocket tunneling: currently only HTTP requests are proxied. Adding WebSocket passthrough would let you expose real-time applications.
  • Dashboard UI: a web interface showing active tunnels, request logs, and bandwidth metrics.
  • Request logging and replay: store requests that pass through the tunnel for debugging.
  • Custom domain support: CNAME a domain directly to a tunnel instead of using the generated subdomain.
  • Automated SSL renewal: integrate a DNS provider API (like Cloudflare) so certbot can auto-renew wildcard certificates.

Try It Out

bash
npm install @gagandeep023/expose-tunnel # Expose port 3000 npx @gagandeep023/expose-tunnel --port 3000 --api-key YOUR_KEY

The package is published on npm as @gagandeep023/expose-tunnel and the source code is on GitHub at Gagandeep023/expose-tunnel. The self-hosting guide in the repository covers every step from DNS setup to SSL certificates to PM2 deployment.

Get more posts like this

I write about system design, backend engineering, and building npm packages from scratch. Follow along on Substack for new posts.

Subscribe on Substack →