Tech Expert & Vibe Coder

With 14+ years of experience, I specialize in self-hosting, AI automation, and Vibe Coding – building applications using AI-powered tools like Google Antigravity, Dyad, and Cline. From homelabs to enterprise solutions.

Implementing nginx stream module for transparent tcp load balancing across tailscale subnet routers

Why I Built This

I run several services across a distributed Tailscale mesh—some on my Proxmox cluster, others on a Synology NAS, and a few lightweight containers on remote VPS nodes. Each location acts as a subnet router, advertising its local networks into the Tailscale overlay. This works beautifully for routing, but I hit a problem: I needed transparent TCP load balancing across these subnet routers without forcing clients to know which backend was where.

My initial approach was to use application-level proxies or DNS round-robin, but both felt clunky. I wanted something that operated at Layer 4, preserved client IPs where possible, and didn't require backends to understand HTTP semantics. That's when I turned to nginx's stream module—a tool I'd used before for simple TCP proxying but never fully explored for this use case.

My Real Setup

Here's what I was working with:

  • Three Tailscale subnet routers: one on Proxmox (10.0.1.0/24), one on Synology (10.0.2.0/24), and one on a VPS (10.0.3.0/24)
  • Services running on various ports: PostgreSQL (5432), Redis (6379), and a custom TCP service on port 9000
  • Clients connecting from within the Tailscale network expecting a single stable endpoint
  • No desire to terminate TLS at the load balancer—backends handled their own encryption

I installed nginx on a dedicated VM within my Proxmox cluster, assigned it a static Tailscale IP (100.64.0.5), and configured it purely as a stream-based load balancer. This VM had no HTTP server block—just the stream module doing TCP forwarding.

Basic Configuration That Worked

I started with a simple round-robin setup for my PostgreSQL instances. The goal was to distribute new connections evenly across two backends sitting behind different subnet routers:

stream {
  upstream postgres_pool {
    server 10.0.1.11:5432;
    server 10.0.2.12:5432;
  }
  
  server {
    listen 5432;
    proxy_pass postgres_pool;
    proxy_connect_timeout 5s;
  }
}

This worked immediately. Clients connected to 100.64.0.5:5432 and were routed to one of the two backends. The key insight here: nginx doesn't care about the application protocol. It just forwards the TCP stream.

For my Redis setup, I needed something smarter because connection durations varied wildly. Some clients held long-lived connections for pub/sub, while others made quick GET/SET calls. I switched to least_conn:

stream {
  upstream redis_pool {
    least_conn;
    server 10.0.1.21:6379 max_fails=2 fail_timeout=10s;
    server 10.0.3.22:6379 max_fails=2 fail_timeout=10s;
  }
  
  server {
    listen 6379;
    proxy_pass redis_pool;
  }
}

This immediately balanced load better. New connections went to whichever backend had fewer active streams at that moment.

Preserving Client IPs

One limitation I hit: backends saw all connections coming from the nginx VM's IP, not the original client. For logging and access control, this was a problem.

I enabled the PROXY protocol on the nginx side:

server {
  listen 9000;
  proxy_pass custom_service_pool;
  proxy_protocol on;
}

Then I configured my custom service (a Go application) to parse PROXY protocol headers. This worked, but it required backend support. For services that didn't understand PROXY protocol, I had to accept the IP loss or use a different approach (like HAProxy, which I didn't want to introduce).

What Didn't Work

My first attempt at health checking failed. I assumed nginx stream had built-in active health checks like the http module. It doesn't—at least not in the open-source version. The stream module only does passive health checks: if a backend drops connections or times out, nginx marks it down based on max_fails and fail_timeout.

I tried to work around this by scripting external health checks that would modify the nginx config and reload it. This was fragile and slow. Eventually, I accepted that passive checks were good enough for my use case, as long as I tuned fail_timeout aggressively (10-15 seconds) and monitored backend availability separately.

