A TCP proxy is the “hello world” of network programming that actually teaches you something. It’s small enough to hold in your head, but it forces you to confront the real problems: bidirectional flow, half-close, goroutine leaks, backpressure, and graceful shutdown. In this post I’ll build a Go TCP proxy in about 80 lines, run it against a local echo server, and show you exactly what happens.
The minimum that works
The core of a TCP proxy is almost embarrassingly simple:
net.Listenon a local address- Accept connections in a loop
- For each connection,
net.Dialthe upstream - Copy data in both directions with
io.Copy - Close both sides when done
That’s it. Here’s the minimum viable version:
func handle(client net.Conn) {
defer client.Close()
upstream, err := net.Dial("tcp", "127.0.0.1:8000")
if err != nil {
return
}
defer upstream.Close()
go io.Copy(upstream, client) // client -> upstream
io.Copy(client, upstream) // upstream -> client
}
One goroutine per direction, one io.Copy in the foreground. When the upstream->client copy finishes, handle returns, both connections close, and everything cleans up.
But this minimum has real problems. Let’s fix them one by one.
Half-close: the detail everyone gets wrong
TCP connections are full-duplex: each side can close its write direction independently. When the client sends EOF (via CloseWrite), the proxy should propagate that to the upstream — but the upstream might still have data to send back. If you just Close() both connections, you truncate the response.
The fix is CloseWrite() on the upstream when the client->upstream copy finishes, and CloseWrite() on the client when the upstream->client copy finishes:
go func() {
io.Copy(upstream, client)
if tc, ok := upstream.(*net.TCPConn); ok {
tc.CloseWrite() // propagate client EOF to upstream
}
}()
go func() {
io.Copy(client, upstream)
if tc, ok := client.(*net.TCPConn); ok {
tc.CloseWrite() // propagate upstream EOF to client
}
}()
This is half-close, and it’s the single most important concept in proxy programming. Without it, you’ll hang connections or truncate responses in ways that are incredibly hard to debug.
Goroutine leaks: the silent killer
Every go statement is a promise to clean up. The minimum version has a subtle leak: if the upstream->client copy finishes first, handle returns and closes both connections — but the client->upstream goroutine is still blocked in io.Copy. On a real server, this leaks goroutines and file descriptors until you run out.
The fix: wait for both copies to finish before returning. Use a sync.WaitGroup:
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); io.Copy(upstream, client); /* half-close */ }()
go func() { defer wg.Done(); io.Copy(client, upstream); /* half-close */ }()
wg.Wait()
Now handle only returns when both directions are truly done. No leaked goroutines.
Timeouts and deadlines
A proxy without timeouts is a proxy that hangs forever. You need:
- Dial timeout: don’t block forever connecting to upstream
- Idle timeout: kill connections that go quiet
- Overall timeout: cap how long a connection can live
dialer := &net.Dialer{Timeout: 5 * time.Second}
upstream, err := dialer.DialContext(ctx, "tcp", upstreamAddr)
For idle timeouts, you can set read deadlines and reset them on activity, or use a simpler approach: a context with timeout that cancels the connection when it fires:
select {
case <-done:
case <-time.After(30 * time.Second):
// force close both connections
}
Graceful shutdown
When your proxy gets SIGTERM, you want to:
- Stop accepting new connections
- Let in-flight connections finish
- Exit cleanly
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
ln.Close() // stop accepting
// wait for active handlers via WaitGroup
}()
Closing the listener causes Accept() to return an error, which breaks the accept loop. A sync.WaitGroup tracks active handlers so you can wait for them to drain.
Counting and limiting connections
You need to know how many connections are active, and you probably want to cap them:
var activeConn, totalConn atomic.Int64
var sem chan struct{} // optional: max concurrent connections
func handle(ctx context.Context, client net.Conn) {
activeConn.Add(1)
totalConn.Add(1)
defer activeConn.Add(-1)
defer client.Close()
// ... proxy logic ...
}
If you set a semaphore (buffered channel), you can limit concurrent connections and reject new ones when full — a basic form of backpressure.
The complete 80-line proxy
Here’s the full version with all the fixes:
package main
import (
"context"
"flag"
"fmt"
"io"
"net"
"os"
"os/signal"
"sync"
"sync/atomic"
"syscall"
"time"
)
var (
listenAddr = flag.String("listen", "127.0.0.1:9000", "listen address")
upstreamAddr = flag.String("upstream", "127.0.0.1:8000", "upstream address")
idleTimeout = flag.Duration("idle", 30*time.Second, "idle timeout")
maxConns = flag.Int("max-conns", 0, "max concurrent connections (0=unlimited)")
)
var (
activeConn atomic.Int64
totalConn atomic.Int64
sem chan struct{}
)
func main() {
flag.Parse()
if *maxConns > 0 {
sem = make(chan struct{}, *maxConns)
}
ln, err := net.Listen("tcp", *listenAddr)
if err != nil {
fmt.Fprintf(os.Stderr, "listen: %v\n", err)
os.Exit(1)
}
fmt.Printf("TCP proxy listening on %s -> %s\n", *listenAddr, *upstreamAddr)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
var wg sync.WaitGroup
go func() {
<-sigCh
fmt.Println("\nReceived signal, shutting down gracefully...")
cancel()
ln.Close()
}()
for {
conn, err := ln.Accept()
if err != nil {
select {
case <-ctx.Done():
goto wait
default:
fmt.Fprintf(os.Stderr, "accept: %v\n", err)
continue
}
}
if sem != nil {
select {
case sem <- struct{}{}:
case <-ctx.Done():
conn.Close()
goto wait
}
}
wg.Add(1)
go func(c net.Conn) {
defer wg.Done()
defer func() {
if sem != nil {
<-sem
}
}()
handle(ctx, c)
}(conn)
}
wait:
wg.Wait()
fmt.Printf("Total connections: %d\n", totalConn.Load())
fmt.Println("Shutdown complete.")
}
func handle(ctx context.Context, client net.Conn) {
activeConn.Add(1)
totalConn.Add(1)
defer activeConn.Add(-1)
defer client.Close()
fmt.Printf("[conn] active=%d total=%d remote=%s\n",
activeConn.Load(), totalConn.Load(), client.RemoteAddr())
dialer := &net.Dialer{Timeout: 5 * time.Second}
upstream, err := dialer.DialContext(ctx, "tcp", *upstreamAddr)
if err != nil {
fmt.Fprintf(os.Stderr, "dial upstream: %v\n", err)
return
}
defer upstream.Close()
var copyWg sync.WaitGroup
copyWg.Add(2)
go func() {
defer copyWg.Done()
io.Copy(upstream, client)
if tc, ok := upstream.(*net.TCPConn); ok {
tc.CloseWrite()
}
}()
go func() {
defer copyWg.Done()
io.Copy(client, upstream)
if tc, ok := client.(*net.TCPConn); ok {
tc.CloseWrite()
}
}()
done := make(chan struct{})
go func() {
copyWg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(*idleTimeout):
case <-ctx.Done():
}
}
Running it against an echo server
Here’s the test setup: a simple echo server on port 8000, the proxy on port 9000, and a client connecting through the proxy.
Echo server (echo.go):
func main() {
ln, _ := net.Listen("tcp", "127.0.0.1:8000")
fmt.Println("Echo server on 127.0.0.1:8000")
for {
c, _ := ln.Accept()
go func(c net.Conn) {
defer c.Close()
sc := bufio.NewScanner(c)
for sc.Scan() {
fmt.Fprintf(c, "ECHO: %s\n", sc.Text())
}
}(c)
}
}
Running the proxy and sending a test message:
$ go build -race -o proxy_bin main.go && go build -o echo_bin echo.go
$ ./echo_bin &
Echo server on 127.0.0.1:8000
$ ./proxy_bin -listen 127.0.0.1:9000 -upstream 127.0.0.1:8000 &
TCP proxy listening on 127.0.0.1:9000 -> 127.0.0.1:8000
$ go run test_client.go
Sent: "hello via proxy"
Received: "ECHO: hello via proxy\n"
Sent: "second connection"
Received: "ECHO: second connection\n"
# the proxy logs each accepted connection as it arrives
[conn] active=1 total=1 remote=127.0.0.1:53802
[conn] active=2 total=2 remote=127.0.0.1:53816
$ kill -TERM <proxy pid>
Received signal, shutting down gracefully...
Total connections: 2
Shutdown complete.
The -race detector reports no data races. The connection counter shows 2 total connections. Graceful shutdown prints the final count and exits cleanly.
What this proxy is useful for
- Debugging: put a proxy between your client and a remote service to log or inspect traffic
- Port mapping: forward a local port to a remote service (the poor man’s SSH tunnel)
- TLS termination: wrap the listener with
tls.Listenand you have a TLS-terminating proxy - Protocol inspection: add a
io.TeeReaderto log bytes as they pass through - Rate limiting: add a token bucket on the copy goroutines
What this proxy does NOT do (use a real tool)
This is a learning exercise, not production infrastructure. It lacks:
- UDP support: TCP only. UDP is a completely different model.
- Load balancing: one upstream address, no health checking.
- Health checks: if upstream is down, connections fail immediately.
- Connection pooling: every client connection dials a new upstream connection.
- Metrics: no Prometheus, no statsd, no pprof.
- Hot reload: changing config requires a restart.
For production use, look at Envoy, HAProxy, nginx stream module, or Go’s own httputil.ReverseProxy for HTTP-specific workloads.
The mental model
A TCP proxy is a demultiplexer with direction. You accept N connections, dial N upstreams, and shuttle bytes between them. The hard parts aren’t the mechanics — they’re the lifecycle questions: Who closes what? When do goroutines stop? What happens when one side vanishes? If you can answer those for this 80-line proxy, you can reason about any proxy, from a sidecar to a service mesh.
The next time you encounter a network tool, ask: where are its goroutines, where are its io.Copys, and who calls CloseWrite? That’s the whole model.
Related: NAT traversal explained · Go for network tools