Sécuriser des applis Docker avec Let’s Encrypt et Nginx : HTTPS de bout en bout + dépannage
Ce tutoriel explique comment exposer plusieurs applications Docker en HTTPS de bout en bout (du navigateur jusqu’au conteneur applicatif si nécessaire), en s’appuyant sur Nginx comme reverse proxy et Let’s Encrypt pour des certificats TLS gratuits et renouvelés automatiquement. Il inclut des commandes réelles, des exemples de configurations, et une section de dépannage orientée terrain.
1) Objectif et architecture
Ce que l’on veut obtenir
- Un serveur (VPS, machine dédiée, VM) avec Docker.
- Une ou plusieurs applis conteneurisées (ex.
app1,app2). - Un reverse proxy Nginx qui :
- reçoit le trafic HTTP/HTTPS depuis Internet,
- redirige HTTP → HTTPS,
- termine TLS (ou relaie TLS) selon le besoin,
- route par nom de domaine (
app1.example.com,app2.example.com).
- Let’s Encrypt qui :
- émet un certificat pour chaque domaine,
- renouvelle automatiquement les certificats.
Deux modèles TLS possibles
-
TLS “terminé” au reverse proxy (le plus fréquent)
- Internet → HTTPS → Nginx (décrypte)
- Nginx → HTTP → conteneur applicatif
Avantages : simple, performant, moins de complexité côté applis.
Inconvénient : le trafic entre Nginx et l’appli n’est pas chiffré (mais reste sur un réseau Docker local).
-
HTTPS de bout en bout (TLS jusqu’au conteneur)
- Internet → HTTPS → Nginx
- Nginx → HTTPS → conteneur applicatif
Avantages : chiffrement complet même sur le réseau interne.
Inconvénients : plus complexe (certificats internes, validation, SNI, etc.).
Remarque : “de bout en bout” peut aussi signifier TLS jusqu’à Nginx + réseau interne isolé. Ici, on couvre les deux : terminaison TLS au proxy et option TLS vers le backend.
2) Prérequis
Côté DNS
- Un domaine (ex.
example.com). - Des enregistrements A/AAAA pointant vers l’IP publique du serveur :
app1.example.com→ IPapp2.example.com→ IP- (optionnel)
www.example.com→ IP
Vérification :
dig +short app1.example.com A
dig +short app1.example.com AAAA
Côté serveur
- Linux (Debian/Ubuntu recommandé).
- Ports ouverts :
- 80/tcp (HTTP) nécessaire pour le challenge HTTP-01 de Let’s Encrypt
- 443/tcp (HTTPS)
Vérifier l’écoute :
sudo ss -lntp | grep -E ':80|:443' || true
Installer Docker + Compose (exemple Ubuntu) :
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker "$USER"
Déconnectez/reconnectez-vous, puis :
docker version
docker compose version
3) Choix de la stratégie : Nginx + Certbot en conteneurs
Il existe des stacks “tout-en-un” (ex. nginx-proxy + compagnon Let’s Encrypt). Elles sont pratiques mais parfois plus opaques à dépanner.
Ici, on fait volontairement explicite :
- Un conteneur
nginx(reverse proxy) - Un conteneur
certbot(obtention + renouvellement) - Des volumes partagés :
/etc/letsencrypt(certificats)/var/www/certbot(webroot pour challenges)
Avantage : vous comprenez précisément ce qui se passe, et le dépannage est plus simple.
4) Structure des fichiers
Créez un répertoire de projet :
mkdir -p ~/docker-nginx-letsencrypt
cd ~/docker-nginx-letsencrypt
mkdir -p nginx/conf.d
mkdir -p certbot/www
mkdir -p certbot/conf
On va utiliser :
nginx/conf.d/*.conf: vhostscertbot/www: répertoire webroot pour/.well-known/acme-challenge/certbot/conf:/etc/letsencrypt
5) docker compose : Nginx + Certbot + applis
Créez compose.yml :
cat > compose.yml <<'EOF'
services:
nginx:
image: nginx:1.27-alpine
container_name: nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./certbot/www:/var/www/certbot:ro
- ./certbot/conf:/etc/letsencrypt:ro
networks:
- web
# Conteneur "outil" : on l'exécute à la demande (pas besoin qu'il tourne en permanence)
certbot:
image: certbot/certbot:latest
container_name: certbot
volumes:
- ./certbot/www:/var/www/certbot
- ./certbot/conf:/etc/letsencrypt
networks:
- web
# Exemple d'appli 1 (HTTP)
app1:
image: nginxdemos/hello:plain-text
container_name: app1
restart: unless-stopped
networks:
- web
# Exemple d'appli 2 (HTTP)
app2:
image: nginxdemos/hello:plain-text
container_name: app2
restart: unless-stopped
networks:
- web
networks:
web:
name: web
EOF
Démarrez les services :
docker compose up -d
docker compose ps
6) Nginx : configuration HTTP minimale + endpoint ACME
Avant d’avoir des certificats, Nginx doit répondre en HTTP pour que Certbot puisse valider le domaine (challenge HTTP-01).
Créez un vhost HTTP pour app1.example.com et app2.example.com (remplacez par vos domaines) :
cat > nginx/conf.d/00-http-acme.conf <<'EOF'
server {
listen 80;
listen [::]:80;
server_name app1.example.com app2.example.com;
# Répertoire webroot pour le challenge ACME
location /.well-known/acme-challenge/ {
root /var/www/certbot;
try_files $uri =404;
}
# Pour l'instant, on ne force pas encore HTTPS tant que les certifs ne sont pas prêts.
location / {
return 200 "Nginx HTTP OK (en attente des certificats Let's Encrypt)\n";
add_header Content-Type text/plain;
}
}
EOF
Rechargez Nginx :
docker compose exec nginx nginx -t
docker compose exec nginx nginx -s reload
Test depuis votre machine :
curl -i http://app1.example.com/
Vous devez obtenir 200 avec le message.
7) Obtenir les certificats Let’s Encrypt (webroot)
Pourquoi “webroot” ?
Certbot va déposer un fichier temporaire sous :
http://app1.example.com/.well-known/acme-challenge/<token>
Let’s Encrypt vérifie qu’il est accessible. Comme Nginx sert ce chemin depuis /var/www/certbot, et que ce volume est partagé, la validation fonctionne.
Commande Certbot
Remplacez email et domaines :
docker compose run --rm certbot certonly \
--webroot \
--webroot-path /var/www/certbot \
-d app1.example.com \
-d app2.example.com \
--email admin@example.com \
--agree-tos \
--no-eff-email
Si tout se passe bien, vous verrez un message de succès et des fichiers apparaîtront dans ./certbot/conf.
Vérifiez :
ls -la certbot/conf/live/app1.example.com/ || true
ls -la certbot/conf/live/app2.example.com/ || true
Remarque : Let’s Encrypt crée souvent un seul certificat multi-domaines si vous les demandez ensemble. Dans ce cas, vous aurez un répertoire live/app1.example.com/ contenant un cert valable aussi pour app2.example.com. C’est normal.
8) Nginx HTTPS : redirection + reverse proxy vers les conteneurs
Maintenant, on met en place :
- un serveur HTTP qui redirige vers HTTPS (sauf ACME),
- un serveur HTTPS par domaine, avec proxy vers
app1etapp2.
Créez nginx/conf.d/10-app1.conf :
cat > nginx/conf.d/10-app1.conf <<'EOF'
# HTTP -> HTTPS (en laissant passer le challenge ACME)
server {
listen 80;
listen [::]:80;
server_name app1.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
try_files $uri =404;
}
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name app1.example.com;
ssl_certificate /etc/letsencrypt/live/app1.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app1.example.com/privkey.pem;
# Bonnes pratiques TLS (base)
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
# En-têtes utiles
add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy no-referrer always;
# Reverse proxy vers le service Docker "app1" (HTTP)
location / {
proxy_pass http://app1:80;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# Timeouts raisonnables
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
EOF
Créez nginx/conf.d/20-app2.conf :
cat > nginx/conf.d/20-app2.conf <<'EOF'
server {
listen 80;
listen [::]:80;
server_name app2.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
try_files $uri =404;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name app2.example.com;
ssl_certificate /etc/letsencrypt/live/app1.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app1.example.com/privkey.pem;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
ssl_protocols TLSv1.2 TLSv1.3;
add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;
location / {
proxy_pass http://app2:80;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
}
}
EOF
Notez que app2 réutilise le certificat multi-domaines stocké sous live/app1.example.com/ (si vous avez demandé les deux domaines ensemble). Si vous avez des répertoires distincts, adaptez les chemins.
Rechargez Nginx :
docker compose exec nginx nginx -t
docker compose exec nginx nginx -s reload
Test :
curl -I http://app1.example.com/
curl -I https://app1.example.com/
curl -I https://app2.example.com/
Vous devez voir une redirection 301 en HTTP et une réponse 200/301 côté HTTPS selon l’appli.
9) Renouvellement automatique des certificats
Let’s Encrypt délivre des certificats valables ~90 jours. Il faut renouveler régulièrement (tous les jours ou toutes les semaines) et recharger Nginx après renouvellement.
Test “dry-run”
docker compose run --rm certbot renew --dry-run
Renouvellement réel + reload Nginx
Vous pouvez créer un script :
cat > renew.sh <<'EOF'
#!/usr/bin/env sh
set -eu
docker compose run --rm certbot renew
# Recharger Nginx pour prendre en compte les nouveaux certificats
docker compose exec nginx nginx -s reload
EOF
chmod +x renew.sh
Lancer manuellement :
./renew.sh
Planification via cron (sur l’hôte)
Éditez le crontab :
crontab -e
Ajoutez (tous les jours à 03:17) :
17 3 * * * cd /home/<votre_user>/docker-nginx-letsencrypt && ./renew.sh >> renew.log 2>&1
Remplacez le chemin. Vérifiez que docker est accessible au cron (souvent oui si vous utilisez l’utilisateur correct).
10) HTTPS de bout en bout : Nginx → backend en HTTPS
Dans beaucoup de cas, chiffrer entre Nginx et les conteneurs n’apporte pas grand-chose si tout est sur le même hôte et le même réseau Docker. Mais si vous avez :
- des backends sur d’autres machines,
- un réseau overlay multi-nœuds,
- une exigence de conformité,
- ou simplement une politique “TLS partout”,
alors vous pouvez faire Nginx → backend en HTTPS.
Option A : backend expose déjà HTTPS (certificat interne)
Supposons que app1 écoute en HTTPS sur 443 avec un certificat interne (auto-signé ou CA interne). Vous modifiez :
proxy_pass https://app1:443;
Et vous gérez la validation :
- Si certificat auto-signé : soit vous désactivez la vérification (moins sûr), soit vous installez la CA interne dans Nginx.
Exemple désactivation (à éviter si possible) :
proxy_ssl_verify off;
Exemple avec CA interne :
- Placez votre CA (ex.
ca.pem) dans un volume monté dans Nginx, par exemple./nginx/ca/ca.pem:
mkdir -p nginx/ca
# cp ca.pem nginx/ca/ca.pem
Modifiez compose.yml pour ajouter :
./nginx/ca:/etc/nginx/ca:ro
Puis dans le vhost :
proxy_ssl_trusted_certificate /etc/nginx/ca/ca.pem;
proxy_ssl_verify on;
proxy_ssl_verify_depth 2;
proxy_ssl_server_name on;
proxy_ssl_server_name on; est important si le backend sert plusieurs noms (SNI).
Option B : mTLS (TLS mutuel) entre Nginx et backend
C’est le niveau supérieur : Nginx présente un certificat client, et le backend le vérifie.
- Côté backend : configurer la vérification du certificat client et la CA.
- Côté Nginx :
proxy_ssl_certificate /etc/nginx/mtls/client.crt;
proxy_ssl_certificate_key /etc/nginx/mtls/client.key;
proxy_ssl_trusted_certificate /etc/nginx/mtls/ca.pem;
proxy_ssl_verify on;
C’est très robuste, mais demande une PKI interne et une gestion de rotation.
11) Durcissement TLS et sécurité HTTP
HSTS : attention
L’en-tête :
add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;
force les navigateurs à utiliser HTTPS pendant 180 jours. C’est bien, mais :
- si vous cassez HTTPS, vos utilisateurs seront “bloqués” (ils ne pourront plus repasser en HTTP facilement),
includeSubDomainss’applique à tous les sous-domaines.
Vous pouvez commencer plus prudemment :
add_header Strict-Transport-Security "max-age=86400" always;
Puis augmenter.
Taille upload et websockets
Pour des applis type API / websockets :
client_max_body_size 50m;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
À mettre dans le location / si nécessaire.
Logs utiles
Par défaut, Nginx loggue dans stdout/stderr du conteneur. Consultez :
docker logs nginx --tail=200
12) Dépannage (cas réels et solutions)
12.1 Erreur Let’s Encrypt : “Timeout during connect” / “Connection refused”
Symptômes : Certbot échoue à valider le domaine.
Causes fréquentes :
- DNS pointe vers la mauvaise IP.
- Port 80 bloqué par firewall / security group.
- Un autre service écoute sur le port 80 (Apache, Nginx hôte, etc.).
- Nginx ne sert pas
/.well-known/acme-challenge/.
Vérifications :
- DNS :
dig +short app1.example.com A
- Ports ouverts côté serveur :
sudo ss -lntp | grep ':80'
sudo ss -lntp | grep ':443'
- Firewall (UFW) :
sudo ufw status verbose || true
Ouvrir si nécessaire :
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
- Test HTTP depuis l’extérieur :
curl -i http://app1.example.com/.well-known/acme-challenge/test
Vous devriez obtenir 404 (c’est normal si le fichier n’existe pas), mais pas un timeout.
12.2 Certbot : “Invalid response” sur le challenge
Symptôme : Let’s Encrypt reçoit une page HTML inattendue, ou une redirection, ou un 404 alors que le fichier existe.
Cause : le bloc location /.well-known/acme-challenge/ n’est pas correct, ou un autre vhost matche avant.
Diagnostic :
- Vérifier la conf chargée :
docker compose exec nginx nginx -T | sed -n '1,200p'
- Créer un fichier test dans le webroot :
mkdir -p certbot/www/.well-known/acme-challenge
echo "ok" > certbot/www/.well-known/acme-challenge/ping
curl -i http://app1.example.com/.well-known/acme-challenge/ping
Vous devez voir 200 et ok.
Si vous voyez une redirection vers HTTPS, ce n’est pas forcément un problème (Let’s Encrypt suit parfois), mais il est plus fiable de ne pas rediriger ce chemin.
12.3 Nginx ne démarre pas après ajout HTTPS
Symptôme : docker compose up -d puis docker logs nginx montre une erreur de certificat introuvable.
Cause : chemins ssl_certificate incorrects ou certificats non encore générés.
Solution :
- Revenir temporairement à une conf HTTP,
- Obtenir le certificat,
- Réactiver la conf HTTPS.
Vérifiez les fichiers :
ls -la certbot/conf/live/app1.example.com/fullchain.pem
ls -la certbot/conf/live/app1.example.com/privkey.pem
Test config Nginx :
docker compose exec nginx nginx -t
12.4 Boucle de redirection (ERR_TOO_MANY_REDIRECTS)
Symptôme : navigateur indique trop de redirections.
Cause classique : l’appli derrière Nginx force HTTPS mais ne sait pas qu’elle est déjà derrière un proxy TLS, car X-Forwarded-Proto est absent ou mal interprété.
Solution :
- Assurez-vous d’envoyer :
proxy_set_header X-Forwarded-Proto https;
- Côté application, configurez la “trusted proxy” / “proxy mode” :
- Django :
SECURE_PROXY_SSL_HEADER - Express :
app.set('trust proxy', 1) - Laravel/Symfony : trusted proxies / headers
- Next.js / Nginx : idem selon stack
- Django :
Testez :
curl -I https://app1.example.com/
Regardez la chaîne de Location:.
12.5 Erreur 502 Bad Gateway
Symptôme : Nginx répond 502.
Causes :
- Le conteneur backend n’est pas up.
- Mauvais nom de service (
proxy_pass http://app1:80;doit correspondre au service Compose). - Le backend écoute sur un autre port.
- Problème DNS interne Docker (rare) ou réseau.
Vérifications :
docker compose ps
docker compose logs app1 --tail=200
docker compose exec nginx getent hosts app1
docker compose exec nginx wget -qO- http://app1:80/ | head
Si wget depuis le conteneur Nginx échoue, ce n’est pas un problème TLS mais un problème réseau/port.
12.6 Certificat “valide” mais navigateur affiche encore l’ancien
Cause : Nginx n’a pas été rechargé après renouvellement.
Solution :
docker compose exec nginx nginx -s reload
Vérifiez la date d’expiration :
echo | openssl s_client -servername app1.example.com -connect app1.example.com:443 2>/dev/null | openssl x509 -noout -dates
12.7 Rate limits Let’s Encrypt
Symptôme : vous avez trop tenté et Let’s Encrypt refuse temporairement.
Solution :
- Utiliser l’environnement de staging pour tester :
docker compose run --rm certbot certonly \
--webroot --webroot-path /var/www/certbot \
-d app1.example.com -d app2.example.com \
--email admin@example.com --agree-tos --no-eff-email \
--staging
- Puis repasser en prod quand la conf Nginx est stable.
13) Aller plus loin : multi-domaines, wildcard, DNS-01
Le challenge HTTP-01 nécessite le port 80 accessible. Si vous voulez :
- un wildcard
*.example.com, - ou vous ne pouvez pas ouvrir le port 80,
il faut utiliser le challenge DNS-01 (création d’un enregistrement TXT). Avec Certbot, cela dépend du provider DNS (plugins). Exemple conceptuel (varie selon fournisseur) :
docker compose run --rm certbot certonly \
--dns-<provider> \
--dns-<provider>-credentials /run/secrets/dnscreds \
-d "*.example.com" -d example.com
C’est puissant mais plus long à mettre en place (gestion des credentials, secrets, etc.).
14) Récapitulatif des commandes clés
- Démarrer la stack :
docker compose up -d
- Obtenir les certificats :
docker compose run --rm certbot certonly --webroot --webroot-path /var/www/certbot \
-d app1.example.com -d app2.example.com --email admin@example.com --agree-tos --no-eff-email
- Tester la conf Nginx :
docker compose exec nginx nginx -t
- Recharger Nginx :
docker compose exec nginx nginx -s reload
- Renouveler :
docker compose run --rm certbot renew
docker compose exec nginx nginx -s reload
Conclusion
Vous avez maintenant une base solide pour exposer des applications Docker via Nginx avec des certificats Let’s Encrypt, avec redirection HTTP→HTTPS, en-têtes de sécurité, renouvellement automatisé et une méthode claire de dépannage. Pour aller vers un “HTTPS de bout en bout” strict, vous pouvez chiffrer aussi la liaison Nginx→backend (avec CA interne ou mTLS), au prix d’une complexité supplémentaire mais avec un gain en isolation cryptographique.
Si vous me donnez vos domaines, le type d’app (Node, Django, Spring, etc.) et si vous souhaitez TLS jusqu’au backend ou seulement jusqu’au proxy, je peux proposer une configuration Nginx adaptée (websockets, gros uploads, HTTP/2, cache, etc.).