Docker Compose : publier une application sans exposer PostgreSQL à Internet
Un déploiement Docker peut fonctionner parfaitement tout en exposant beaucoup trop de choses. Le cas classique : l’application répond bien derrière Caddy, mais PostgreSQL écoute aussi sur toutes les interfaces du serveur parce qu’un simple ports: - "5432:5432" a été laissé dans le fichier Compose.
L’objectif est simple : Internet doit atteindre Caddy sur les ports 80 et 443. Caddy doit atteindre l’application. L’application doit atteindre PostgreSQL. En revanche, un visiteur extérieur ne doit jamais pouvoir contacter directement la base de données.
« Exposé » et « publié » ne veulent pas dire la même chose
Dans Docker Compose, les services d’un même réseau peuvent communiquer entre eux avec leur nom de service. Si la base s’appelle db, l’application peut utiliser db:5432. Elle n’a pas besoin de connaître l’adresse IP du conteneur, qui peut changer après un redémarrage.
La section ports répond à un autre besoin : elle crée une correspondance entre un port du conteneur et un port de l’hôte. Sans adresse précise, Docker publie ce port sur toutes les interfaces par défaut. C’est pratique pour un service web public, beaucoup moins pour une base de données.
ports:
- "5432:5432"
Cette configuration ne signifie pas « autoriser seulement les autres conteneurs ». Elle signifie « publier PostgreSQL sur le port 5432 de l’hôte ». Selon le routage et les règles réseau, le service peut alors devenir joignable depuis l’extérieur.
Pour une base utilisée uniquement par l’application, la bonne solution est généralement plus courte : ne pas déclarer de port publié.
Architecture recommandée : deux réseaux, un seul point d’entrée
Une séparation simple peut utiliser deux réseaux Docker :
- frontend relie Caddy à l’application ;
- backend relie l’application à PostgreSQL ;
- Caddy n’est pas membre du réseau backend ;
- PostgreSQL n’est pas membre du réseau frontend ;
- seul Caddy publie des ports vers l’hôte.
L’application joue le rôle de pont logique : elle reçoit les requêtes HTTP autorisées depuis Caddy et dialogue avec la base selon ses propres règles. Cela ne remplace pas l’authentification ni les mises à jour, mais réduit nettement la surface directement accessible.
Exemple Compose avec Caddy conteneurisé
Voici le squelette réseau d’un déploiement. Les images, volumes, variables et secrets doivent être adaptés à l’application réelle ; ils sont volontairement simplifiés ici.
services:
caddy:
image: caddy:2
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- frontend
restart: unless-stopped
app:
image: exemple/mon-application:1.0
environment:
DATABASE_HOST: db
DATABASE_PORT: "5432"
networks:
- frontend
- backend
restart: unless-stopped
db:
image: postgres:18
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- backend
restart: unless-stopped
networks:
frontend:
backend:
internal: true
volumes:
caddy_data:
caddy_config:
postgres_data:
Trois détails comptent :
dbne contient aucune sectionports;- le réseau
backendest déclaréinternal: true; - Caddy et PostgreSQL ne partagent aucun réseau.
L’application peut résoudre le nom db grâce au DNS interne de Docker. Caddy peut résoudre le nom app parce qu’ils partagent le réseau frontend.
Le Caddyfile correspondant
app.exemple.fr {
reverse_proxy app:3000
}
Le port 3000 doit être celui sur lequel l’application écoute à l’intérieur de son conteneur. Il n’a pas besoin d’être publié sur l’hôte, puisque Caddy contacte directement app:3000 sur le réseau Docker.
Avec un vrai nom de domaine pointant vers le serveur et les ports nécessaires accessibles, Caddy peut gérer automatiquement HTTPS. La configuration DNS et les règles réseau restent toutefois à vérifier séparément.
Le piège de localhost dans un conteneur
Dans le conteneur Caddy, localhost désigne le conteneur Caddy lui-même. Cette configuration est donc généralement fausse si l’application tourne dans un autre conteneur :
reverse_proxy localhost:3000
Caddy cherchera le port 3000 chez lui et retournera souvent une erreur 502. Dans un même réseau Compose, utilisez le nom du service :
reverse_proxy app:3000
La même logique s’applique à l’application : localhost:5432 vise le conteneur de l’application, pas PostgreSQL. La cible correcte est normalement db:5432.
Si Caddy tourne directement sur l’hôte
Le schéma change légèrement. Caddy installé sur le serveur ne bénéficie pas automatiquement du DNS interne du projet Compose. Il faut publier le port de l’application vers l’hôte, mais uniquement sur l’adresse locale :
services:
app:
image: exemple/mon-application:1.0
ports:
- "127.0.0.1:3000:3000"
networks:
- backend
Le Caddyfile peut alors cibler :
app.exemple.fr {
reverse_proxy 127.0.0.1:3000
}
L’adresse 127.0.0.1 limite l’accès au serveur lui-même. Une publication écrite seulement "3000:3000" écoute par défaut sur toutes les interfaces et peut rendre l’application directement accessible, en contournant Caddy.
Ne comptez pas uniquement sur UFW
Docker crée ses propres règles de filtrage et de traduction réseau pour les ports publiés. Une règle UFW qui semble fermer un port ne doit donc pas servir de seule preuve que ce port est inaccessible. La première protection consiste à ne pas publier ce qui n’a pas besoin de l’être, puis à vérifier les règles effectives du serveur.
Pour PostgreSQL, l’absence de section ports est plus fiable qu’un port publié puis bloqué après coup.
Et les mots de passe ?
Un réseau privé n’autorise pas à négliger les identifiants. PostgreSQL doit conserver une authentification robuste, et les secrets ne devraient pas être écrits directement dans le fichier Compose versionné.
Docker Compose propose un mécanisme secrets qui monte les valeurs autorisées sous forme de fichiers dans les services concernés. Plusieurs images officielles, dont PostgreSQL, prennent en charge la convention des variables terminées par _FILE. L’application doit toutefois savoir lire le secret de cette manière, ou recevoir une adaptation prévue pour cela.
Un fichier .env évite de répéter certaines valeurs dans le YAML, mais il ne devient pas secret par magie. Il faut contrôler ses permissions, l’exclure du dépôt Git et comprendre où les valeurs apparaissent une fois injectées.
Vérifications après déploiement
Une configuration n’est pas validée parce que docker compose up -d ne renvoie pas d’erreur. Contrôlez le résultat :
docker compose config
docker compose ps
docker compose logs --tail=100 caddy app db
docker network ls
ss -lntup
La sortie de docker compose ps doit montrer les ports 80 et 443 pour Caddy. PostgreSQL ne doit pas afficher de correspondance du type 0.0.0.0:5432->5432/tcp. Si Caddy tourne sur l’hôte, le port applicatif doit apparaître sur 127.0.0.1, pas sur 0.0.0.0.
Testez ensuite depuis l’extérieur du serveur, idéalement depuis une autre connexion. Le domaine doit répondre en HTTPS, tandis que les ports internes ne doivent pas être joignables publiquement.
Diagnostic rapide d’une erreur 502
- Vérifiez que le conteneur de l’application est en cours d’exécution.
- Confirmez le port sur lequel l’application écoute réellement.
- Vérifiez que Caddy et l’application partagent le même réseau.
- Remplacez
localhostpar le nom du service si Caddy est conteneurisé. - Contrôlez les journaux de Caddy et de l’application.
- Vérifiez que l’application écoute sur une adresse accessible depuis le réseau du conteneur, et pas uniquement sur sa propre boucle locale.
En conclusion
La règle est facile à retenir : publiez le point d’entrée, pas toute la pile. Caddy reçoit le trafic public, l’application reste derrière lui, et PostgreSQL reste sur son réseau interne sans port publié.
Cette architecture n’élimine pas tous les risques, mais elle évite une exposition inutile et rend le chemin réseau beaucoup plus lisible au moment de diagnostiquer une panne.