Why I Needed TCP Routing with SNI
I run multiple services behind a single public IP on my home network. For years, I handled HTTP/HTTPS traffic through Traefik without issues, but I had SSH and RDP services scattered across different ports. Port 2222 for one SSH server, 3390 for RDP on one machine, 3391 for another. It worked, but it was messy and hard to remember.
I wanted something cleaner: route SSH and RDP through standard ports (22 and 3389) using hostnames, just like HTTP routing works. The problem is that SSH and RDP are TCP protocols, not HTTP. They don't send a Host header that Traefik can inspect.
That's where SNI (Server Name Indication) comes in. SNI is part of the TLS handshake—the client tells the server which hostname it's trying to reach before encryption starts. If I could wrap SSH and RDP in TLS and use SNI, Traefik could route based on hostname even for non-HTTP protocols.
My Real Setup
I use Traefik v2.x running in Docker on my home server. It already handles all my HTTP services. My goal was to add TCP routing for:
- SSH access to different VMs and containers
- RDP connections to Windows machines
The core challenge: SSH and RDP don't natively support SNI. SSH has no concept of hostnames in its protocol. RDP does support TLS, but most clients don't send SNI by default.
I needed to:
- Wrap SSH in a TLS tunnel that sends SNI
- Configure RDP clients to send SNI (or use a TLS proxy)
- Set up Traefik TCP routers to inspect SNI and route accordingly
Configuring Traefik TCP Routers
Traefik's TCP routing is fundamentally different from HTTP routing. HTTP routers can inspect headers, paths, and methods. TCP routers can only look at:
- The destination port (EntryPoint)
- SNI hostname (if TLS is used)
- Client IP (with middlewares)
That's it. No path matching, no header inspection.
Static Configuration (EntryPoints)
First, I defined EntryPoints in Traefik's static config. These are the ports Traefik listens on. I added two new ones:
entryPoints:
ssh-tls:
address: ":2222"
rdp-tls:
address: ":3390"
I didn't use port 22 or 3389 directly because my host already had services on those ports. Traefik would listen on 2222 and 3390, then route to the actual services.
Dynamic Configuration (Routers and Services)
For each backend service, I created a TCP router and service. Here's what I used for an SSH connection to a VM called "devbox":
tcp:
routers:
ssh-devbox:
entryPoints:
- ssh-tls
rule: "HostSNI(`devbox.home.local`)"
service: ssh-devbox-service
tls:
passthrough: false
certResolver: letsencrypt
services:
ssh-devbox-service:
loadBalancer:
servers:
- address: "192.168.1.50:22"
Key details:
- rule: "HostSNI(`devbox.home.local`)" – This is the only rule type that works for TCP routing. It matches the SNI hostname sent during the TLS handshake.
- tls.passthrough: false – Traefik terminates TLS, decrypts the SNI, routes the connection, then re-encrypts to the backend. If passthrough were true, Traefik would forward the encrypted stream without inspecting it.
- certResolver: letsencrypt – I use Let's Encrypt for certificates. Traefik automatically provisions and renews them.
- address: "192.168.1.50:22" – The actual SSH server on my network.
For RDP, the config was nearly identical, just different ports and hostnames:
tcp:
routers:
rdp-winbox:
entryPoints:
- rdp-tls
rule: "HostSNI(`winbox.home.local`)"
service: rdp-winbox-service
tls:
passthrough: false
certResolver: letsencrypt
services:
rdp-winbox-service:
loadBalancer:
servers:
- address: "192.168.1.60:3389"
The SSH Problem
SSH does not support SNI. It doesn't even use TLS—it has its own encryption layer. To make this work, I had to wrap SSH in a TLS tunnel.
I tested two approaches:
Approach 1: stunnel
stunnel is a TLS wrapper for arbitrary TCP connections. On my client machine, I ran:
[ssh-devbox] client = yes accept = 127.0.0.1:2222 connect = devbox.home.local:2222 sni = devbox.home.local
Then connected with:
ssh -p 2222 [email protected]
This worked. stunnel established a TLS connection to Traefik, sent the SNI hostname, and Traefik routed it to the correct backend. The SSH client connected to stunnel locally, unaware of the TLS layer.
Approach 2: OpenSSL s_client
For quick tests, I used OpenSSL directly:
openssl s_client -connect devbox.home.local:2222 -servername devbox.home.local -quiet | ssh user@localhost
This piped the TLS-wrapped connection into SSH. It worked for testing but wasn't practical for daily use.
What Didn't Work
I tried using SSH ProxyCommand to automate the TLS wrapping:
Host devbox
HostName devbox.home.local
Port 2222
ProxyCommand openssl s_client -connect %h:%p -servername %h -quiet
This broke because SSH sends its own protocol negotiation immediately, which confused the TLS handshake. stunnel was the only reliable solution.
The RDP Problem
RDP does support TLS, but most clients (Windows Remote Desktop, Remmina) don't send SNI by default. I found two workarounds:
Approach 1: Force SNI with FreeRDP
FreeRDP (a Linux RDP client) has an option to send SNI:
xfreerdp /v:winbox.home.local:3390 /u:user /cert-ignore /tls-seclevel:0
This worked immediately. FreeRDP sent the SNI hostname, Traefik routed correctly.
Approach 2: stunnel for Windows RDP Client
On Windows, the built-in Remote Desktop client doesn't support SNI. I used stunnel as a local proxy:
[rdp-winbox] client = yes accept = 127.0.0.1:3390 connect = winbox.home.local:3390 sni = winbox.home.local
Then connected to 127.0.0.1:3390 in Remote Desktop. This added an extra step but worked reliably.
What Didn't Work
I tried configuring the Windows RDP client to send SNI by modifying registry keys. This did nothing. Microsoft's RDP implementation simply doesn't expose SNI control to users.
Debugging and Logs
Traefik's logs were essential for troubleshooting. I enabled debug logging:
log: level: DEBUG
Common issues I hit:
- No SNI sent – Traefik logged "no SNI" and rejected the connection. This meant the client wasn't sending the hostname.
- Certificate mismatch – If the SNI hostname didn't match the certificate, connections failed. I had to ensure Let's Encrypt provisioned certs for all hostnames.
- Wrong EntryPoint – If I connected to the wrong port, Traefik couldn't match any router. Always verify which EntryPoint the router is attached to.
I also used tcpdump to inspect TLS handshakes:
tcpdump -i any -n port 2222 -X
This showed whether SNI was actually being sent in the ClientHello packet.
Limitations and Trade-offs
This setup works, but it's not seamless:
- Client-side complexity – SSH and RDP clients need stunnel or special configuration. This isn't transparent to users.
- Extra TLS layer – Wrapping SSH (which is already encrypted) in TLS adds overhead. It's not huge, but it's there.
- Certificate management – Every hostname needs a valid certificate. Let's Encrypt works, but if you have many services, DNS validation is easier than HTTP challenges.
- No fallback – If SNI isn't sent, Traefik can't route the connection. There's no "default" backend like with HTTP.
For RDP, the experience is better because the protocol already uses TLS. For SSH, the stunnel requirement is clunky.
What I Learned
TCP routing with SNI is possible, but it's not designed for protocols like SSH that don't natively support it. Traefik does its job—it routes based on SNI—but the client side requires workarounds.
If I were starting fresh, I'd consider:
- SSH over HTTPS – Tools like
websocketdorsshportalcan wrap SSH in HTTP, making it easier to route through Traefik's HTTP routers. - VPN instead – For internal services, a WireGuard VPN might be simpler than trying to expose everything through a reverse proxy.
- Separate ports per service – If SNI is too complex, just use different ports. It's less elegant but more reliable.
That said, this setup works for my use case. I have a single public IP, a handful of services, and I don't mind the stunnel step for SSH. It's not perfect, but it's functional.
Key Takeaways
- TCP routing in Traefik only works with SNI if TLS is involved.
- SSH requires a TLS wrapper (like stunnel) to send SNI.
- RDP works better because it natively supports TLS, but not all clients send SNI.
- Debugging requires inspecting TLS handshakes and Traefik's logs.
- This approach adds complexity—consider whether it's worth it for your setup.
If you run Traefik and need to expose non-HTTP services, this is one way to do it. Just know what you're signing up for.