Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

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 proxyPoints fortsÀ savoir
CaddyHTTPS automatique (Let's Encrypt), configuration très courteLe choix de ce chapitre
NginxLe plus répandu, très performant, documentation abondanteCertificats à gérer avec un outil à part (Certbot) ; exemple dans le cours Node.js
TraefikSe configure par des étiquettes posées sur les conteneursLit l'API de Docker : attention à l'accès au socket (chapitre 11)
HAProxyRépartition de charge très fine, haute disponibilitéPlus bas niveau ; voir le cours Haute disponibilité

L'architecture cible

sorbier-frais comprend quatre conteneurs :

ServiceImageRôleRéseaux
proxycaddy:2.11HTTPS, routage, journaux d'accès ; seul à publier des ports (80 et 443)frontal
frontghcr.io/sorbier/sorbier-frais-frontLes fichiers statiques de l'interface, servis par Nginx sur 8080 (chapitre 5)frontal
apighcr.io/sorbier/sorbier-fraisL'API Express sur 3000 ; ses routes (/health, /notes…) sont publiées sous /api/frontal et donnees
bddmysql:8.4La base frais, dans un volumedonnees (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 :

powershell
Add-DnsServerResourceRecordA -ZoneName "ad.sorbier.example" -Name "gestion" -IPv4Address "192.168.10.179"
PosteVLAN 10pfSenseTCP 443 permishttps://gestion.ad.sorbier.examplesrv-gestion · 192.168.10.179proxyCaddy, certificatfrontNginx :8080autrescheminsapiNode.js :3000/api/*réseau donnees(interne)bddMySQL :3306volume mysql-donneessecrets/run/secretsréseau frontal :proxy, front, api
sorbier-frais : un seul point d'entrée HTTPS, trois services cachés derrière

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 :

text
/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 :

bash
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 :

bash
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

text
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
	}
}
DirectiveRô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 gzipCompresse les réponses
headerAjoute 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
logJournal 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

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 :

text
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 : donnees est 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 :
js
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 /tmp en 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-donnees conserve 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

bash
cd /srv/gestion
sudo docker compose config --quiet && echo "fichier valide"
sudo docker compose pull
sudo docker compose up -d
sudo docker compose ps
text
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 :

bash
curl -I http://gestion.ad.sorbier.example
curl -I https://gestion.ad.sorbier.example
curl https://gestion.ad.sorbier.example/api/health
text
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ômeCause probableVérification
502 Bad Gateway renvoyé par CaddyLe service visé ne répond pas, ou le nom ou le port est faux dans le Caddyfiledocker compose ps, docker compose logs api
Avertissement de certificat dans le navigateurLe 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 lieuChanger le mot de passe dans MySQL, ou recréer le volume en développement
L'API journalise EACCES sur /run/secrets/db_passwordLe fichier de secret n'est pas lisible par l'UID 1000ls -ln secrets/
Le site ne répond pas du tout depuis les postesLe pare-feu ne laisse pas passer TCP 443 vers srv-gestioncurl -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 :

bash
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.

text
suivi.sorbier.example {
	reverse_proxy app:8080
}

Les conditions, toutes nécessaires :

  • le nom suivi.sorbier.example est déclaré dans le DNS public et pointe vers 203.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 /data de 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 :

text
wiki.ad.sorbier.example {
	tls /certs/wiki.crt /certs/wiki.key
	reverse_proxy wiki:80
}

Dans compose.yaml, sous services: :

yaml
  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.

yaml
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èmeRisqueCorrection
latestVersion imprévisible, retour arrière impossibleUne étiquette fixe (2.1.0) produite par la CI
Application publiée en HTTP sur 80Identifiants et données en clair sur le réseauDerrière le reverse proxy en HTTPS, sans port publié
Base publiée sur 3306Joignable par tout le réseau, cible directe des tentatives de connexionAucun port publié, réseau interne partagé avec la seule application
Mots de passe en clair dans le fichierVisibles dans Git, docker inspect, docker compose configSecrets montés en fichiers (MYSQL_ROOT_PASSWORD_FILE)
Même mot de passe pour l'application et rootUne fuite donne tous les droitsUn compte dédié (MYSQL_USER) aux droits limités, un mot de passe distinct pour root
Pas de volume pour la baseDonnées perdues à la recréationVolume 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).

  1. 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.
  2. Rétablir d'abord si la cause n'est pas évidente : remettre la version précédente dans .env (VERSION=2.4.0) et sudo docker compose up -d. Le service revient en une minute.
  3. 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é ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Journaux et supervision des conteneursRevoir le sommaire du cours