
Introduction : un serveur fonctionnel n’est pas forcément un serveur maîtrisé
Au fil de la construction de mon Homelab, mon VPS est devenu une vraie petite infrastructure. Il héberge Docker, plusieurs applications web, Nginx Proxy Manager pour les services publics, Dockge pour l’administration des stacks et Tailscale pour les accès privés.
Tout fonctionnait. C’était justement le bon moment pour vérifier une chose plus importante : est-ce que je savais exactement ce qui était exposé sur Internet ?
J’ai donc consacré une session complète à l’audit et à la sécurisation du VPS.
Mon objectif n’était pas d’installer le plus grand nombre possible d’outils de sécurité. Je voulais d’abord comprendre la situation réelle :
- quels ports écoutaient sur le serveur ;
- quels ports étaient publiés par Docker ;
- quels services devaient réellement rester publics ;
- quel rôle jouait UFW ;
- comment protéger SSH ;
- si Fail2Ban fonctionnait correctement ;
- comment déplacer l’administration vers Tailscale ;
- et si Docker occupait inutilement de l’espace.
Cette session m’a surtout rappelé une règle simple : la sécurité n’est pas un produit à installer, mais une architecture à comprendre.
Commencer par observer avant de modifier
Sur un serveur distant, je préfère toujours commencer par le diagnostic.
Une mauvaise règle de pare-feu ou une modification SSH appliquée trop vite peut me couper complètement du VPS. Avant de fermer quoi que ce soit, je vérifie donc l’état du système.
Je commence par les services actifs :
sudo systemctl --type=service --state=running
Je regarde ensuite les ports réellement en écoute :
sudo ss -tulpn
Puis les ports publiés par Docker :
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
Ces trois commandes ne montrent pas exactement la même chose.
systemctl décrit les services gérés par Ubuntu. ss montre les sockets réseau ouvertes sur l’hôte. docker ps permet de repérer les ports publiés par les conteneurs.
Dans un Homelab Docker, cette dernière vérification est essentielle. Une application peut être parfaitement isolée dans son conteneur tout en devenant accessible depuis Internet si son fichier Compose publie un port sur toutes les interfaces.
Distinguer les services publics des services privés
Avant de configurer le pare-feu, j’ai classé les services selon leur rôle.
Les services destinés aux visiteurs doivent être accessibles publiquement. Dans mon architecture, il s’agit principalement du trafic web :
- HTTP sur le port
80; - HTTPS sur le port
443; - les sites et applications publiés derrière Nginx Proxy Manager.
À l’inverse, les outils d’administration n’ont aucune raison d’être visibles par n’importe qui sur Internet :
- Dockge ;
- les interfaces de maintenance ;
- les API internes ;
- les bases de données ;
- les outils de développement ;
- les services techniques utilisés uniquement par moi.
Cette distinction devient ma règle de base :
Si un service n’a aucune raison d’être public, je cherche d’abord à supprimer son exposition plutôt qu’à ajouter une règle complexe pour le protéger.


