Chapitre 925 minIntermédiaire
Déployer une application complète derrière un reverse proxy HTTPS
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- concevoir une application de plusieurs conteneurs avec un seul point d'entrée ;
- configurer Caddy en HTTPS avec un certificat interne ou Let's Encrypt ;
- passer les mots de passe par des secrets Compose ;
- mettre à jour l'application et revenir en arrière en une minute.
4 exercices corrigés1 schéma
Déployer une application complète avec Docker, c'est faire tourner ensemble son front, son API et sa base de données, chacun dans son conteneur, et ne publier qu'un reverse proxy qui reçoit les connexions HTTPS, présente le certificat et transmet chaque requête au bon conteneur. Ce chapitre déploie sorbier-frais sur srv-gestion avec Compose et le reverse proxy Caddy : réseaux séparés, secrets, certificat de l'autorité interne, puis variante avec un certificat Let's Encrypt pour un site public.
Le rôle du reverse proxy
Un reverse proxy est un serveur placé devant les applications : les clients ne parlent qu'à lui, et lui relaie chaque requête vers le service interne qui convient, selon le nom demandé ou le chemin de l'URL. Il centralise ce que chaque application n'a pas à refaire :
- HTTPS : il détient le certificat, chiffre les échanges, redirige HTTP vers HTTPS ;
- le routage :
/api/vers l'API, le reste vers le front ; ou un nom par application sur la même adresse IP ; - les en-têtes de sécurité et la compression ;
- les journaux d'accès, au même format pour toutes les applications.
Les applications restent ainsi sur des réseaux internes, sans aucun port publié.
| Reverse proxy | Points forts | À savoir |
|---|---|---|
| Caddy | HTTPS automatique (Let's Encrypt), configuration très courte | Le choix de ce chapitre |
| Nginx | Le plus répandu, très performant, documentation abondante | Certificats à gérer avec un outil à part (Certbot) ; exemple dans le cours Node.js |
| Traefik | Se configure par des étiquettes posées sur les conteneurs | Lit l'API de Docker : attention à l'accès au socket (chapitre 11) |
| HAProxy | Répartition de charge très fine, haute disponibilité | Plus bas niveau ; voir le cours Haute disponibilité |
L'architecture cible
sorbier-frais comprend quatre conteneurs :
| Service | Image | Rôle | Réseaux |
|---|---|---|---|
proxy | caddy:2.11 | HTTPS, routage, journaux d'accès ; seul à publier des ports (80 et 443) | frontal |
front | ghcr.io/sorbier/sorbier-frais-front | Les fichiers statiques de l'interface, servis par Nginx sur 8080 (chapitre 5) | frontal |
api | ghcr.io/sorbier/sorbier-frais | L'API Express sur 3000 ; ses routes (/health, /notes…) sont publiées sous /api/ | frontal et donnees |
bdd | mysql:8.4 | La base frais, dans un volume | donnees (interne) |
Les salariés ouvrent https://gestion.ad.sorbier.example, un nom enregistré dans le DNS de l'annuaire et qui pointe vers srv-gestion :
Add-DnsServerResourceRecordA -ZoneName "ad.sorbier.example" -Name "gestion" -IPv4Address "192.168.10.179"
Préparer le dossier sur le serveur
Tout le déploiement tient dans un dossier, /srv/gestion, dont seul compose.yaml et Caddyfile sont versionnés dans Git :
/srv/gestion/
├── compose.yaml
├── Caddyfile
├── .env # la version à déployer
├── certs/
│ ├── gestion.crt # certificat signé par l'autorité interne
│ └── gestion.key # sa clé privée
└── secrets/
├── db_password.txt
└── db_root_password.txt
Génère les deux mots de passe de base de données, aléatoires, sans retour à la ligne final :
sudo mkdir -p /srv/gestion/secrets /srv/gestion/certs
cd /srv/gestion
openssl rand -base64 32 | tr -d '\n' | sudo tee secrets/db_password.txt > /dev/null
openssl rand -base64 32 | tr -d '\n' | sudo tee secrets/db_root_password.txt > /dev/null
sudo chown root:1000 secrets/db_password.txt
sudo chmod 640 secrets/db_password.txt
sudo chmod 600 secrets/db_root_password.txt
Compose monte chaque secret dans le conteneur sous /run/secrets/<nom>, avec les droits du fichier de l'hôte. Le mot de passe de l'application est lu par l'API, qui tourne sous l'UID 1000 (l'utilisateur node), d'où le groupe 1000 et le mode 640. Celui de root n'est lu que par le script de démarrage de MySQL, exécuté en root : mode 600.
Le certificat. Les postes de Sorbier font confiance à l'autorité de certification d'entreprise (AD CS), déployée par stratégie de groupe (cours Windows Server). On lui fait signer un certificat pour gestion.ad.sorbier.example. Sur srv-gestion, crée la clé et la demande de certificat :
sudo openssl req -new -newkey rsa:3072 -nodes \
-keyout certs/gestion.key -out gestion.csr \
-subj "/CN=gestion.ad.sorbier.example" \
-addext "subjectAltName=DNS:gestion.ad.sorbier.example"
sudo chmod 600 certs/gestion.key
Le fichier gestion.csr est soumis à l'autorité avec le modèle « Serveur Web » ; le certificat obtenu (avec la chaîne de l'autorité, au format PEM) est enregistré dans certs/gestion.crt. Les navigateurs exigent que le nom figure dans l'extension subjectAltName : le seul champ CN ne suffit plus. Les notions de certificat et de chaîne de confiance sont dans le cours Cryptographie.
Le Caddyfile
gestion.ad.sorbier.example {
tls /certs/gestion.crt /certs/gestion.key
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
handle_path /api/* {
reverse_proxy api:3000
}
handle {
reverse_proxy front:8080
}
log {
output stdout
format json
}
}
| Directive | Rôle |
|---|---|
gestion.ad.sorbier.example { … } | Un site, désigné par son nom ; Caddy écoute sur 443 et redirige le port 80 vers HTTPS (code 308) |
tls … … | Utilise ce certificat et cette clé, au lieu d'en demander un automatiquement |
encode zstd gzip | Compresse les réponses |
header | Ajoute les en-têtes de sécurité (HSTS : le navigateur n'utilisera plus que HTTPS pour ce nom pendant un an) et retire l'en-tête Server |
handle_path /api/* | Les requêtes dont le chemin commence par /api/ vont vers le service api, port 3000, par son nom sur le réseau frontal ; le préfixe est retiré, /api/health arrive à l'API comme /health |
handle (sans chemin) | Tout le reste va vers le front |
log | Journal d'accès en JSON sur la sortie standard, donc lisible par docker compose logs proxy |
Caddy ajoute de lui-même les en-têtes X-Forwarded-For et X-Forwarded-Proto : l'API connaît ainsi l'adresse réelle du client et sait que la requête est arrivée en HTTPS, à condition de faire confiance au proxy (le réglage trust proxy d'Express, activé ici par la variable TRUST_PROXY).
Le fichier compose.yaml
name: gestion
services:
proxy:
image: caddy:2.11
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./certs:/certs:ro
- caddy-donnees:/data
- caddy-config:/config
networks:
- frontal
depends_on:
- front
- api
restart: unless-stopped
front:
image: ghcr.io/sorbier/sorbier-frais-front:${VERSION:?définis VERSION dans .env}
networks:
- frontal
restart: unless-stopped
api:
image: ghcr.io/sorbier/sorbier-frais:${VERSION:?définis VERSION dans .env}
environment:
NODE_ENV: production
TRUST_PROXY: "1"
DB_HOST: bdd
DB_NAME: frais
DB_USER: frais
DB_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
networks:
- frontal
- donnees
depends_on:
bdd:
condition: service_healthy
read_only: true
tmpfs:
- /tmp
restart: unless-stopped
bdd:
image: mysql:8.4
environment:
MYSQL_DATABASE: frais
MYSQL_USER: frais
MYSQL_PASSWORD_FILE: /run/secrets/db_password
MYSQL_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
secrets:
- db_password
- db_root_password
volumes:
- mysql-donnees:/var/lib/mysql
networks:
- donnees
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
networks:
frontal:
donnees:
internal: true
volumes:
mysql-donnees:
caddy-donnees:
caddy-config:
secrets:
db_password:
file: ./secrets/db_password.txt
db_root_password:
file: ./secrets/db_root_password.txt
Et le fichier .env, qui ne contient plus aucun secret, seulement la version à déployer :
VERSION=2.4.0
Les points qui font la différence avec le fichier de développement du chapitre 8 :
- un seul service publie des ports, le proxy ; l'API n'a plus de
ports:; - deux réseaux :
donneesest interne, la base n'est joignable que par l'API (chapitre 7) ; - les mots de passe sont des secrets, montés en fichiers : ils n'apparaissent ni dans
docker inspect, ni dans l'environnement des processus. L'image MySQL sait lire les variables terminées par_FILE; l'API aussi, grâce à quelques lignes de code :
import { readFileSync } from 'node:fs';
// Le mot de passe vient d'un fichier (secret Docker) ou, à défaut, d'une variable.
export const dbPassword = process.env.DB_PASSWORD_FILE
? readFileSync(process.env.DB_PASSWORD_FILE, 'utf8').trim()
: process.env.DB_PASSWORD;
- l'API tourne en lecture seule (
read_only: true), avec un/tmpen mémoire : même compromise, elle ne peut pas modifier ses propres fichiers ; - les images sont celles construites et analysées par la CI, désignées par leur version (chapitre 12) ;
- le volume
caddy-donneesconserve les certificats que Caddy obtient lui-même (variante Let's Encrypt plus bas) ; le port UDP 443 sert à HTTP/3.
Démarrer et vérifier
cd /srv/gestion
sudo docker compose config --quiet && echo "fichier valide"
sudo docker compose pull
sudo docker compose up -d
sudo docker compose ps
NAME IMAGE SERVICE STATUS PORTS
gestion-api-1 ghcr.io/sorbier/sorbier-frais:2.4.0 api Up 40 seconds (healthy) 3000/tcp
gestion-bdd-1 mysql:8.4 bdd Up 52 seconds (healthy) 3306/tcp, 33060/tcp
gestion-front-1 ghcr.io/sorbier/sorbier-frais-front:2.4.0 front Up 52 seconds (healthy) 8080/tcp
gestion-proxy-1 caddy:2.11 proxy Up 40 seconds 0.0.0.0:80->80/tcp, [::]:80->80/tcp, 0.0.0.0:443->443/tcp, [::]:443->443/tcp, 0.0.0.0:443->443/udp, [::]:443->443/udp
Seul le proxy a des ports publiés (0.0.0.0:…) ; les autres n'affichent que leurs ports internes. Depuis un poste du VLAN 10 :
curl -I http://gestion.ad.sorbier.example
curl -I https://gestion.ad.sorbier.example
curl https://gestion.ad.sorbier.example/api/health
HTTP/1.1 308 Permanent Redirect
Location: https://gestion.ad.sorbier.example/
HTTP/2 200
strict-transport-security: max-age=31536000
x-content-type-options: nosniff
{"status":"ok"}
En cas de souci, lis d'abord les journaux du service concerné : sudo docker compose logs --tail 50 proxy, puis api.
| Symptôme | Cause probable | Vérification |
|---|---|---|
502 Bad Gateway renvoyé par Caddy | Le service visé ne répond pas, ou le nom ou le port est faux dans le Caddyfile | docker compose ps, docker compose logs api |
| Avertissement de certificat dans le navigateur | Le nom tapé n'est pas dans le certificat, ou le poste ne fait pas confiance à l'autorité | openssl x509 -in certs/gestion.crt -noout -ext subjectAltName |
L'API journalise Access denied for user 'frais' | Le volume existait avec un autre mot de passe : l'initialisation n'a pas eu lieu | Changer le mot de passe dans MySQL, ou recréer le volume en développement |
L'API journalise EACCES sur /run/secrets/db_password | Le fichier de secret n'est pas lisible par l'UID 1000 | ls -ln secrets/ |
| Le site ne répond pas du tout depuis les postes | Le pare-feu ne laisse pas passer TCP 443 vers srv-gestion | curl -I depuis srv-gestion lui-même, puis les journaux du pfSense |
Mettre à jour et revenir en arrière
Une nouvelle version de sorbier-frais est publiée par la CI. Sur srv-gestion :
cd /srv/gestion
sudo sed -i 's/^VERSION=.*/VERSION=2.5.0/' .env
sudo docker compose pull api front
sudo docker compose up -d
Compose ne recrée que api et front, dont l'image a changé ; la base et le proxy ne sont pas touchés. Si la nouvelle version pose problème, on remet VERSION=2.4.0 et on relance docker compose up -d : l'ancienne image est encore sur le serveur et dans le registre. Ce retour arrière en une minute est l'un des grands intérêts des conteneurs. Il ne vaut que si la nouvelle version n'a pas modifié la base de façon incompatible : d'où l'export de la base avant chaque mise à jour (chapitre 6).
Variante : un site public avec Let's Encrypt
Le site public de suivi des livraisons tourne sur srv-web (192.168.10.225), dans la DMZ, publié sur Internet par l'adresse 203.0.113.17 (redirection des ports 80 et 443 sur le pfSense). Pour un nom public, Caddy obtient et renouvelle seul un certificat Let's Encrypt : il suffit de ne pas écrire de directive tls.
suivi.sorbier.example {
reverse_proxy app:8080
}
Les conditions, toutes nécessaires :
- le nom
suivi.sorbier.exampleest déclaré dans le DNS public et pointe vers203.0.113.17; - les ports 80 et 443 de l'adresse publique arrivent bien jusqu'au conteneur Caddy (redirection et règles du pare-feu), pour que Let's Encrypt puisse valider le nom ;
- le volume
/datade Caddy est conservé : sinon, chaque recréation redemanderait un certificat, et Let's Encrypt limite le nombre de certificats délivrés pour un même nom.
Exercices
Exercice 1 sur 4 Ajouter une application Réussi À revoir
Sorbier veut publier sur srv-gestion un wiki interne, image ghcr.io/sorbier/wiki:1.4, qui écoute sur le port 80 de son conteneur, à l'adresse https://wiki.ad.sorbier.example. Le certificat est fourni dans certs/wiki.crt et certs/wiki.key. Écris l'ajout au Caddyfile, le service à ajouter dans compose.yaml, et dis ce qu'il faut faire côté DNS.
Voir la réponseMasquer la réponse de l'exercice 1
Dans le Caddyfile, un second site :
wiki.ad.sorbier.example {
tls /certs/wiki.crt /certs/wiki.key
reverse_proxy wiki:80
}
Dans compose.yaml, sous services: :
wiki:
image: ghcr.io/sorbier/wiki:1.4
networks:
- frontal
volumes:
- wiki-pages:/var/www/html/data
restart: unless-stopped
et wiki-pages: sous la clé volumes: du bas. Aucun port publié : le proxy joint le wiki par son nom sur le réseau frontal.
Côté DNS : un enregistrement A wiki dans la zone ad.sorbier.example vers 192.168.10.179 (ou un alias CNAME vers gestion). Les deux sites partagent la même adresse IP et le même port 443 : Caddy les distingue par le nom demandé (SNI pour TLS, en-tête Host pour HTTP). Puis docker compose up -d, qui crée le conteneur wiki. Le proxy, lui, n'est pas recréé : Compose compare la définition du service et l'image, pas le contenu d'un fichier monté. Recharge Caddy : docker compose exec -w /etc/caddy proxy caddy reload (ou docker compose restart proxy).
Tu avais trouvé ?
Exercice 2 sur 4 Repérer les failles d'un déploiement Réussi À revoir
Voici un extrait du fichier d'un autre service de Sorbier. Relève au moins quatre problèmes de sécurité et propose une correction pour chacun.
services:
app:
image: ghcr.io/sorbier/planning:latest
ports:
- "80:3000"
environment:
DB_PASSWORD: Planning2026
bdd:
image: mysql:8.4
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: Planning2026
Voir la réponseMasquer la réponse de l'exercice 2
| Problème | Risque | Correction |
|---|---|---|
latest | Version imprévisible, retour arrière impossible | Une étiquette fixe (2.1.0) produite par la CI |
| Application publiée en HTTP sur 80 | Identifiants et données en clair sur le réseau | Derrière le reverse proxy en HTTPS, sans port publié |
Base publiée sur 3306 | Joignable par tout le réseau, cible directe des tentatives de connexion | Aucun port publié, réseau interne partagé avec la seule application |
| Mots de passe en clair dans le fichier | Visibles dans Git, docker inspect, docker compose config | Secrets montés en fichiers (MYSQL_ROOT_PASSWORD_FILE) |
Même mot de passe pour l'application et root | Une fuite donne tous les droits | Un compte dédié (MYSQL_USER) aux droits limités, un mot de passe distinct pour root |
| Pas de volume pour la base | Données perdues à la recréation | Volume nommé sur /var/lib/mysql |
Tu avais trouvé ?
Exercice 3 sur 4 Le 502 Réussi À revoir
Après une mise à jour, les salariés obtiennent une page blanche et curl -I https://gestion.ad.sorbier.example/api/health renvoie HTTP/2 502. docker compose ps montre gestion-api-1 avec l'état Restarting (1) 5 seconds ago. Décris ta démarche de diagnostic et la façon de rétablir le service au plus vite.
Voir la réponseMasquer la réponse de l'exercice 3
Le 502 vient de Caddy : il n'arrive pas à joindre l'API, qui redémarre en boucle (code de sortie 1, donc une erreur de l'application).
- Lire la cause :
sudo docker compose logs --tail 50 api. On y trouve par exemple une variable manquante, une erreur de connexion à la base ou une migration attendue. - Rétablir d'abord si la cause n'est pas évidente : remettre la version précédente dans
.env(VERSION=2.4.0) etsudo docker compose up -d. Le service revient en une minute. - Corriger ensuite à froid, en recette, puis redéployer. Si la nouvelle version avait déjà modifié la base, le retour arrière demande aussi de restaurer l'export fait avant la mise à jour.
Le tout se trace dans un ticket d'incident (cours ITIL et support).
Tu avais trouvé ?
Exercice 4 sur 4 HTTP vers HTTPS Réussi À revoir
Explique pourquoi le compose.yaml publie aussi le port 80 alors que l'application n'est servie qu'en HTTPS, et ce qui se passerait si on le retirait.
Voir la réponseMasquer la réponse de l'exercice 4
Caddy écoute sur le port 80 pour rediriger vers HTTPS (réponse 308 vers https://…) : un salarié qui tape gestion.ad.sorbier.example sans préciser le protocole, ou un vieux lien en http://, arrive ainsi sur la version chiffrée. Pour un certificat Let's Encrypt, le port 80 sert aussi à la validation du nom (défi HTTP).
Sans le port 80, ces visiteurs obtiendraient une erreur de connexion au lieu d'être redirigés. Aucune donnée ne passe en clair sur le port 80 : seule la redirection y est servie, et l'en-tête HSTS fait ensuite aller le navigateur directement en HTTPS.
Tu avais trouvé ?