Another issue: I tried to use hash-based load balancing with $remote_addr to achieve session stickiness. This worked, but Tailscale's NAT traversal sometimes changed the apparent client IP between connections, breaking stickiness. I ended up not relying on IP-based hashing and instead designed backends to handle reconnections gracefully.

TLS Passthrough

For one service, I needed to route based on SNI (Server Name Indication) without terminating TLS. The backend held the certificates, and I wanted end-to-end encryption.

I used nginx's ssl_preread module to inspect the SNI header before forwarding:

stream {
  map $ssl_preread_server_name $backend {
    service1.internal backend1;
    service2.internal backend2;
    default backend_default;
  }
  
  upstream backend1 { server 10.0.1.31:443; }
  upstream backend2 { server 10.0.2.32:443; }
  upstream backend_default { server 10.0.3.33:443; }
  
  server {
    listen 443;
    proxy_pass $backend;
    ssl_preread on;
  }
}

This worked perfectly. Clients connected to 100.64.0.5:443, nginx read the SNI, routed to the correct backend, and TLS remained intact end-to-end. No certificate management on the load balancer, no decryption overhead.

Performance and Tuning

Under moderate load (a few hundred concurrent connections), the default nginx settings were fine. But I noticed occasional connection delays during traffic spikes. I tuned these kernel parameters on the nginx VM:

net.core.somaxconn = 4096
net.ipv4.tcp_tw_reuse = 1
fs.file-max = 100000

And adjusted nginx's worker settings:

worker_processes auto;
worker_connections 2048;

This eliminated the delays. I also enabled access logs for the stream module to understand traffic patterns:

log_format basic '$remote_addr [$time_local] '
                 '$protocol $status $bytes_sent $bytes_received '
                 '$session_time';

server {
  listen 5432;
  proxy_pass postgres_pool;
  access_log /var/log/nginx/stream_postgres.log basic;
}

These logs helped me identify which backends were seeing the most traffic and which clients were holding long connections.

Integration with Tailscale

One thing I learned: nginx doesn't need to know anything about Tailscale. As long as the VM running nginx is a Tailscale node and can reach the backend IPs (either directly or via advertised routes), everything just works. The stream module operates purely at the TCP layer, so Tailscale's WireGuard tunneling is invisible to it.

I did have to ensure that the nginx VM's Tailscale node was configured to accept routes from other subnet routers. I used:

tailscale up --accept-routes

Without this, the nginx VM couldn't reach backends on remote subnets, and connections would hang.

Monitoring and Failures

I set up a simple monitoring script that connects to each backend directly (bypassing nginx) every 30 seconds. If a backend is down, I get a notification, but I don't automatically remove it from the nginx config. Instead, nginx's passive health checks handle failover.

During one incident, a backend became unresponsive due to a disk I/O hang. Nginx marked it down after max_fails=2 attempts, and traffic shifted to the healthy backend. Once I resolved the issue and restarted the backend service, nginx automatically started routing to it again after fail_timeout expired.

This passive approach isn't perfect—there's a brief window where some clients might hit the failing backend—but it's simple and requires no external orchestration.

Key Takeaways

  • The nginx stream module is excellent for transparent TCP load balancing when you don't need HTTP-specific features
  • It works seamlessly with Tailscale subnet routers—no special configuration needed beyond basic routing
  • Passive health checks are sufficient for many use cases, but you need to tune max_fails and fail_timeout carefully
  • PROXY protocol is the cleanest way to preserve client IPs, but it requires backend support
  • TLS passthrough with SNI routing is straightforward and keeps certificate management at the backend
  • For stateful services like databases, least_conn works better than round-robin
  • Kernel tuning matters under load—don't skip it if you're handling more than a few dozen concurrent connections

This setup has been running in my environment for several months now. It's stable, requires minimal maintenance, and gives me a single entry point for services scattered across my Tailscale network. The transparency is the real win—clients don't know or care which subnet router they're hitting, and I can move backends around without updating client configurations.