Vérifier UFW sans lui faire une confiance aveugle
UFW reste un outil pratique sous Ubuntu pour définir une politique simple de filtrage.
Je commence par vérifier son état :
sudo ufw status verbose
Je contrôle également le service :
sudo systemctl status ufw
Et, lorsque le comportement me paraît incohérent, je regarde directement les règles de filtrage du système :
sudo iptables -L -n
sudo nft list ruleset
Lors de cet audit, le comportement d’UFW m’a obligé à ne pas m’arrêter au simple message active ou inactive. Ce qui m’intéresse réellement est le trafic qui passe ou qui ne passe pas.
Pour un VPS web classique, la logique que je veux conserver est simple :
sudo ufw default deny incoming
sudo ufw default allow outgoing
Les connexions entrantes sont refusées par défaut, puis j’autorise uniquement les services réellement nécessaires.
Pour le web :
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
Si SSH doit encore rester temporairement accessible publiquement :
sudo ufw allow 22/tcp
Et pour le trafic provenant de l’interface Tailscale :
sudo ufw allow in on tailscale0
Après modification :
sudo ufw reload
sudo ufw status numbered
Je garde toujours une session SSH ouverte pendant ces changements afin de pouvoir revenir en arrière si une règle bloque une nouvelle connexion.
Le point critique : Docker peut publier un port indépendamment de ce que j’imagine
L’audit m’a surtout obligé à regarder Docker autrement.
Lorsqu’un fichier Compose contient :
ports:
- "5001:5001"
Docker publie généralement le port sur toutes les interfaces de l’hôte.
Je peux alors voir quelque chose comme :
0.0.0.0:5001->5001/tcp
Ce 0.0.0.0 est important : il signifie que le service écoute sur toutes les interfaces disponibles.
Je ne considère donc pas UFW comme l’unique mécanisme de contrôle de l’exposition des conteneurs. La sécurité commence directement dans les fichiers Compose.
Pour chaque stack, je peux vérifier sa configuration finale :
docker compose config
Puis contrôler les ports actifs :
docker ps --format "table {{.Names}}\t{{.Ports}}"
Restreindre un port à la machine locale
Si un service ne doit être accessible que depuis le VPS lui-même, je peux publier le port uniquement sur 127.0.0.1 :
ports:
- "127.0.0.1:5001:5001"
Le service reste joignable localement mais n’est plus publié directement sur l’adresse publique.
Ne publier aucun port quand le reverse proxy suffit
Pour une application web desservie uniquement par Nginx Proxy Manager, une meilleure solution consiste souvent à ne publier aucun port hôte.
Le conteneur rejoint simplement un réseau Docker partagé avec le reverse proxy :
services:
app:
image: mon-image
networks:
- proxy
networks:
proxy:
external: true
Nginx Proxy Manager peut alors joindre l’application par le réseau Docker interne.
Cette approche simplifie énormément l’architecture :
Internet
│
├── 80 / 443
│
▼
Nginx Proxy Manager
│
▼
réseau Docker "proxy"
│
├── site A
├── site B
└── application C
Les applications ne doivent plus chacune posséder leur propre port public.
Utiliser Tailscale comme réseau d’administration
Pour l’administration du VPS, je préfère séparer le trafic privé du trafic destiné aux visiteurs.
Tailscale me permet de créer ce deuxième réseau.
Je vérifie d’abord le service :
sudo systemctl status tailscaled
Puis l’état du réseau :
tailscale status
Je récupère l’adresse Tailscale du VPS :
tailscale ip -4
Et je peux vérifier les caractéristiques de la connexion :
tailscale netcheck
L’objectif est de déplacer progressivement les services d’administration vers ce réseau privé.
Mon architecture cible devient alors :
INTERNET PUBLIC
│
└── 80 / 443
│
▼
Nginx Proxy Manager
│
▼
services web publics
TAILSCALE
│
├── SSH
├── Dockge
├── maintenance
└── services privés
Cette séparation est plus facile à comprendre qu’une longue liste de ports publics protégés par des règles différentes.
Tester Tailscale avant de fermer SSH public
Je ne ferme jamais l’accès SSH public avant d’avoir validé un autre chemin d’administration.
Depuis une deuxième machine connectée à Tailscale, je teste par exemple :
ssh utilisateur@IP_TAILSCALE_DU_VPS
Je garde la session SSH existante ouverte et j’ouvre une deuxième session.
Si la seconde connexion fonctionne correctement via Tailscale, je sais que je dispose d’un chemin de secours avant de réduire l’exposition du port 22.
Cette manière de procéder est moins spectaculaire, mais elle évite le scénario classique : sécuriser son serveur au point de ne plus pouvoir s’y connecter soi-même.
Renforcer SSH avec une clé
Même si SSH doit finalement passer par Tailscale, je veux que son authentification reste solide.
Sur mon poste de travail, je peux générer une clé moderne :
ssh-keygen -t ed25519
Puis copier la clé publique sur le serveur :
ssh-copy-id utilisateur@serveur
Je teste ensuite la connexion dans une nouvelle session.
Lorsque la connexion par clé est confirmée, je peux renforcer la configuration SSH.
Dans /etc/ssh/sshd_config, les paramètres importants peuvent notamment être :
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
Avant de recharger SSH, je vérifie toujours la syntaxe :
sudo sshd -t
Puis :
sudo systemctl reload ssh
Important : je ne désactive jamais l’authentification par mot de passe avant d’avoir prouvé qu’une nouvelle session fonctionne correctement avec la clé.
Ajouter Fail2Ban comme couche supplémentaire
Fail2Ban surveille les journaux du système et peut bannir temporairement les adresses qui multiplient les tentatives de connexion échouées.
Installation :
sudo apt update
sudo apt install fail2ban
Je crée ensuite un fichier de configuration local :
sudo nano /etc/fail2ban/jail.local
Configuration minimale :
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 3
[sshd]
enabled = true port = ssh maxretry = 3
Puis j’active le service :
sudo systemctl enable --now fail2ban
Je vérifie son état :
sudo fail2ban-client status
sudo fail2ban-client status sshd
Au cours de ma session de vérification, Fail2Ban avait déjà enregistré des tentatives de connexion et appliqué un bannissement.
Cela rappelle une réalité assez simple : dès qu’un service SSH est visible sur Internet, les scans automatisés finissent par le trouver.
Fail2Ban reste utile, mais je le considère comme une couche supplémentaire. Le meilleur trafic hostile est celui qui n’atteint jamais le service parce que celui-ci n’est pas exposé publiquement.
Auditer les conteneurs Docker
La sécurité ne concerne pas uniquement le réseau.
Je vérifie aussi les conteneurs actifs :
docker ps
Puis leur consommation de ressources :
docker stats --no-stream
Et l’espace occupé par Docker :
docker system df
Pour davantage de détails :
docker system df -v
Lors de cette session, le cache de build représentait plusieurs gigaoctets. Un nettoyage ciblé permet de récupérer de l’espace sans toucher aux données persistantes.
Je commence de préférence par :
docker builder prune
La commande me demande confirmation avant de supprimer le cache de build inutilisé.
Il existe également :
docker system prune
mais je l’utilise avec davantage de prudence.
Je n’exécute pas automatiquement une commande agressive comme :
docker system prune -a --volumes
sur un VPS de production.
Les volumes peuvent contenir précisément les données que je veux conserver.
Vérifier une deuxième fois les ports réellement exposés
Après les changements, je reprends le diagnostic depuis le début.
Je regarde les ports de l’hôte :
sudo ss -tulpn
Puis ceux de Docker :
docker ps --format "table {{.Names}}\t{{.Ports}}"
Et enfin UFW :
sudo ufw status verbose
Je cherche particulièrement les publications du type :
0.0.0.0:PORT->PORT/tcp
Si je ne sais pas immédiatement pourquoi un port doit être public, je le considère comme un point à vérifier.


