HAProxy, deux domaines et deux VM sur un seul Proxmox
Comment j'ai routé deux noms de domaine vers deux VM Proxmox avec une seule IP publique, grâce à HAProxy dans un LXC, au SNI et à un filtrage Cloudflare.
Self hosting & homelab9 min de lecture

Ça faisait un moment que je voulais un playground, une VM à part avec son propre Dokploy et son propre domaine, où je peux tester, casser et réinstaller sans jamais risquer de faire tomber mes vrais services. J'ai un Proxmox chez OVH avec de la place, donc je me suis dit « facile, je monte une VM ». Sauf que je n'ai qu'une seule IP publique, et que mes ports 80 et 443 partaient déjà tous vers mon Dokploy de prod. Spoiler, pas besoin d'acheter une deuxième IP. Un petit LXC avec HAProxy dedans a suffi, avec quand même deux ou trois petits pièges.
Le point de départ#
Sur mon Proxmox, les VM sont dans un réseau privé 10.0.0.0/24 derrière le bridge vmbr1, et mon hôte fait du NAT avec iptables. Tout le trafic web partait vers le conteneur qui fait tourner mon Dokploy, en 10.0.0.74.
-A PREROUTING -d 203.0.113.10/32 -p tcp --dport 80 -j DNAT --to-destination 10.0.0.74:80
-A PREROUTING -d 203.0.113.10/32 -p tcp --dport 443 -j DNAT --to-destination 10.0.0.74:443
# Seul Cloudflare a le droit de parler aux ports 80 et 443
-A FORWARD -i vmbr0 -d 10.0.0.74/32 -p tcp -m multiport --dports 80,443 -m set ! --match-set cf4 src -j DROPnikho.dev passe par le proxy de Cloudflare, et la dernière règle jette tout ce qui ne vient pas d'une IP Cloudflare (l'ipset cf4). Personne ne peut donc contourner Cloudflare en tapant directement sur l'IP du serveur.
Le problème est simple. Un port redirigé ne peut pointer que vers une seule machine. Pour ajouter mon playground, il me fallait donc quelque chose devant les deux VM, capable de savoir à qui s'adresse chaque requête.
Une seule IP suffit, grâce au SNI#
Sur le port 80, c'est facile. La requête HTTP est en clair, il suffit de lire l'en-tête Host pour savoir où l'envoyer.
Sur le 443, on pourrait croire qu'il faut déchiffrer le trafic pour savoir à qui il est destiné. En fait non. Au tout début d'une connexion TLS, le navigateur envoie un ClientHello qui contient le nom de domaine demandé, en clair. C'est le SNI (Server Name Indication). HAProxy lit ce champ, choisit la bonne VM, puis relaie le flux chiffré tel quel, sans jamais le déchiffrer.
Ça veut dire que chaque VM garde ses propres certificats Let's Encrypt, gérés par son Dokploy, exactement comme avant. Rien à migrer.
Pourquoi HAProxy#
HAProxy, c'est un reverse proxy et un load balancer open source qui existe depuis plus de vingt ans, et qui tourne devant des sites comme GitHub ou Stack Overflow. Il est réputé pour ses performances, un seul processus encaisse des dizaines de milliers de connexions en consommant très peu de CPU et de mémoire. Pour mon usage, il ne fait que lire le SNI et relayer le flux, autant dire qu'il ne travaille quasiment pas.
J'aurais pu faire la même chose avec Nginx et son module stream, ou avec Traefik, mais HAProxy fait exactement ça, simplement, avec une config lisible et une page de stats intégrée.
Pourquoi un LXC dédié#
J'ai hésité à installer HAProxy directement sur l'hôte Proxmox. Mauvaise idée, au final. HAProxy reçoit tout le trafic d'Internet, et une faille dedans donnerait directement la main sur l'hyperviseur, donc sur toutes les VM.
Une VM complète pour un HAProxy aurait été du gaspillage. Un LXC Debian non privilégié avec 1 vCPU et 512 Mo fait très bien l'affaire, et se snapshot en un clic avant chaque modif.
Créer le LXC#
J'ai utilisé le script HAProxy de community-scripts, lancé depuis le shell du nœud Proxmox, en mode Advanced pour régler le réseau à la main.
- Container non privilégié.
- Bridge
vmbr1, le réseau privé, surtout pasvmbr0qui est relié à l'IP publique. - IP statique
10.0.0.77/24, avec la passerelle10.0.0.1(l'IP devmbr1sur l'hôte).
La config HAProxy#
Une subtilité avant de regarder la config. Le domaine du playground n'est pas chez Cloudflare mais chez un autre provider, il pointe donc directement sur mon IP. Si je gardais le filtre Cloudflare dans iptables, toutes ses requêtes seraient jetées. Le filtrage passe donc dans HAProxy, qui voit à la fois l'IP du visiteur et le domaine demandé.
nikho.devn'est accepté que depuis Cloudflare.playground.exampleest accepté depuis n'importe où.
Voici la config complète que j'utilise.
defaults
log global
mode http
option httplog
timeout connect 5s
timeout client 50s
timeout server 50s
timeout tunnel 1h
# Port 443 : routage sur le SNI, sans déchiffrer
frontend https_in
bind *:443
mode tcp
option tcplog
tcp-request inspect-delay 5s
acl is_playground req.ssl_sni -i playground.example
acl is_playground req.ssl_sni -m end .playground.example
acl from_cf src -f /etc/haproxy/cloudflare.lst
acl tls_hello req_ssl_hello_type 1
tcp-request content accept if tls_hello is_playground
tcp-request content accept if tls_hello from_cf
tcp-request content reject
use_backend playground_https if is_playground
default_backend nikho_https
backend nikho_https
mode tcp
server dokploy 10.0.0.74:443 check
backend playground_https
mode tcp
server playground 10.0.0.76:443 check
# Port 80 : routage sur l'en-tête Host
frontend http_in
bind *:80
acl is_playground hdr(host) -i playground.example
acl is_playground hdr_end(host) -i .playground.example
acl from_cf src -f /etc/haproxy/cloudflare.lst
http-request deny if !is_playground !from_cf
use_backend playground_http if is_playground
default_backend nikho_http
backend nikho_http
server dokploy 10.0.0.74:80 check
backend playground_http
server playground 10.0.0.76:80 checkQuelques lignes méritent une explication.
tcp-request inspect-delaylaisse à HAProxy le temps de recevoir leClientHelloavant de décider.req_ssl_hello_type 1vérifie qu'il s'agit bien d'unClientHelloTLS.- Les deux ACL
is_playgroundcouvrent le domaine nu et tous ses sous-domaines. - Les deux
acceptlaissent passer le playground depuis partout et le reste uniquement depuis Cloudflare. Lerejectfinal coupe tout ce qui n'a pas été accepté. default_backendenvoie tout le reste vers mon Dokploy de prod. Tout ce qui tournait déjà continue donc de marcher sans que j'y touche.timeout tunnel 1hévite de couper les WebSockets inactifs au bout de 50 secondes.
Le fichier cloudflare.lst contient les plages d'IP de Cloudflare, et un petit cron hebdomadaire le tient à jour.
#!/bin/sh
set -e
tmp=$(mktemp)
curl -fsS https://www.cloudflare.com/ips-v4 > "$tmp"
if [ -s "$tmp" ]; then
chmod 644 "$tmp"
mv "$tmp" /etc/haproxy/cloudflare.lst
haproxy -c -f /etc/haproxy/haproxy.cfg && systemctl reload haproxy
else
rm -f "$tmp"
fiOn valide toujours la config avant de recharger.
haproxy -c -f /etc/haproxy/haproxy.cfg && systemctl reload haproxyTester avant de basculer#
Le gros avantage de passer par un LXC, c'est qu'on peut tout tester sans toucher à la prod. Depuis l'hôte, curl --resolve force le nom de domaine vers l'IP du LXC.
curl -skI --resolve nikho.dev:443:10.0.0.77 https://nikho.devIci la requête vient de l'hôte, pas de Cloudflare, donc HAProxy doit couper la connexion. C'est exactement ce qu'on veut vérifier, le filtre fonctionne avant même d'avoir redirigé le moindre port.
Basculer sans rien casser#
Il ne reste plus qu'à faire pointer les ports 80 et 443 vers le LXC.
cp /etc/iptables/rules.v4 /etc/iptables/rules.v4.bak
iptables -t nat -I PREROUTING 1 -d 203.0.113.10/32 -p tcp --dport 80 -j DNAT --to-destination 10.0.0.77:80
iptables -t nat -I PREROUTING 1 -d 203.0.113.10/32 -p tcp --dport 443 -j DNAT --to-destination 10.0.0.77:443Les nouvelles règles sont insérées en tête, elles passent donc devant les anciennes. Si quelque chose casse, il suffit de les supprimer avec -D pour revenir instantanément à l'état d'avant. Une fois que tout marche, on supprime les anciennes redirections vers 10.0.0.74, et surtout on n'oublie pas le iptables-save > /etc/iptables/rules.v4, sinon tout disparaît au prochain reboot.
Dernière vérification, depuis l'extérieur cette fois. Une requête vers nikho.dev qui tape directement sur l'IP du serveur doit être coupée net.
curl -skI --resolve nikho.dev:443:203.0.113.10 https://nikho.devLa page de stats de HAProxy confirme que le trafic passe bien par lui.
# pxname svname scur stot status
https_in FRONTEND 47 48 OPEN
nikho_https dokploy 47 48 UP
playground_https playground 0 0 DOWNLe backend du playground est DOWN, normal, la VM n'existait pas encore.
Une VM pour le playground, pas un LXC#
Un playground, c'est l'endroit où je vais installer n'importe quoi trouvé sur Internet, lancer des conteneurs douteux et tout casser régulièrement. Pour HAProxy, un LXC était parfait, mais ici la question méritait d'être posée.
Un LXC partage le noyau de l'hôte. Si quelque chose arrive à s'en échapper, c'est directement l'hyperviseur qui est touché, et donc toutes les autres machines. Ça passe pour un service que je maîtrise, beaucoup moins pour une machine dont le but est justement d'accueillir des trucs que je ne connais pas encore. En plus, Proxmox déconseille officiellement Docker dans un LXC, et les mises à jour de l'hôte cassent régulièrement Docker Swarm dedans. Pour le playground, ce sera donc une vraie VM, avec son propre noyau.
- Machine
q35, CPU typehost, 2 cœurs et 4 Go de RAM. - Bridge
vmbr1, IP10.0.0.76/24. - Debian 13 en cloud-init, puis le script d'installation de Dokploy.
Dès que Dokploy tourne sur la VM, le backend passe UP dans les stats, et le playground répond avec son certificat Let's Encrypt.
Les limites#
Ce setup me convient très bien, mais il a quelques limites à connaître.
- L'IP des visiteurs est perdue. Vues des VM, toutes les connexions viennent du LXC. Pour
nikho.dev, l'en-têteCF-Connecting-IPde Cloudflare compense. Pour le playground, il faudrait activer le PROXY protocol,send-proxy-v2côté HAProxy et l'option équivalente côté Traefik, des deux côtés en même temps, sinon plus rien ne passe. - Le LXC est un point unique de défaillance. S'il tombe, les deux domaines tombent. Pensez à cocher le démarrage automatique.
En bref#
- Une seule IP publique suffit pour plusieurs VM, à condition de mettre un HAProxy devant.
- Le port 80 se route sur l'en-tête
Host, le 443 sur le SNI, sans jamais déchiffrer le trafic. Les certificats restent gérés par chaque VM. - Le filtrage Cloudflare peut se faire dans HAProxy, domaine par domaine, avec une liste blanche et un
rejectfinal. - Un LXC dédié protège l'hyperviseur, mais pour une machine où l'on installe n'importe quoi, une VM reste le bon choix.
Bref, avec un LXC qui ne consomme presque rien et une quarantaine de lignes de config, j'ai enfin mon playground sur son propre domaine, et ma prod n'a pas bougé. Le jour où j'ai besoin d'une troisième VM, ce sera deux ACL et deux backends de plus.

