Skip to content

Commit af0d02b

Browse files
committed
add load balancer article and improve content
1 parent 98304a6 commit af0d02b

File tree

3 files changed

+367
-0
lines changed

3 files changed

+367
-0
lines changed
76 KB
Loading

src/content/post/2025/05-29-traefik-load-balancer/_resources/.gitkeep

Whitespace-only changes.
Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
---
2+
title: Load balancing multiple Rathole tunnels with Traefik HTTP and TCP routers
3+
description: |
4+
Expose multiple home servers using a single Rathole server.
5+
publishDate: 2025-05-29
6+
heroImage: '../../../../content/post/2025/05-29-traefik-load-balancer/_images/load-balancer-architecture-16-9.png'
7+
heroAlt: Load balancer architecture diagram
8+
tags:
9+
- devops
10+
- docker
11+
category: homelab
12+
toc: true
13+
draft: true
14+
---
15+
16+
17+
## Introduction
18+
19+
This article is a continuation of [Expose home server with Rathole tunnel and Traefik](https://nemanjamitic.com/blog/2025-04-29-rathole-traefik-home-server#exposing-multiple-servers), which explains how to permanently host websites from hom by bypassing CGNAT. That setup works well for exposing a single home server (like a Raspberry Pi, server PC, or virtual machine), but it has a limitation: it requires one VPS (or at least one public network interface) per home server. This is because the Rathole server exclusively uses ports `80` and `443`.
20+
21+
But it doesn't have to be like this. We can reuse a single Rathole server for many tunnels and home servers, we just need a tool to load balance their traffic. As long as our VPS's network interface provides enough bandwidth for our websites and services.
22+
23+
This article explains how to achieve that using Traefik HTTP and TCP routers.
24+
25+
## Prerequisites
26+
27+
- A working Rathole tunnel setup from the previous article (including a VPS and domain name)
28+
- More than one home server (Raspberry Pi, server PC, virtual machine, LXC container)
29+
30+
## Architecture overview
31+
32+
### The problem
33+
34+
The main problem here is that we can't bind more than one port to ports `80` and `443` respectively. Only one service can listen at one port at same time. So something like this doesn't exist:
35+
36+
```yml title="docker-compose.yml"
37+
services:
38+
39+
rathole:
40+
image: rapiz1/rathole:v0.5.0
41+
container_name: rathole
42+
command: --server /config/rathole.server.toml
43+
restart: unless-stopped
44+
ports:
45+
# host:container
46+
- 2333:2333
47+
- 80:5080,5081 # non existent syntax, can't bind two ports to a single port
48+
- 443:5443,5444 # same
49+
volumes:
50+
- ./rathole.server.toml:/config/rathole.server.toml:ro
51+
```
52+
53+
Neither operating system nor Docker provide load balancing functionality out of the box, we need to handle it ourselves.
54+
55+
### The solution
56+
57+
We need to introduce a tool for load balancing traffic between tunnels, we will use Traefik since we already use it with Rathole client.
58+
59+
For each home server we need 2 tunnels, one for HTTP and another for HTTPS traffic:
60+
61+
1. Tunnel for HTTP traffic will use Traefik HTTP router as usual.
62+
2. Tunnel for HTTPS traffic is a bit more interesting and challenging. For it, we will use Traefik TCP router running in the passthrough mode since we don't want to terminate HTTPS traffic on the VPS but to delegate resolving certificates to the existing Traefik running on the client side to keep the existing setup and architecture.
63+
64+
**Reminder:**
65+
66+
I already wrote about the advantage of resolving SSL certificates locally on home server in the [Architecture overview](https://nmc-docker.pi.nemanjamitic.com/blog/2025-04-29-rathole-traefik-home-server#architecture-overview) section of the previous article but here is a quick recap again:
67+
68+
1. Home server contains its entire configuration
69+
2. Home server is tunnel agnostic and reusable
70+
3. No coupling between tunnel server and client, no need to maintain state/version
71+
4. Decoupled debugging
72+
5. Improved security, additional encryption layer further down the tunnel
73+
74+
## Traefik load balancer and Rathole server
75+
76+
Since we passthrough encrypted HTTPS traffic, Traefik can't read subdomain from a HTTP request as usual. Instead, we will run Traefik router in TCP mode, using the [HostSNIRegexp](https://doc.traefik.io/traefik/routing/routers/#hostsni-and-hostsniregexp) matcher. This will run the router on layer 4 (TCP) instead of usual layer 7 (HTTP).
77+
78+
For more in depth info how this works you can read here: [Server Name Indication (SNI)](https://en.wikipedia.org/wiki/Server_Name_Indication).
79+
80+
Now that we understand the principle we can get to the practical implementation.
81+
82+
### Traefik HTTP and TCP routers
83+
84+
Bellow is the complete `docker-compose.yml` that defines Traefik TCP router and the Rathole server with 2 HTTP, HTTPS tunnel pairs for 2 home servers, `pi` - OrangePi and `local` - MiniPC in my case.
85+
86+
```yml title="docker-compose.yml"
87+
version: '3.8'
88+
89+
services:
90+
traefik:
91+
image: traefik:v2.9.8
92+
container_name: traefik
93+
restart: unless-stopped
94+
command:
95+
- --providers.docker=true
96+
- --entrypoints.web.address=:80
97+
- --entrypoints.websecure.address=:443
98+
- --entrypoints.traefik.address=:8080
99+
- --api.dashboard=true
100+
- --api.insecure=false
101+
- --log.level=DEBUG
102+
- --accesslog=true
103+
ports:
104+
- 80:80
105+
- 443:443
106+
- 8080:8080
107+
volumes:
108+
- /var/run/docker.sock:/var/run/docker.sock:ro
109+
networks:
110+
- proxy
111+
labels:
112+
# Enable the dashboard at http://traefik.amd2.nemanjamitic.com
113+
# http for simplicity, no acme.json file
114+
- traefik.enable=true
115+
- 'traefik.http.routers.traefik.rule=Host(`traefik.amd2.${SITE_HOSTNAME}`)'
116+
- traefik.http.routers.traefik.entrypoints=web
117+
- traefik.http.routers.traefik.service=api@internal
118+
- traefik.http.routers.traefik.middlewares=auth
119+
- 'traefik.http.middlewares.auth.basicauth.users=${TRAEFIK_AUTH}'
120+
121+
rathole:
122+
image: rapiz1/rathole:v0.5.0
123+
container_name: rathole
124+
command: --server /config/rathole.server.toml
125+
restart: unless-stopped
126+
ports:
127+
- 2333:2333
128+
volumes:
129+
- ./rathole.server.toml:/config/rathole.server.toml:ro
130+
networks:
131+
- proxy
132+
133+
labels:
134+
### HTTP port 80 - HTTP routers ###
135+
136+
# pi.nemanjamitic.com, www.pi.nemanjamitic.com, *.pi.nemanjamitic.com, www.*.pi.nemanjamitic.com
137+
138+
# Route *.pi.nemanjamitic.com -> 5080
139+
- 'traefik.http.routers.rathole-pi.rule=HostRegexp(`pi.${SITE_HOSTNAME}`, `www.pi.${SITE_HOSTNAME}`, `{subdomain:[a-z0-9-]+}.pi.${SITE_HOSTNAME}`, `www.{subdomain:[a-z0-9-]+}.pi.${SITE_HOSTNAME}`)'
140+
- traefik.http.routers.rathole-pi.entrypoints=web
141+
- traefik.http.routers.rathole-pi.service=rathole-pi
142+
- traefik.http.services.rathole-pi.loadbalancer.server.port=5080
143+
144+
# Route *.local.nemanjamitic.com -> 5081
145+
- 'traefik.http.routers.rathole-local.rule=HostRegexp(`local.${SITE_HOSTNAME}`, `www.local.${SITE_HOSTNAME}`, `{subdomain:[a-z0-9-]+}.local.${SITE_HOSTNAME}`, `www.{subdomain:[a-z0-9-]+}.local.${SITE_HOSTNAME}`)'
146+
- traefik.http.routers.rathole-local.entrypoints=web
147+
- traefik.http.routers.rathole-local.service=rathole-local
148+
- traefik.http.services.rathole-local.loadbalancer.server.port=5081
149+
150+
### HTTPS port 443 with TLS passthrough - TCP routers ###
151+
152+
# Route *.pi.nemanjamitic.com -> 5443
153+
- 'traefik.tcp.routers.rathole-pi-secure.rule=HostSNIRegexp(`pi.${SITE_HOSTNAME}`, `www.pi.${SITE_HOSTNAME}`, `{subdomain:[a-z0-9-]+}.pi.${SITE_HOSTNAME}`, `www.{subdomain:[a-z0-9-]+}.pi.${SITE_HOSTNAME}`)'
154+
- traefik.tcp.routers.rathole-pi-secure.entrypoints=websecure
155+
- traefik.tcp.routers.rathole-pi-secure.tls.passthrough=true
156+
- traefik.tcp.routers.rathole-pi-secure.service=rathole-pi-secure
157+
- traefik.tcp.services.rathole-pi-secure.loadbalancer.server.port=5443
158+
159+
# Route *.local.nemanjamitic.com -> 5444
160+
- 'traefik.tcp.routers.rathole-local-secure.rule=HostSNIRegexp(`local.${SITE_HOSTNAME}`, `www.local.${SITE_HOSTNAME}`, `{subdomain:[a-z0-9-]+}.local.${SITE_HOSTNAME}`, `www.{subdomain:[a-z0-9-]+}.local.${SITE_HOSTNAME}`)'
161+
- traefik.tcp.routers.rathole-local-secure.entrypoints=websecure
162+
- traefik.tcp.routers.rathole-local-secure.tls.passthrough=true
163+
- traefik.tcp.routers.rathole-local-secure.service=rathole-local-secure
164+
- traefik.tcp.services.rathole-local-secure.loadbalancer.server.port=5444
165+
166+
networks:
167+
proxy:
168+
external: true
169+
```
170+
171+
Let's start with the most important part, `labels` on the `rathole` container that define load balancing on the two tunnels. First we define two HTTP routers using `HostRegexp()` matcher. It takes HTTP traffic from entrypoint on port `80` and load balances it between two tunnels on ports `5080` and `5081`.
172+
173+
Second pair of labels define TCP router that takes traffic from HTTPS entrypoint on port `443` and passes it through without decrypting and load balances it between tunnels on ports `5443` and `5444`. Note that with `HostSNIRegexp()` matcher you can't include escaped dot '`.`' in the regex but you must repeat the entire domain sequence to handle `www` variant of the domain.
174+
175+
Also note that we use a separate regex variants to match the root subdomain explicitly, e.g. `pi.nemanjamitic.com` and `www.pi.nemanjamitic.com` for both HTTP and TCP routers.
176+
177+
That's it, this is the main load balancing logic definition.
178+
179+
**Note:** Because we use `HostRegexp()` and `HostSNIRegexp()` on the server you will need to use `Host()` and `HostSNI()` matchers **for the Traefik running on the client side of the tunnel**, or you will get `404` errors without additional configuration. Regex matchers on both server and client seems to be too loose.
180+
181+
### Rathole server config
182+
183+
Now it's just left to write config for Rathole server that defines 2x2 tunnels. Just make sure to use **a different token and port** for each tunnel.
184+
185+
```toml title="rathole.server.toml"
186+
[server]
187+
bind_addr = "0.0.0.0:2333"
188+
189+
[server.transport]
190+
type = "noise"
191+
192+
[server.transport.noise]
193+
local_private_key = "private_key"
194+
195+
# separated based on token, also can NOT use same ports
196+
197+
# pi
198+
[server.services.pi-traefik-http]
199+
token = "secret_token_1"
200+
bind_addr = "0.0.0.0:5080"
201+
202+
[server.services.pi-traefik-https]
203+
token = "secret_token_1"
204+
bind_addr = "0.0.0.0:5443"
205+
206+
# local
207+
[server.services.local-traefik-http]
208+
token = "secret_token_2"
209+
bind_addr = "0.0.0.0:5081"
210+
211+
[server.services.local-traefik-https]
212+
token = "secret_token_2"
213+
bind_addr = "0.0.0.0:5444"
214+
```
215+
216+
**Reminder:** You just need to open port `2333` in the VPS firewall for the Rathole control channel and not for the ports `5080, 5081, 5443, 5444` because they are used by Rathole internally.
217+
218+
### Traefik dashboard
219+
220+
Additionally, for the sake of debugging we expose Traefik dashboard using `labels` on `traefik` container. To simplify the configuration and avoid handling `acme.json` file we expose it using HTTP.
221+
222+
**Warning:** When setting dashboard hashed password via the `TRAEFIK_AUTH` environment variable make sure to escape the `$` characters properly or auth will break. For that you will need to use both double quotes `"..."` and escape slash '`\`', see the example bellow:
223+
224+
```bash
225+
# install apache2-utils
226+
sudo apt install apache2-utils
227+
228+
# hash the password
229+
htpasswd -nb admin yourpassword
230+
```
231+
232+
```bash title=".env"
233+
# use BOTH "..." and \$ to escape $ properly
234+
235+
# this will work correctly
236+
TRAEFIK_AUTH="admin:\$asd1\$E3lsdAo\$3Mertp57JJ4LVU.HRR0"
237+
238+
# this will break
239+
TRAEFIK_AUTH="admin:$asd1$E3lsdAo$3Mertp57JJ4LVU.HRR0"
240+
241+
# this will also break
242+
TRAEFIK_AUTH=admin:\$asd1\$E3lsdAo\$3Mertp57JJ4LVU.HRR0
243+
```
244+
245+
## Rathole client
246+
247+
The client part of the tunnel is almost same like for a single home server. The only thing to keep in mind is to to bind the specific client just to tunnels that are meant for him, and not to all tunnels. Kind of obvious and self-explanatory, but just in case let's be very clear and explicit.
248+
249+
Here we define the `rathole.client.toml` Rathole client config to bind the `pi` home server to it's HTTP `pi-traefik-http` and HTTPS `pi-traefik-https` tunnels.
250+
251+
```toml title="rathole.client.toml"
252+
[client]
253+
remote_addr = "123.123.123.123:2333"
254+
255+
[client.transport]
256+
type = "noise"
257+
258+
[client.transport.noise]
259+
remote_public_key = "public_key"
260+
261+
# single client per tunnels pair
262+
263+
# pi
264+
[client.services.pi-traefik-http]
265+
token = "secret_token_1"
266+
local_addr = "traefik:80"
267+
268+
[client.services.pi-traefik-https]
269+
token = "secret_token_1"
270+
local_addr = "traefik:443"
271+
```
272+
273+
Similarly, here we define the `rathole.client.toml` config to bind the `local` home server to it's HTTP `local-traefik-http` and HTTPS `local-traefik-https` tunnels.
274+
275+
276+
```toml title="rathole.client.toml"
277+
[client]
278+
remote_addr = "123.123.123.123:2333"
279+
280+
[client.transport]
281+
type = "noise"
282+
283+
[client.transport.noise]
284+
remote_public_key = "public_key"
285+
286+
# single client per tunnels pair
287+
288+
# local
289+
[client.services.local-traefik-http]
290+
token = "secret_token_2"
291+
local_addr = "traefik:80"
292+
293+
[client.services.local-traefik-https]
294+
token = "secret_token_2"
295+
local_addr = "traefik:443"
296+
```
297+
298+
`docker-compose.yml` for the Rathole client and Traefik are exactly the same as they were for a single home server, I am repeating it here just for the sake of completeness.
299+
300+
```yml title="docker-compose.yml"
301+
version: '3.8'
302+
303+
services:
304+
305+
rathole:
306+
image: rapiz1/rathole:v0.5.0
307+
container_name: rathole
308+
command: --client /config/rathole.client.toml
309+
restart: unless-stopped
310+
volumes:
311+
- ./rathole.client.toml:/config/rathole.client.toml:ro
312+
networks:
313+
- proxy
314+
315+
traefik:
316+
image: 'traefik:v2.9.8'
317+
container_name: traefik
318+
restart: unless-stopped
319+
depends_on:
320+
- rathole
321+
command:
322+
# moved from static conf to pass email as env var
323+
- '--certificatesresolvers.letsencrypt.acme.email=${TRAEFIK_LETSENCRYPT_EMAIL}'
324+
security_opt:
325+
- no-new-privileges:true
326+
networks:
327+
- proxy
328+
# rathole will pass traffic through proxy network directly on 80 and 443
329+
# defined in rathole.client.toml
330+
environment:
331+
- TRAEFIK_AUTH=${TRAEFIK_AUTH}
332+
volumes:
333+
- /etc/localtime:/etc/localtime:ro
334+
- /var/run/docker.sock:/var/run/docker.sock:ro
335+
- ./traefik-data/traefik.yml:/traefik.yml:ro
336+
- ./traefik-data/acme.json:/acme.json
337+
- ./traefik-data/configurations:/configurations
338+
labels:
339+
- 'traefik.enable=true'
340+
- 'traefik.docker.network=proxy'
341+
- 'traefik.http.routers.traefik-secure.entrypoints=websecure'
342+
- 'traefik.http.routers.traefik-secure.rule=Host(`traefik.${SITE_HOSTNAME}`)'
343+
- 'traefik.http.routers.traefik-secure.middlewares=user-auth@file'
344+
- 'traefik.http.routers.traefik-secure.service=api@internal'
345+
346+
networks:
347+
proxy:
348+
external: true
349+
```
350+
351+
## Completed code
352+
353+
- **Traefik load balancer and Rathole server:** https://github.com/nemanjam/rathole-server
354+
- **Rathole client and local Traefik:** https://github.com/nemanjam/traefik-proxy/tree/main/core
355+
356+
## Conclusion
357+
358+
You can use this setup to expose as many home servers you want, in a cost effective and practical way, as long as your VPS has enough network bandwidth to support their traffic. It can bring your homelab on another level.
359+
360+
What tool and method did you use to expose your home servers to the internet? Do you like this approach, are you willing to give it a try? Let me know in the comments.
361+
362+
Happy self-hosting.
363+
364+
## References
365+
366+
- Traefik `v2.9` `HostRegexp` and `HostSNIRegexp` reference https://doc.traefik.io/traefik/v2.9/routing/routers/#rule
367+
- TLS Server Name Indication (SNI), Wikipedia https://en.wikipedia.org/wiki/Server_Name_Indication

0 commit comments

Comments
 (0)