Mise en situation
Nginx · Docker · App
Configuration complète d'un serveur de production selon l'ANSSI-BP-028 : nginx en reverse proxy exposé sur Internet, daemon Docker durci, application déployée en container isolé. Chaque recommandation ANSSI est appliquée dans ce contexte opérationnel.
| Reco | Niveau | Application concrète |
|---|---|---|
| R35 | M·I | Compte www-data dédié nginx. appuser UID 1001 dans le container. Aucun
partage de compte nobody. |
| R62 | M | Seuls nginx, docker, ssh, auditd actifs. Avahi, CUPS, Bluetooth désactivés. |
| R63 | M·I | Nginx sans server_tokens, sans autoindex, sans méthodes inutiles (TRACE). |
| R64 | M·I·R | Nginx : CAP_NET_BIND_SERVICE seulement. Container : cap_drop: ALL. |
| R65 | M·I·R | Nginx sandboxé par systemd (ProtectSystem, PrivateTmp). App isolée via namespaces Docker. |
| R66 | M·I·R·E | daemon.json : userns-remap, no-new-privileges, profil seccomp par défaut,
journald. |
| R72 | M·I·R | Journaux nginx dans /var/log/nginx/. Journaux Docker via journald. Un journal par
service. |
| R78 | M·I·R | Nginx dans son processus isolé. App dans un réseau Docker bridge dédié app-net. |
| R79 | M·I | Nginx : headers HSTS/CSP/X-Frame, TLS 1.3, rate limiting, logs access/error surveillés. |
| R80 | M | Port 3000 lié sur 127.0.0.1 uniquement — invisible depuis Internet. |
Un serveur fraîchement installé avec nginx et Docker obtient environ 50–60. Objectif après durcissement complet : 80+. Notez le score de départ.
sudo apt update && sudo apt install lynis sudo lynis audit system grep "hardening_index" /var/log/lynis-report.dat
Le socle est identique à tout serveur, avec une adaptation clé : ip_forward = 1 est obligatoire pour Docker. Les services inutiles sont désactivés sans toucher nginx ni docker.
sudo apt update && sudo apt upgrade -y # Désactiver — ne PAS toucher nginx et docker sudo systemctl disable --now avahi-daemon cups bluetooth ModemManager rpcbind # Vérifier que nginx et docker sont actifs systemctl is-active nginx docker
sudo tee /etc/sysctl.d/99-hardening.conf << 'EOF' ## Mémoire & noyau kernel.randomize_va_space = 2 kernel.kptr_restrict = 2 kernel.dmesg_restrict = 1 kernel.sysrq = 0 fs.suid_dumpable = 0 fs.protected_symlinks = 1 fs.protected_hardlinks = 1 ## Réseau IPv4 — ip_forward=1 requis pour Docker net.ipv4.ip_forward = 1 net.ipv4.conf.all.accept_redirects = 0 net.ipv4.conf.default.accept_redirects = 0 net.ipv4.conf.all.send_redirects = 0 net.ipv4.conf.all.accept_source_route = 0 net.ipv4.tcp_syncookies = 1 net.ipv4.icmp_ignore_bogus_error_responses = 1 net.ipv4.conf.all.log_martians = 1 net.ipv4.conf.default.rp_filter = 1 ## IPv6 — désactiver si non utilisé net.ipv6.conf.all.disable_ipv6 = 1 net.ipv6.conf.default.disable_ipv6 = 1 EOF sudo sysctl --system
-p 3000:3000
l'expose sur Internet même si UFW bloque 3000. La solution : toujours lier sur
127.0.0.1:3000:3000 dans docker-compose.
sudo apt install ufw sudo ufw default deny incoming sudo ufw default allow outgoing # SSH — restreindre à l'IP admin si possible sudo ufw allow from <IP_ADMIN> to any port 22 # Nginx — seuls ports publics sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw enable sudo ufw status verbose # Le port 3000 ne doit PAS apparaître sudo ufw status | grep 3000
sudo apt install ca-certificates curl gnupg sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/debian/gpg | \ sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg echo "deb [arch=$(dpkg --print-architecture) \ signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/debian \ $(lsb_release -cs) stable" | \ sudo tee /etc/apt/sources.list.d/docker.list sudo apt update && sudo apt install docker-ce docker-ce-cli containerd.io docker-compose-plugin
# N'ajouter QUE les comptes de déploiement — pas les comptes standard # ⚠ groupe docker = privilèges quasi-root sur l'hôte sudo usermod -aG docker deployer # Vérifier les permissions socket ls -la /var/run/docker.sock # Attendu : srw-rw---- root docker
{
// User namespace : root dans le container = UID non-privilégié sur l'hôte
"userns-remap": "default",
// Bloquer l'escalade de privilèges dans tous les containers
"no-new-privileges": true,
// Journalisation via journald (intégré à auditd)
"log-driver": "journald",
"log-opts": {
"tag": "docker/{{.Name}}",
"max-size": "10m",
"max-file": "5"
},
// Maintenir les containers au redémarrage du daemon
"live-restore": true
}
sudo systemctl restart docker docker info | grep -E "Userns|Logging" cat /etc/subuid | grep dockremap # Attendu : dockremap:100000:65536
Nginx est le seul service exposé à Internet. Chaque fonctionnalité inutile est désactivée. Le processus est sandboxé par systemd (R65) sous le compte www-data (R35).
# Désactiver le compte www-data pour les connexions (R34) sudo usermod -s /bin/false -L www-data # /etc/nginx/nginx.conf — options globales de sécurité user www-data; worker_processes auto; http { # Masquer la version nginx (R63) server_tokens off; # Anti-Slowloris client_body_timeout 10; client_header_timeout 10; keepalive_timeout 30; # Limiter la taille des requêtes client_max_body_size 10M; # Rate limiting global limit_req_zone $binary_remote_addr zone=global:10m rate=30r/s; }
sudo mkdir -p /etc/systemd/system/nginx.service.d/ sudo tee /etc/systemd/system/nginx.service.d/hardening.conf << 'EOF' [Service] NoNewPrivileges=yes PrivateTmp=yes ProtectSystem=strict ReadWritePaths=/var/log/nginx /run/nginx.pid /var/cache/nginx ProtectHome=yes CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID ProtectProc=invisible UMask=0027 EOF sudo systemctl daemon-reload && sudo systemctl restart nginx systemd-analyze security nginx
sudo apt install certbot python3-certbot-nginx sudo certbot --nginx -d mondomaine.fr sudo certbot renew --dry-run
server {
listen 80;
server_name mondomaine.fr;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name mondomaine.fr;
ssl_certificate /etc/letsencrypt/live/mondomaine.fr/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mondomaine.fr/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
ssl_stapling on;
# Headers de sécurité (R79)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'" always;
# Rate limiting
limit_req zone=global burst=50 nodelay;
# Reverse proxy vers l'app Docker (R78, R80)
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Powered-By;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
}
# Bloquer les fichiers cachés (.env, .git...)
location ~ /\. { deny all; access_log off; }
# Logs séparés (R72)
access_log /var/log/nginx/app_access.log;
error_log /var/log/nginx/app_error.log warn;
}
sudo ln -s /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx curl -sI https://mondomaine.fr | grep -E "Strict|X-Frame|Content-Security"
Le container applicatif applique le principe de moindre privilège : utilisateur non-root, filesystem read-only, image minimale Alpine, pas d'outils de build dans l'image finale.
# Étape 1 : Build FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY . . # Étape 2 : Image finale — Alpine, minimale FROM node:20-alpine # Créer un utilisateur non-root dédié (R35) RUN addgroup -g 1001 -S appgroup && \ adduser -u 1001 -S appuser -G appgroup WORKDIR /app # Copier uniquement les artefacts compilés COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules COPY --from=builder --chown=appuser:appgroup /app/dist ./dist # Passer sur l'utilisateur non-root AVANT CMD USER appuser EXPOSE 3000 # Healthcheck pour la supervision (R79) HEALTHCHECK --interval=30s --timeout=3s \ CMD wget -qO- http://localhost:3000/health || exit 1 CMD ["node", "dist/server.js"]
docker build -t monapp:latest . docker run --rm monapp:latest whoami # → appuser # Scanner les vulnérabilités (trivy) trivy image monapp:latest
services:
app:
image: monapp:latest
container_name: app
# Port sur 127.0.0.1 uniquement — invisible depuis Internet (R80)
ports:
- "127.0.0.1:3000:3000"
networks:
- app-net
# Filesystem read-only (R65)
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
# Supprimer TOUTES les capabilities (R64)
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
# Limites ressources (anti-DoS)
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
environment:
- NODE_ENV=production
restart: unless-stopped
logging:
driver: journald
options:
tag: "app-container"
networks:
app-net:
driver: bridge
driver_opts:
com.docker.network.bridge.name: app-net
docker compose up -d docker exec app whoami # → appuser docker exec app touch /test # → Read-only file system ✓ docker exec app cat /proc/1/status | grep CapPrm # → CapPrm: 0000000000000000 (aucune capability) ss -tlnp | grep 3000 # → 127.0.0.1:3000 ✓
# Modifications config nginx -w /etc/nginx/ -p wa -k nginx_config # Modifications config Docker -w /etc/docker/ -p wa -k docker_config -w /var/run/docker.sock -p rwxa -k docker_socket -w /usr/bin/docker -p x -k docker_exec # Logs auth (tentatives SSH) -w /var/log/auth.log -p wa -k auth_log -e 2
sudo augenrules --load sudo journalctl -u nginx -f sudo journalctl -t app-container -f sudo ausearch -k docker_socket -ts today
Vérification finale
Relancer Lynis, puis vérifier chaque couche par les commandes ci-dessous.
sudo lynis audit system grep "hardening_index" /var/log/lynis-report.dat # Score attendu : 78–85 (vs 50–60 initialement)
ufw status verbose
nmap -p 3000 localhost
ufw status | grep 22
curl -sI https://… | grep server
curl -sI https://… | grep Strict
ps aux | grep nginx
nmap --script ssl-enum-ciphers
docker info | grep Userns
ls -la /var/run/docker.sock
journalctl -t docker
docker exec app whoami
docker exec app touch /test
… cat /proc/1/status | grep Cap
docker stats app --no-stream