TLS/SNI passthrough router running on HAProxy. Routes incoming HTTPS (and any TLS) traffic to different backends based on the SNI hostname in the TLS ClientHello — without decrypting anything. Also supports plain TCP routing by port.
Configured entirely via environment variables. No config file to maintain.
flowchart LR
internet["🌐 Internet"]
fw["Firewall<br/>(DNAT)"]
subgraph router["sni-router container · HAProxy"]
tls[":443<br/>SNI inspection<br/>(TLS passthrough)"]
tcp[":5432<br/>TCP passthrough"]
end
m1["Machine 1 :443<br/>(reverse proxy)"]
m2["Machine 2 :443<br/>(reverse proxy)"]
m3["Machine 3 :443<br/>(reverse proxy)"]
m4["Machine 4 :5432<br/>PostgreSQL"]
internet --> fw
fw --> tls
fw --> tcp
tls -->|"SNI: project1.example.com"| m1
tls -->|"SNI: project2.example.com"| m2
tls -->|"SNI: *.staging.example.com"| m3
tls -->|"default"| m1
tcp --> m4
The TLS stream is forwarded byte-for-byte — sni-router never decrypts anything. Each backend manages its own certificates.
If PROXY_PROTOCOL=true, HAProxy prepends a PROXY protocol v2 header to each forwarded connection so backends can recover the real client IP.
# Pull the image
docker pull ghcr.io/circle-rd/sni-router:latest
# Or build locally
git clone https://github.com/Circle-RD/sni-router.git
cd sni-router
docker build -t sni-router .Create a .env from the template and adjust values:
cp .env.example .env
$EDITOR .env
docker compose up -d| Variable | Required | Default | Description |
|---|---|---|---|
SNI_LISTEN_PORT |
No | 443 |
Port HAProxy listens on for TLS traffic |
SNI_ROUTE_N |
At least one | — | Routing rule: hostname:backend_ip:backend_port |
SNI_DEFAULT |
Yes | — | Default backend when no SNI rule matches: ip:port |
N must be consecutive integers starting at 1 (SNI_ROUTE_1, SNI_ROUTE_2, …).
Wildcard hostnames are supported: *.example.com will match any subdomain of example.com.
| Variable | Required | Default | Description |
|---|---|---|---|
TCP_ROUTE_N |
No | — | Port-based rule: listen_port:backend_ip:backend_port |
Each TCP_ROUTE_N creates a dedicated HAProxy frontend on listen_port. No SNI inspection — traffic is forwarded as-is.
Remember to expose each
listen_portin yourdocker-compose.ymlports:section.
| Variable | Required | Default | Description |
|---|---|---|---|
PROXY_PROTOCOL |
No | false |
Send PROXY protocol v2 header to all backends (true/false) |
When enabled, HAProxy prepends a PROXY protocol v2 header carrying the real client IP to every forwarded connection. Your backend must be configured to accept it.
By default every backend is TCP-checked every 2 seconds (check inter 2s fall 3 rise 2). This detects whether the port is open and automatically removes/restores backends during redeployments.
For deeper application-level verification you can point HAProxy at an HTTPS health-check endpoint per backend. HAProxy will open its own TLS connection (without certificate verification) and send an HTTP GET request.
| Variable | Required | Default | Description |
|---|---|---|---|
SNI_HEALTH_N |
No | (TCP) | HTTPS health-check path for SNI_ROUTE_N (e.g. /health) |
SNI_DEFAULT_HEALTH |
No | (TCP) | HTTPS health-check path for the SNI_DEFAULT backend |
TCP_HEALTH_N |
No | (TCP) | HTTPS health-check path for TCP_ROUTE_N (e.g. /health) |
When a *_HEALTH_* variable is not set, the corresponding backend keeps a plain TCP connect check.
Example — enable an HTTPS health check for the first SNI route:
environment:
SNI_ROUTE_1: "app.example.com:192.168.1.10:443"
SNI_HEALTH_1: "/api/health" # GET https://app.example.com/api/health
SNI_ROUTE_2: "api.example.com:192.168.1.20:443"
# SNI_HEALTH_2 not set → plain TCP check
SNI_DEFAULT: "192.168.1.10:443"
SNI_DEFAULT_HEALTH: "/health"Example — Traefik:
# traefik static config
entryPoints:
websecure:
proxyProtocol:
trustedIPs:
- "192.168.1.5" # IP of the sni-router containerExample — nginx:
server {
listen 443 ssl proxy_protocol;
set_real_ip_from 192.168.1.5;
real_ip_header proxy_protocol;
}| Variable | Required | Default | Description |
|---|---|---|---|
STATS_ENABLED |
No | false |
Enable the built-in HAProxy web dashboard |
STATS_PORT |
No | 8404 |
Port for the stats UI |
STATS_PASSWORD |
No | (none) | Password for user admin. Leave empty to disable auth |
Access the dashboard at http://<local-ip>:<STATS_PORT>/stats.
⚠️ Always bind the stats port to a local network interface, never to0.0.0.0on a public-facing host. Indocker-compose.yml:"192.168.1.5:8404:8404"instead of"8404:8404".
services:
sni-router:
image: ghcr.io/circle-rd/sni-router:latest
restart: unless-stopped
ports:
- "443:443"
# Bind stats UI to local network interface only
- "192.168.1.5:8404:8404"
environment:
SNI_LISTEN_PORT: "443"
SNI_ROUTE_1: "project1.example.com:192.168.1.10:443"
SNI_ROUTE_2: "project2.example.com:192.168.1.20:443"
SNI_ROUTE_3: "*.staging.example.com:192.168.1.30:443"
SNI_DEFAULT: "192.168.1.10:443"
PROXY_PROTOCOL: "true"
STATS_ENABLED: "true"
STATS_PORT: "8404"
STATS_PASSWORD: "changeme"Validate SNI routing (the certificate returned must be the one from the backend, not from sni-router):
openssl s_client -connect <public-ip>:443 -servername project1.example.com </dev/null 2>&1 | grep "subject="
openssl s_client -connect <public-ip>:443 -servername project2.example.com </dev/null 2>&1 | grep "subject="Test default fallback (unknown SNI → SNI_DEFAULT):
openssl s_client -connect <public-ip>:443 -servername unknown.example.com </dev/null 2>&1 | grep "subject="Test plain TCP route (e.g. PostgreSQL on port 5432):
psql -h <public-ip> -p 5432 -U myuser mydbThe published image supports the following platforms:
| Platform | Architecture |
|---|---|
linux/amd64 |
x86-64 servers, VMs |
linux/arm64 |
ARM64 (Apple M*, AWS Graviton, RPi 4/5) |
linux/arm/v7 |
ARMv7 (Raspberry Pi 2/3) |
Docker will automatically pull the correct variant for your host.
- The container starts and
entrypoint.shruns before HAProxy. - The script reads
SNI_ROUTE_NandTCP_ROUTE_Nvariables, generates a validhaproxy.cfg, and runshaproxy -c -fto validate it. - If validation passes, HAProxy starts. If not, the container exits immediately with an error.
- For TLS connections, HAProxy reads only the TLS ClientHello (the SNI field) without decrypting anything, then forwards the raw TCP byte stream to the matching backend.
- For plain TCP routes, HAProxy forwards by listen port without any inspection.
MIT — see LICENSE.