La cible que je veux conserver
| Service | Exposition souhaitée |
|---|---|
| Nginx Proxy Manager HTTP | Public |
| Nginx Proxy Manager HTTPS | Public |
| Sites web | Via Nginx Proxy Manager |
| Dockge | Privé |
| SSH | Tailscale ou accès fortement restreint |
| Bases de données | Réseau Docker uniquement |
| API internes | Docker interne ou Tailscale |
| Outils de maintenance | Tailscale |
Ce tableau est volontairement simple. Il m’aide davantage qu’une liste de règles de pare-feu sans contexte.

Penser la sécurité en couches
À la fin de cette session, je ne cherche plus un hypothétique état « serveur sécurisé ».
Je préfère penser en plusieurs couches complémentaires.

Couche 1 — Infrastructure de l’hébergeur
Le VPS bénéficie déjà des mécanismes d’isolation et des protections réseau disponibles chez l’hébergeur.
Je ne considère toutefois jamais cette couche comme une excuse pour négliger la configuration du serveur.
Couche 2 — Ubuntu et UFW
UFW applique une politique simple à l’hôte : refuser par défaut les connexions entrantes et autoriser uniquement ce qui est nécessaire.
Couche 3 — Docker
Les fichiers Compose doivent eux-mêmes limiter les ports publiés.
C’est une couche fondamentale parce que Docker participe directement à la configuration réseau du serveur.
Couche 4 — Nginx Proxy Manager
Nginx Proxy Manager devient le point d’entrée principal des applications web publiques.
Il centralise l’accès HTTP/HTTPS au lieu d’exposer un port différent pour chaque application.
Couche 5 — Tailscale
Le réseau Tailscale transporte les accès administratifs et les services privés.
Couche 6 — SSH
L’authentification par clé réduit fortement le risque lié aux mots de passe.
Couche 7 — Fail2Ban et les journaux
Fail2Ban ajoute une détection simple des tentatives répétées sur les services qui restent accessibles.
Aucune de ces couches n’est parfaite. Ensemble, elles réduisent les conséquences d’une erreur isolée.
Mon script d’audit rapide
Pour éviter d’oublier une vérification, j’ai préparé un petit script.
#!/usr/bin/env bash
set -u
echo "======================================"
echo " AUDIT SECURITE DU VPS"
echo "======================================"
echo
echo "[1] UFW"
sudo ufw status verbose || true
echo
echo "[2] PORTS EN ECOUTE"
sudo ss -tulpn || true
echo
echo "[3] PORTS DOCKER"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" || true
echo
echo "[4] FAIL2BAN"
sudo fail2ban-client status sshd || true
echo
echo "[5] TAILSCALE"
tailscale status || true
echo
echo "[6] ESPACE DOCKER"
docker system df || true
echo
echo "======================================"
echo " FIN AUDIT"
echo "======================================"
Je peux l’enregistrer dans :
sudo nano /usr/local/bin/security-audit.sh
Puis le rendre exécutable :
sudo chmod +x /usr/local/bin/security-audit.sh
Et le lancer :
sudo /usr/local/bin/security-audit.sh
Pour l’instant, je préfère l’exécuter manuellement. L’objectif n’est pas uniquement de produire un rapport, mais de me forcer à lire et à comprendre les résultats.
Ce que cette session m’a appris
Cette journée de vérification m’a surtout appris à ne pas confondre plusieurs notions.
Un conteneur healthy n’est pas nécessairement un conteneur correctement exposé.
Un pare-feu indiqué comme actif ne remplace pas la vérification réelle des ports.
Un service protégé par un mot de passe n’a pas nécessairement besoin d’être accessible depuis Internet.
Et une interface graphique d’administration peut être très pratique sans pour autant devoir être publique.
J’ai également retenu quatre règles simples :
- observer avant de modifier ;
- publier le moins de ports possible ;
- séparer les services publics et l’administration ;
- tester un nouveau chemin d’accès avant de fermer l’ancien.
Conclusion : réduire l’exposition avant d’ajouter de la complexité
Mon VPS n’est pas devenu « invulnérable » après cette session.
En revanche, son architecture est devenue beaucoup plus compréhensible.
Je sais mieux quels services doivent être publics, lesquels doivent rester privés, pourquoi les ports Docker méritent une attention particulière et comment Tailscale peut devenir le réseau d’administration du serveur.
Cette approche me semble plus durable qu’une accumulation de règles.
Pour chaque nouveau service, je peux maintenant me poser les mêmes questions :
- Ce port doit-il vraiment être public ?
- Ce conteneur a-t-il réellement besoin d’un
ports:? - Puis-je le joindre uniquement depuis le réseau Docker ?
- Puis-je l’administrer via Tailscale ?
- SSH fonctionne-t-il correctement par clé ?
- Est-ce que je vérifie la configuration réelle après chaque modification ?
La sécurité de mon Homelab devient ainsi une partie normale de son architecture, et non une opération exceptionnelle réalisée uniquement lorsqu’un problème apparaît.
