L’API métier d’un partenaire (éditique, documents, référentiels) est sur un réseau privé, accessible uniquement depuis le serveur de dev qui, lui, a la route VPN vers ce backend interne. Notre stack applicative, elle, tourne en Docker local sur nos postes.
Pendant deux semaines, deux mauvaises options se sont disputées :
- Mocker l’API ? Faux par construction — on ne teste plus l’intégration réelle, juste notre idée de l’API.
- Pousser sur le serveur de dev à chaque test ? Trop lent — boucle de feedback de plusieurs minutes par itération.
La bonne option : un tunnel SSH depuis notre poste vers le serveur de dev, qui relaie vers l’API interne. Sauf qu’un détail a coûté une demi-journée.
Symptôme
Le tunnel « classique » se monte sans broncher :
ssh -L 18080:<ip-api-interne>:8080 user@serveur-dev
Depuis le host, ça marche : curl http://127.0.0.1:18080/__health répond. Victoire… jusqu’à ce qu’on lance le même appel depuis un conteneur :
docker compose exec app curl http://127.0.0.1:18080/__health
# curl: (7) Failed to connect to 127.0.0.1 port 18080: Connection refused
Et même en visant le pont Docker :
docker compose exec app curl http://host.docker.internal:18080/__health
# curl: (7) Connection refused
Le tunnel est bien là, le port écoute… mais le conteneur ne le voit pas.
Cause
Par défaut, ssh -L 18080:... bind le port local sur 127.0.0.1 uniquement (la loopback du host). Or un conteneur Docker a sa propre stack réseau : son 127.0.0.1 à lui, ce n’est pas celui du host. Quand le conteneur tape host.docker.internal, il atteint l’IP du host sur le réseau Docker — qui n’est pas 127.0.0.1. Le tunnel n’écoute pas sur cette interface-là, donc : Connection refused.
Autrement dit : le forward SSH est lié à une interface (127.0.0.1) que les conteneurs ne peuvent pas atteindre. Le port existe, mais pas là où Docker regarde.
Solution
Binder le forward sur 0.0.0.0 (toutes les interfaces du host), pas seulement la loopback :
ssh -L 0.0.0.0:18080:<ip-api-interne>:8080 user@serveur-dev
Maintenant le tunnel écoute aussi sur l’IP que les conteneurs joignent via host.docker.internal. Côté application, on pointe simplement la base URL dessus :
# .env.local
API_BASE_URL=http://host.docker.internal:18080
Et depuis le conteneur, ça passe :
docker compose exec app curl http://host.docker.internal:18080/__health
# {"status":"ok"} ✓
On développe alors en local contre la vraie API, comme si on était sur le serveur de dev — sans installer le VPN du partenaire sur chaque poste, sans maintenir de mock.
Comme ce tunnel se monte/démonte souvent et qu’on oublie toujours un flag, on l’a enrobé dans un wrapper bash versionné dans le repo, avec un garde de sécurité explicite sur le mode 0.0.0.0 :
cmd_up() {
local bind="127.0.0.1"
if [ "${1:-}" = "--docker" ]; then
bind="0.0.0.0"
warn "Mode --docker : bind sur 0.0.0.0 → l'API sera exposée à TOUTE"
warn "machine joignable sur le LAN tant que le tunnel est actif."
warn "→ à n'utiliser que le temps des tests, à fermer ensuite ($0 down)."
fi
ssh -i "$SSH_KEY" -f -N \
-o ExitOnForwardFailure=yes \
-o StrictHostKeyChecking=accept-new \
-o ServerAliveInterval=60 \
-L "${bind}:${LOCAL_PORT}:${REMOTE_HOST}:${REMOTE_PORT}" \
"$SSH_HOST"
# Smoke test après montage : on ne dit "OK" que si /__health répond
sleep 1
curl -fsS "http://127.0.0.1:${LOCAL_PORT}/__health" >/dev/null \
&& ok "Tunnel monté et API joignable." \
|| warn "Tunnel monté mais /__health ne répond pas encore."
}
Trois options SSH valent le détour : -f -N (passe en arrière-plan sans ouvrir de shell), ExitOnForwardFailure=yes (échoue franchement si le port est déjà pris, au lieu d’un tunnel à moitié monté), et ServerAliveInterval=60 (garde la connexion vivante sur un tunnel qui dort entre deux requêtes).
Lessons learned
ssh -Lbind sur127.0.0.1par défaut — invisible des conteneurs Docker. Un conteneur a sa propre loopback ; il ne peut pas atteindre le127.0.0.1du host. Pour exposer un tunnel à des conteneurs (ou d’autres VM locales), binder explicitement sur0.0.0.0:-L 0.0.0.0:port:cible:port.host.docker.internalest le pont host ↔ conteneur : depuis un conteneur, c’est l’IP du host sur le réseau Docker. Couplé à un tunnel SSH bindé sur0.0.0.0, il ouvre l’accès local à n’importe quel service distant joignable depuis l’autre bout du tunnel.0.0.0.0expose le port à tout le LAN tant que le tunnel vit. Ce n’est pas anodin : à réserver au temps des tests, fermer ensuite. Mettez le warning dans l’outil, pas dans la tête du dev.- Ajoutez
ExitOnForwardFailure=yeset un smoke test (curl /__healthaprès montage). Sinon un tunnel « monté » mais non fonctionnel (port déjà pris, route absente) vous fait débugger l’application au lieu du réseau. - Documentez le tunnel dans le repo, pas dans la mémoire d’un dev. Un wrapper
up / status / downversionné, qui auto-détecte la clé et teste la santé, c’est dix minutes gagnées par nouvel arrivant — et zéro flag oublié.
#ssh #docker #devops #networking #tunnel