|
| 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