Table des matières
Migration proxy HTTP : Raspi → NUC
Cette page décrit le reverse proxy de la maison. Il tournait auparavant sur un Raspberry Pi (Arch Linux, Apache et Certbot). Il tourne maintenant sur un NUC sous Debian 13, avec le logiciel Caddy dans un conteneur Docker.
Le changement visait une machine plus simple à entretenir, plus fiable, entièrement en logiciels libres et plus facile à surveiller.
1. Système
Distribution
Le NUC fonctionne sous Debian 13 « Trixie », version stable depuis le 9 août 2025 et maintenue jusqu'en 2030. L'installation est minimale : un serveur SSH et les outils habituels, sans interface graphique.
Le noyau est installé par le paquet linux-image-amd64, qui suit automatiquement les correctifs de sécurité. Le bootloader est GRUB, le choix par défaut de Debian, qui s'entend bien avec LVM.
Partitionnement
Le disque est un NVMe de 250 Go, non chiffré. Il est découpé en trois partitions :
| Partition | Taille | FS | Montage |
|---|---|---|---|
| nvme0n1p1 | 1 Go | FAT32 | /boot/efi |
| nvme0n1p2 | 1 Go | ext4 | /boot |
| nvme0n1p3 | ~248 Go | LVM (PV) | — |
La troisième partition est un volume physique LVM. Elle porte le groupe de volumes volumes, découpé ainsi :
| LV | Taille | FS | Montage |
|---|---|---|---|
| lv-swap | 8 Go | swap | [SWAP] |
| lv-root | 25 Go | ext4 | / |
| lv-var | 35 Go | ext4 | /var |
| lv-opt | 60 Go | ext4 | /opt |
| (libre) | ~93 Go | — | réserve |
Environ 93 Go restent non alloués dans le VG, pour étendre un LV le jour où il sera plein.
/var :
lvextend -L +20G /dev/volumes/lv-var resize2fs /dev/volumes/lv-var
2. Réseau
Adressage
Le routeur est un OpenWrt à l'adresse 192.168.1.90. Il sert aussi de DNS local et résout les noms des machines internes.
Le NUC a toujours l'adresse 192.168.1.25. Il l'obtient par une réservation DHCP sur le routeur, liée à son adresse MAC cc:28:aa:47:ea:ab.
Cette adresse était déjà celle de l'ancien proxy. Le remplacement n'a donc demandé aucun changement ailleurs : les redirections de ports du routeur et les noms internes sont restés les mêmes.
Gestion réseau
L'interface enp1s0 est gérée par ifupdown. Elle est déclarée en DHCP dans /etc/network/interfaces par la ligne iface enp1s0 inet dhcp.
Pour renouveler le bail DHCP :
sudo sh -c 'ifdown enp1s0; ifup enp1s0'
# Sur OpenWrt sed -i '/cc:28:aa:47:ea:ab/d; /192.168.1.25/d' /tmp/dhcp.leases /etc/init.d/dnsmasq restart
3. Sécurité
Pare-feu (UFW)
La politique par défaut refuse le trafic entrant et autorise le trafic sortant. Les ouvertures sont les suivantes :
ufw default deny incoming ufw default allow outgoing ufw allow from 192.168.1.0/24 to any port 22 proto tcp # SSH depuis LAN1 ufw allow from 192.168.115.0/24 to any port 22 proto tcp # SSH depuis le WiFi ufw allow from 10.1.100.0/24 to any port 22 proto tcp # SSH depuis le VPN ufw allow 80/tcp # HTTP (challenge ACME) ufw allow 443/tcp # HTTPS ufw allow 443/udp # HTTP/3 ufw enable
SSH n'est joignable que depuis les réseaux internes (LAN1, WiFi, VPN). Le port est fermé côté Internet, en IPv4 comme en IPv6.
Les règles sur les ports 80 et 443 sont là pour la lisibilité. En pratique, Docker publie lui-même ces ports par ses propres règles iptables, en amont d'UFW.
Cloisonnement sortant de Caddy
Le proxy est la seule machine de la maison directement exposée à Internet. Si Caddy était compromis, l'attaquant aurait un pied sur le LAN. Pour limiter les dégâts, le conteneur ne peut joindre, sur les réseaux privés, que les backends qu'il dessert réellement.
Ce filtrage ne peut pas s'écrire avec les règles UFW habituelles. Caddy est sur un réseau bridge Docker : son trafic vers le LAN est routé par l'hôte et traverse la chaîne FORWARD, pas la chaîne OUTPUT. Les règles ufw allow out et ufw deny out ne s'y appliquent donc pas. Docker réserve à cet usage la chaîne DOCKER-USER, évaluée avant ses propres règles.
Les règles sont écrites à la fin du fichier /etc/ufw/after.rules. UFW les charge à chaque démarrage et à chaque rechargement. Une copie du fichier d'origine est conservée sous le nom after.rules.bak.
# BEGIN CADDY EGRESS *filter :DOCKER-USER - [0:0] -A DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN -A DOCKER-USER -s 172.18.0.0/16 -d 192.168.1.9 -p tcp -m multiport --dports 443,3000,3001,3002,8888,17170 -j RETURN -A DOCKER-USER -s 172.18.0.0/16 -d 192.168.1.67 -p tcp --dport 8123 -j RETURN -A DOCKER-USER -s 172.18.0.0/16 -d 192.168.1.90 -p udp --dport 53 -j RETURN -A DOCKER-USER -s 172.18.0.0/16 -d 192.168.1.90 -p tcp --dport 53 -j RETURN -A DOCKER-USER -s 172.18.0.0/16 -d 192.168.0.0/16 -j DROP -A DOCKER-USER -s 172.18.0.0/16 -d 10.0.0.0/8 -j DROP COMMIT # END CADDY EGRESS
Les règles se lisent de haut en bas, et la première qui correspond s'applique. La première laisse passer les connexions déjà établies, c'est-à-dire les réponses de Caddy aux clients du LAN. Les quatre suivantes autorisent les destinations du tableau ci-dessous. Les deux dernières font un DROP vers toutes les autres adresses privées, ce qui couvre LAN1, le WiFi et le VPN.
| Destination | Port | Service |
|---|---|---|
| cahute (.9) | 443 | Apache : blog, dav, musique, nuage, wiki |
| cahute (.9) | 3000 | Gitea |
| cahute (.9) | 3001 | Uptime-Kuma |
| cahute (.9) | 3002 | Homepage |
| cahute (.9) | 8888 | Dozzle |
| cahute (.9) | 17170 | LLDAP (interface web) |
| homeassistant (.67) | 8123 | Home Assistant |
| routeur (.90) | 53 | DNS local |
Internet reste accessible au conteneur. Il en a besoin pour ACME et pour joindre le DNS de secours 1.1.1.1.
Trois conséquences sont à connaître :
- Un
reverse_proxyajouté au Caddyfile vers une adresse ou un port absent de ce tableau ne fonctionnera pas. Rien ne le signalera : la connexion part en timeout et le client reçoit un 502. Il faut d'abord ajouter la règle correspondante dansafter.rules. - Les règles filtrent par IP, pas par nom. Home Assistant doit donc garder l'adresse
.67, ce qui suppose une réservation DHCP sur le routeur. - La source
172.18.0.0/16est le sous-réseau du bridge Docker. Il est figé dans le compose (voir §4), sans quoi il pourrait changer et rendre ces règles inopérantes.
# Recharger après une modification du bloc sudo ufw reload # Afficher la chaîne telle qu'elle est chargée sudo iptables -S DOCKER-USER # Vérifier que le blocage fonctionne : Cockpit (port 9090) n'est pas autorisé, # la commande doit échouer au bout de 3 secondes docker exec proxy-caddy-1 wget -T 3 -qO- http://192.168.1.9:9090 ; echo "code=$?"
Fail2ban
Fail2ban surveille les tentatives de connexion SSH. Une adresse qui échoue cinq fois en dix minutes est bannie pendant une heure. Le réglage se trouve dans /etc/fail2ban/jail.local :
[DEFAULT] bantime = 1h findtime = 10m maxretry = 5 [sshd] enabled = true
SSH durci
La connexion SSH se fait uniquement par clé (de type ed25519). Les mots de passe sont refusés (PasswordAuthentication no) et le compte root ne peut pas se connecter directement (PermitRootLogin no).
La clé a été copiée sur le NUC avec ssh-copy-id avant de désactiver les mots de passe. Dans l'ordre inverse, on se serait enfermé dehors.
Mises à jour automatiques
Le paquet unattended-upgrades installe tout seul les correctifs de sécurité de Debian.
4. Stack proxy
Docker
Le NUC utilise Docker 29 et Compose v5, installés avec le script officiel get.docker.com. Tout ce qui concerne le proxy est rangé dans /opt/proxy/.
Caddy
Caddy fait office de reverse proxy et gère le TLS automatiquement : il obtient et renouvelle seul les certificats Let's Encrypt. Il remplace à lui seul Apache et Certbot.
Le conteneur est décrit dans /opt/proxy/docker-compose.yml :
services: caddy: image: caddy:alpine restart: unless-stopped dns: - 192.168.1.90 # DNS local OpenWrt : résout les noms internes (cahute, etc.) - 1.1.1.1 # secours public ports: - "80:80" - "443:443" - "443:443/udp" volumes: - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config volumes: caddy_data: caddy_config: networks: default: ipam: config: - subnet: 172.18.0.0/16 # figé : référencé par les règles DOCKER-USER (§3)
networks, Docker attribue le sous-réseau au moment où il crée le réseau. Un docker compose down suivi d'un up peut lui en donner un autre. Les règles DOCKER-USER du §3 ne correspondraient alors plus à rien, et Caddy retrouverait un accès libre à tout le LAN sans aucun symptôme.
Au début, le résolveur interne de Docker (127.0.0.11) n'avait aucun serveur amont. Les requêtes vers les serveurs ACME renvoyaient SERVFAIL et Caddy n'obtenait pas ses certificats. Le problème a été réglé en forçant dns: dans le compose.
Ensuite, avec un DNS public seul, les backends internes comme cahute.beafrancois.fr ne résolvaient plus, et les vhosts renvoyaient un 502. C'est pourquoi le DNS OpenWrt est placé en premier.
Caddyfile
La configuration des vhosts se trouve dans /opt/proxy/caddy/Caddyfile. Voici le modèle d'un vhost, dans le cas le plus courant d'un proxy HTTPS vers HTTPS en direction de cahute :
blog.beafrancois.fr {
redir / /blog/ 301
reverse_proxy https://cahute.beafrancois.fr {
header_up Host blog.beafrancois.fr
transport http {
tls_insecure_skip_verify
}
}
header Strict-Transport-Security "max-age=15768000; includeSubDomains; preload"
}
header_up Host transmet au backend l'en-tête Host demandé par le client. Sans elle, Caddy envoie le Host de l'upstream (cahute), et le backend génère des URL et des redirections vers ce nom, qui ne résout pas depuis l'extérieur. C'est l'équivalent du ProxyPreserveHost On d'Apache.
La directive tls_insecure_skip_verify désactive la vérification du certificat du backend, parce que celui de cahute est auto-signé. C'est l'équivalent du SSLProxyVerify none d'Apache.
Vhosts migrés
| Domaine | Backend | Particularité |
|---|---|---|
| blog | cahute/blog | redirection de / vers /blog/ |
| dav | cahute/dav/html/ | redirection de / vers /dav/html/ |
| git | cahute:3000 | — |
| musique | cahute/musique/ | redirection de / vers /musique/ |
| nuage | cahute/nextcloud | redirections .well-known (carddav, caldav, webfinger, nodeinfo) |
| wiki | cahute/dokuwiki/ | redirection de / vers /dokuwiki/ |
| service | homeassistant:8123, en HTTP | WebSocket géré nativement par Caddy |
Le vhost shell.beafrancois.fr n'a pas été repris, car il n'a plus d'enregistrement DNS (NXDOMAIN).
CADDY EGRESS (§3) ne fonctionnera pas tant que la ligne correspondante n'aura pas été ajoutée dans /etc/ufw/after.rules.
Limite de taille des logs
Les logs des conteneurs sont limités à cinq fichiers de 50 Mo chacun, pour ne pas remplir le disque. Le réglage est dans /etc/docker/daemon.json :
{
"log-driver": "json-file",
"log-opts": { "max-size": "50m", "max-file": "5" }
}
Le journal systemd est limité à 2 Go par la ligne SystemMaxUse=2G dans /etc/systemd/journald.conf.
5. Mise à jour de l'image Caddy
La mise à jour de Caddy se fait à la main, sur le proxy. Aucun outil ne met à jour le conteneur automatiquement : si une nouvelle version casse quelque chose, il faut que ce soit au moment où on l'a décidé, pas par surprise.
cd /opt/proxy # 1. Sauvegarder la configuration avant de toucher à quoi que ce soit sudo cp -a /opt/proxy /root/proxy-$(date +%F) # 2. Noter la version en cours, pour pouvoir y revenir docker compose exec caddy caddy version # 3. Télécharger la nouvelle image docker compose pull # 4. Recréer le conteneur avec cette image docker compose up -d # 5. Contrôler les logs, puis chaque vhost docker compose logs --tail 40 caddy | grep -Ei "obtained|error|panic" for h in blog dav git musique nuage wiki service; do echo -n "$h : "; curl -sI -o /dev/null -w '%{http_code}\n' https://$h.beafrancois.fr done
Rollback. Dans le compose, on remplace caddy:alpine par le tag exact de l'ancienne version (par exemple image: caddy:2.x.y-alpine), puis on relance docker compose up -d. Les certificats sont conservés dans le volume caddy_data, donc le rollback est immédiat.
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
alpine suit toujours la dernière version publiée. Un docker compose pull peut donc installer une version majeure sans prévenir. Le rollback exige de connaître la version qui tournait avant : c'est la raison de l'étape 2.
6. Procédure de bascule
Cette procédure est celle qui a servi à passer du Raspberry Pi au NUC. À chaque étape, il restait possible de revenir à l'ancien proxy.
- La veille, abaisser le TTL DNS à 60 secondes, pour qu'un changement se propage rapidement.
- Démarrer Caddy sur le NUC. Les challenges ACME échouent tant qu'il ne reçoit pas le trafic, c'est normal.
- Sur OpenWrt, basculer la réservation DHCP vers le NUC, ou pointer vers lui la redirection de ports.
- Vérifier que les certificats sont obtenus :
docker compose logs -f caddy | grep -E "obtained|error"
- Tester chaque vhost avec
curl -sI. - Pour un rollback, remettre l'ancienne réservation ou l'ancienne redirection sur le routeur : le Raspberry Pi reprend aussitôt la main.
- Après 48 heures sans incident, éteindre le Raspberry Pi.
7. Commandes utiles
# Prendre en compte une modification du Caddyfile, sans couper les connexions en cours docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile # Même chose en redémarrant le conteneur (coupe les connexions en cours) docker compose restart caddy # Vérifier la syntaxe du Caddyfile sans l'appliquer docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile # Suivre les logs en direct docker compose logs -f caddy # État du conteneur docker compose ps # Chaîne DOCKER-USER telle qu'elle est chargée sudo iptables -S DOCKER-USER # Place disponible sur le disque et dans les volumes df -h && vgs && lvs
Ce qui reste à faire
- Épingler le tag Caddy sur une version exacte plutôt que
alpine, pour maîtriser les mises à jour de Caddy. - Mettre en place la surveillance prévue (Grafana, Prometheus, Loki, cAdvisor), qui n'est pas encore déployée.
- Sauvegarder le volume
caddy_data, qui contient les certificats, par exemple avec restic. - Éventuellement, installer logwatch et rkhunter.
- Recréer l'enregistrement DNS
shellsi ce service doit revenir.
