Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 082 / 100

Building a tunnel client in Go: best practices explained

How tools like ngrok work under the hood.

Expona Admin9 min read

Tunnel clients let you expose a local service to the internet without opening firewall ports or configuring a static IP. Tools like ngrok have made this workflow routine for developers, but how they actually work remains mysterious to many. Building a tunnel client in Go: best practices explained means understanding four core concepts that make the whole architecture sing: establishing a persistent outbound connection, multiplexing multiple streams over that single pipe, forwarding traffic to local services, and keeping everything alive when networks hiccup.

Why tunnel clients matter in production

In real-world deployments, you often need to expose internal APIs for webhooks, third-party integrations, or partner testing without blasting your infrastructure open to the internet. A tunnel client runs on your machine or server and maintains a secure channel back to a relay server, which then routes inbound requests through that tunnel to your local application. This pattern is common in e-commerce checkout flows that need sandbox payment provider callbacks, SaaS platforms integrating with external CRMs, and trading systems accepting market data feeds.

Outbound connection — The client dials the server

The foundation of any tunnel is an outbound TCP or TLS connection from your machine to a relay server you control or trust. The client never listens on an inbound port; instead, the client dials out to establish the tunnel. This is the key insight that makes tunnels firewall-friendly.

Here's a minimal example:

go
package main

import (
	"crypto/tls"
	"log"
	"net"
	"time"
)

func main() {
	// Dial the relay server with TLS
	conn, err := tls.Dial("tcp", "relay.example.com:8443", &tls.Config{
		InsecureSkipVerify: false, // Use proper certs in production
	})
	if err != nil {
		log.Fatalf("Failed to dial relay: %v", err)
	}
	defer conn.Close()

	// Connection is now open; relay can send us traffic
	log.Println("Tunnel established to relay server")

	// Keep reading from connection
	select {}
}

In production, you'd wrap this in a retry loop with exponential backoff. The relay server now knows exactly where to send any requests destined for your tunnel. When a client on the internet hits the relay asking for your tunnel, the relay pushes that request down this connection, and your tunnel client forwards it to your local service.

Multiplexing — Many streams over one connection

Creating a new TCP connection for every request would be wasteful and slow. Instead, modern tunnel clients multiplex dozens or hundreds of concurrent requests over a single connection using a protocol like yamux or HTTP/2. Think of it as creating virtual channels within the one physical connection.

When the relay receives a new inbound request for your tunnel, it wraps that request in a new stream ID and sends it down the multiplexed connection. Your tunnel client's multiplexer unwraps each stream and forwards it independently to your local service.

Here's how you'd use yamux in a tunnel client:

go
package main

import (
	"crypto/tls"
	"io"
	"log"
	"net"

	"github.com/hashicorp/yamux"
)

func main() {
	// Dial relay and establish multiplexed session
	conn, err := tls.Dial("tcp", "relay.example.com:8443", &tls.Config{})
	if err != nil {
		log.Fatalf("Dial failed: %v", err)
	}
	defer conn.Close()

	// Wrap connection in yamux session
	session, err := yamux.Client(conn, nil)
	if err != nil {
		log.Fatalf("yamux init failed: %v", err)
	}
	defer session.Close()

	log.Println("Tunnel multiplexer ready")

	// Accept incoming streams from relay
	for {
		stream, err := session.Accept()
		if err != nil {
			log.Printf("Accept error: %v", err)
			break
		}

		// Handle each stream in a goroutine
		go handleStream(stream)
	}
}

func handleStream(stream net.Conn) {
	defer stream.Close()

	// Forward to local service on port 8080
	local, err := net.Dial("tcp", "localhost:8080")
	if err != nil {
		log.Printf("Local dial failed: %v", err)
		return
	}
	defer local.Close()

	// Copy bytes bidirectionally
	go io.Copy(local, stream)
	io.Copy(stream, local)
}

Yamux gives you multiplexing almost for free. Each incoming stream from the relay becomes a goroutine that proxies to your local service. If your local service is an HTTP API on port 8080, every stream becomes a separate request with its own bidirectional I/O.

Forward locally — Each stream is proxied to localhost

Once a stream arrives from the relay, forwarding it to your local service is straightforward: dial the local port and copy bytes both directions. The tricky part is handling errors gracefully and ensuring each connection completes cleanly.

In a real e-commerce webhook scenario, the relay receives a callback from a payment provider, routes it through the tunnel as a new stream, and your tunnel client forwards that stream to your local HTTP server listening on localhost:3000. Your server processes the webhook, returns a 200 OK, the tunnel client copies that response back through the stream to the relay, and the relay sends it back to the payment provider.

Here's a more resilient forwarding function:

go
func forwardStream(stream net.Conn, localAddr string, timeout time.Duration) {
	defer stream.Close()

	// Dial local service with timeout
	dialer := net.Dialer{Timeout: timeout}
	local, err := dialer.Dial("tcp", localAddr)
	if err != nil {
		log.Printf("Failed to connect to %s: %v", localAddr, err)
		stream.Write([]byte("HTTP/1.1 503 Service Unavailable\r\n\r\n"))
		return
	}
	defer local.Close()

	// Set read/write deadlines so dead connections don't hang
	local.SetDeadline(time.Now().Add(30 * time.Second))
	stream.SetDeadline(time.Now().Add(30 * time.Second))

	// Copy data bidirectionally
	errChan := make(chan error, 2)
	go func() {
		_, err := io.Copy(local, stream)
		errChan <- err
	}()
	go func() {
		_, err := io.Copy(stream, local)
		errChan <- err
	}()

	// Wait for either direction to close
	<-errChan
}

The key detail: deadlines prevent zombie connections from accumulating. If either side stops talking for 30 seconds, the connection closes and resources are reclaimed.

Reconnect — Backoff and heartbeats keep the tunnel alive

Networks are unreliable. Your WiFi drops, your ISP hiccups, the relay restarts. A production tunnel client must detect these failures and reconnect automatically, using exponential backoff to avoid hammering the relay during outages.

Additionally, many firewalls and load balancers close idle TCP connections after a few minutes. Heartbeats (periodic pings) keep the connection alive and prove the tunnel is still responsive.

Here's a robust reconnect loop:

go
package main

import (
	"crypto/tls"
	"log"
	"math"
	"time"

	"github.com/hashicorp/yamux"
)

func main() {
	minBackoff := 1 * time.Second
	maxBackoff := 2 * time.Minute
	backoff := minBackoff

	for {
		session, err := connectTunnel("relay.example.com:8443")
		if err != nil {
			log.Printf("Tunnel connect failed: %v. Retrying in %v", err, backoff)
			time.Sleep(backoff)
			// Exponential backoff: double until max
			backoff = time.Duration(
				math.Min(float64(backoff*2), float64(maxBackoff)),
			)
			continue
		}

		// Reset backoff on successful connection
		backoff = minBackoff
		log.Println("Tunnel connected, backoff reset")

		// Run session until it fails
		err = runSession(session)
		log.Printf("Session ended: %v", err)
		session.Close()
	}
}

func connectTunnel(addr string) (*yamux.Session, error) {
	conn, err := tls.Dial("tcp", addr, &tls.Config{})
	if err != nil {
		return nil, err
	}

	session, err := yamux.Client(conn, nil)
	if err != nil {
		conn.Close()
		return nil, err
	}

	return session, nil
}

func runSession(session *yamux.Session) error {
	// Heartbeat ticker to keep tunnel alive
	ticker := time.NewTicker(30 * time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-ticker.C:
			// Send heartbeat by opening and closing a stream
			// (or use a custom heartbeat message if relay supports it)
			stream, err := session.OpenStream()
			if err != nil {
				return err
			}
			stream.Close()

		default:
			// Accept incoming streams
			stream, err := session.Accept()
			if err != nil {
				return err
			}
			go forwardStream(stream, "localhost:3000", 30*time.Second)
		}
	}
}

This pattern ensures your tunnel stays connected even during network blips. The outer loop handles total connection loss, while heartbeats prevent premature disconnection from idle-timeout firewalls.

Common mistakes

  • No deadline enforcement: Forgetting to set read/write deadlines on streams causes resource leaks when clients disconnect abruptly. Always set a reasonable timeout like 30 seconds.
  • Ignoring backoff strategy: Retrying immediately on connection failure hammers your relay during outages. Use exponential backoff with a reasonable max (e.g., 2 minutes).
  • Missing heartbeat logic: Idle connections are killed by many firewalls. Send a heartbeat every 20-30 seconds to keep the tunnel warm.
  • Single-threaded accept loop: Accepting streams must be non-blocking and concurrent. Each stream gets its own goroutine to avoid head-of-line blocking.
  • Hardcoding local port: Make the local forwarding address configurable. Different environments need different ports.
  • No graceful shutdown: When the tunnel client receives SIGTERM, close the session cleanly rather than abrupt termination. This prevents partial request loss.

Key takeaways

  • Tunnel clients dial outbound to a relay, eliminating inbound firewall configuration while maintaining security.
  • Multiplexing with yamux or HTTP/2 packs many requests into one connection, reducing latency and resource overhead.
  • Forwarding each stream to localhost is trivial; the hard part is handling timeouts and partial failures gracefully.
  • Exponential backoff and periodic heartbeats keep the tunnel alive across real-world network disruptions and firewall idle-timeouts.
  • Always set deadlines on connections to prevent resource exhaustion and zombie goroutines.
  • In production, run tunnel clients with logging, metrics, and automated restart policies so your team knows when tunnels go down.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles