Lorsqu’on fournit des containers à des étudiants, l’enjeu n’est pas seulement d’avoir quelque chose qui marche, c’est d’avoir quelque chose qui ne permet pas d’apprendre à casser l’infra. J’ai vu des TP où une seule erreur de configuration suffisait à transformer une machine d’exercice en tremplin vers l’hôte, ce qui, pour un contexte pédagogique, est assez bof.

C’est là qu’entre en scène le durcissement de container (container hardening pour nos amis anglais). Dans cet article j'essaierais de proposer une démarche linéaire et concrète (non exhaustive malheureusement, il y a toujours quelque chose à améliorer) pour durcir un container Nginx, en gardant un focus sur les cas pédagogiques. N'hésitez pas à m'envoyer un mail pour toute demande de rectification c'est toujours les bienvenue.


Comprendre l’objectif et le modèle de menace

Dans ce type de contexte, les étudiants peuvent :

  • pousser des fichiers ou manipuler des contenus web ;
  • tenter des actions réseau internes ou abusives (scans, DoS) ;
  • explorer les failles connues des configurations par défaut.

L’objectif est donc : limiter la surface d'attaque. Cela implique de réduire les points d’entrée exploitables, d’appliquer le principe du moindre privilège, de cloisonner l’exécution et de surveiller les comportements anormaux.


Choisir une image minimale

L’image de base conditionne la sécurité : plus elle est lourde, plus elle contient de bibliothèques potentiellement vulnérables et donc augmente la surface d'attaque.
À l'heure où j'écrit cet article, nginx:1.29-alpine est un choix adapté : Alpine supprime l’inutile, tout en restant compatible.
Pour des exigences extrêmes, des images distortless existent, mais elles demandent plus d’efforts pour le debbug en cas d'incident.

On à donc pour commencer notre Dockerfile :

FROM nginx:1.29-alpine

Exécuter Nginx en non-root

Nginx tourne root par défaut dans Docker, ce qui est dangereux. La première étape de durcissement consiste à créer un utilisateur dédié :

RUN addgroup -S appgroup && adduser -S -u 1000 -G appgroup appuser
USER appuser

Cela garantit que même en cas d’exécution de code malicieux, le processus reste confiné à des droits restreints. Fixer un UID/GID rend aussi les montages tmpfs plus faciles à gérer (voir section d'après).


Rendre le container read-only et gérer les répertoires temporaires

L’option read_only: true appliqué dans un docker-compose empêche toute écriture dans le filesystem. C'est super, mais notre Nginx a besoin de stocker temporairement certains fichiers (uploads, proxys, fastcgi, etc.). C’est là que l'on peux déclarer des répertoires dans le nginx.conf afin de permettre à Nginx de fonctionner :

client_body_temp_path /tmp/client_temp;
proxy_temp_path       /tmp/proxy_temp;
fastcgi_temp_path     /tmp/fastcgi_temp;
uwsgi_temp_path       /tmp/uwsgi_temp;
scgi_temp_path        /tmp/scgi_temp;

Ces dossiers sont indispensables au fonctionnement. L’astuce consiste à monter /tmp en tmpfs, avec les bons UID/GID. Ainsi, l’écriture est possible uniquement en RAM, éphémère et non persistante :

read_only: true
tmpfs:
  - /tmp:uid=1000,gid=1000,mode=1777

De cette manière, on allie sécurité et compatibilité : le container reste immuable, sauf pour les caches éphémères nécessaires à Nginx. Pour la gestion des logs (qui ne peuvent pas être stockés en RAM), j'en parle plus loin.


Restreindre les privilèges Linux

Docker offre par défaut des capabilities du noyau. En supprimer le maximum réduit encore les risques. Exemple :

security_opt:
  - no-new-privileges:true
cap_drop:
  - ALL

Cela empêche un processus de s’accorder de nouveaux privilèges et supprime toute capacité superflue. En pratique, Nginx n’a pas besoin d’élever ses droits, donc la coupure est transparente.


Quotas de ressources : se protéger des abus

Limiter CPU et mémoire n’est pas seulement une optimisation, c’est aussi une défense contre le déni de service. Un étudiant qui s’amuserait à lancer un calcul infini dans ses pages (JavaScript, payloads) ne doit pas pouvoir monopoliser la machine. De plus on limite le nombre de processus créables pour prévenir d'une éventuelle fork-bomb.

deploy:
  resources:
    limits:
      cpus: '0.15'
      memory: '128M'
    reservations:
      pids: '100'

Configuration Nginx : durcissement des directives

Certaines directives renforcent la sécurité :

  • Limiter les tailles d’upload pour éviter la saturation rapide du disque :
client_max_body_size 1M;
  • Masquer la version de Nginx pour ralentir la recherche d'exploitation selon la version :
server_tokens off;
  • Limiter les méthodes HTTP autorisées pour bannir les méthodes non envisagées :
limit_except GET HEAD POST { deny all; }
  • Réduire les timeouts pour éviter les connexions lentes (Slowloris) via les directives :
client_body_timeout 20s;
client_header_timeout 20s;
send_timeout 20s;

Ces ajustements rendent les attaques plus difficiles et protègent aussi des abus non malveillants.


Scanner et mettre à jour régulièrement

Un container figé devient vite vulnérable quand des failles sont publiées. Deux outils simples dont alors disponibles:

  • Trivy : scanner de CVE et mauvaises configurations.
trivy image deploy-nginx:secure
  • Dockle : vérifie les bonnes pratiques Docker.
dockle deploy-nginx:secure

Les intégrer dans une pipeline CI permet de bloquer un build dès qu’une faille critique est détectée. Par exemple sur Gitlab :

stages:
    - scan
    - build

scan_image:
  stage: scan
  image: aquasec/trivy:latest
  script:
    - trivy image --severity CRITICAL,HIGH --exit-code 1 deploy-nginx:secure || exit 1
    - docker pull goodwithtech/dockle:latest
    - dockle deploy-nginx:secure || true

Bien sûr il existe d'autres outils d'analyse, mais ce sont mes principaux en rapport avec docker.


Cloisonner dans l’orchestrateur

Au-delà du container, l’orchestrateur docker doit aussi participer à la sécurité :

  • n’exposer que les ports nécessaires (80) et, si possible, limiter leur exposition uniquement au réseau interne de l’orchestrateur (par exemple, en utilisant le mode bridge ou des réseaux personnalisés Docker ). Cela permet aux services de communiquer entre eux sans rendre les ports accessibles depuis l’extérieur. En exposant un port uniquement sur le réseau interne Docker (sans le publier sur l’interface externe de l’hôte), on limite les risques d’accès non autorisé depuis l’extérieur. Utilisez la directive expose dans le Dockerfile ou configurez les ports dans docker-compose.yml sans les publier (ports: absent ou non mappé vers l’extérieur).
  • éviter les réseaux de type host : ce mode supprime l’isolation réseau entre le container et l’hôte, ce qui expose directement les ports et services du container sur l’interface réseau de la machine physique. Cela augmente considérablement la surface d’attaque, car une compromission du container peut permettre d’atteindre d’autres services de l’hôte, voire d’autres containers. Privilégiez toujours les réseaux Docker isolés (bridge, overlay, ou des réseaux personnalisés) pour garantir un cloisonnement strict entre les containers et l’hôte, et limiter la visibilité réseau aux seuls services nécessaires.
  • ne jamais monter le /var/run/docker.sock dans des containers mis à disposition d’utilisateurs, c’est équivalent à donner un accès root à l’hôte. Si un service a besoin d’orchestration via socket, utiliser un proxy d’API contrôlé ou un service d’agent avec permissions très limitées (exemple avec un docker-socket-proxy).

Appeler à l'aide quand ça ne vas pas

Logger sur du readonly

Même avec un container en lecture seule, il est essentiel de conserver des logs d’accès et d’erreurs pour l’audit et la détection d’incidents. Plusieurs approches existent :

  • Reverse proxy en amont : Utiliser un proxy comme Traefik/NPM/Caddie devant le container pour centraliser les logs, ce qui évite d’avoir à gérer les logs dans le container lui-même.
  • Logs directs via Nginx : Nginx peut envoyer ses logs ailleurs sans nécessiter de volume monté, ni d’accès en écriture sur le disque car ils redirigent les logs vers le système de gestion des logs de Docker, qui les collecte automatiquement. Ainsi, on conserve la traçabilité sans compromettre l’immuabilité du container. Du coup, la configuration pour écrire les logs sur la sortie standard (stdout) et la sortie d’erreur (stderr) :
access_log /dev/stdout;
error_log  /dev/stderr warn;

On peux ensuite rediriger les logs vers un système externe (ELK, Graylog, etc.) via un driver de logs Docker (par exemple : gelf pour Graylog, fluentd, syslog, etc.) :

logging:
  driver: "gelf"
  options:
    gelf-address: "udp://graylog:12201"

Cela permet de centraliser les logs sans ouvrir d’accès en écriture sur le filesystem du container, tout en gardant la traçabilité nécessaire pour la supervision et l’investigation.
Même durci, un container peut être attaqué. Mettre en place :

Alertes d’usage CPU/mémoire anormal

Avec des quotas en place, il reste tout de même essentiel de surveiller l’utilisation des ressources pour détecter rapidement tout comportement inhabituel (pics de CPU, mémoire saturée, nombre de processus élevé). On essaie encore et toujours d'anticiper au maximum.

Plusieurs approches sont possibles :

  • Surveillance intégrée à l’orchestrateur : Docker, Docker Compose ou Kubernetes proposent des métriques d’utilisation des ressources. Par exemple, la commande docker stats permet de visualiser en temps réel la consommation CPU/mémoire de chaque container. En production, des outils comme Prometheus et Grafana permettent de collecter, visualiser et alerter sur ces métriques (je ferais bientôt un article au sujet de cette usine à gaz).
  • Alertes automatisées : Configurez des seuils d’alerte (par exemple, CPU > 80 % ou mémoire > 90 %) pour recevoir une notification (mail, Telegram) dès qu’un container dépasse les limites prévues. Cela peut se faire via des solutions comme Alertmanager qui s'intègre avec Prometheus.
  • Logs et audit : Analysez régulièrement les logs système et applicatifs pour repérer les comportements inhabituels ou les erreurs récurrentes qui pourraient indiquer un problème de ressources. On peux s'aider par exemple, de XDR (comme Wazuh) pour faciliter ces détections, ce qui rejoint le point précédent de l'automatisation.

En mettant en place ce type de surveillance, on essaye de réagir rapidement en cas de surcharge ou de comportement suspect, pour corriger le tir avant que cela n'impacte l'infrastructure (ou que les utilisateurs ne s'en rendent compte en tout cas).


Checklist pratique (résumé)

  1. Image légère (nginx:alpine)
  2. Utilisateur non-root avec UID fixe
  3. RootFS en lecture seule + tmpfs pour /tmp et dossiers temporaires Nginx
  4. Suppression des privilèges (cap_drop: ALL, no-new-privileges)
  5. Quotas CPU/mémoire/process
  6. Directives Nginx : server_tokens off, client_max_body_size, limit_except
  7. Scanner automatiquement (Trivy + Dockle)
  8. Éviter docker.sock et limiter les expos

Limitations & points de vigilance

  • Durcir n’élimine pas le risque : il le réduit. Rester attentif aux CVE et appliquer les mises à jour d’image. Par exemple, analyse du dockerhub des CVE sur la dernier alpine stable nginx. Pour s'informer correctement sur les CVE vous pouvez consulter CVE Details avec les numéros présent sur le dokcerhub.
  • Certaines protections, comme les profils seccomp ou AppArmor, dépendent fortement du runtime et du système hôte. Par exemple, seccomp permet de filtrer les appels système accessibles au container, tandis qu’AppArmor applique des politiques de contrôle d’accès au niveau du noyau. Cependant, tous les environnements Docker ne supportent pas ces mécanismes de la même manière : certaines distributions Linux n’activent pas AppArmor par défaut, et les profils seccomp peuvent varier selon la version de Docker ou du kernel. Il est donc crucial de tester ces protections sur votre cluster cible avant un déploiement à grande échelle, afin de s’assurer qu’elles fonctionnent comme prévu et n’introduisent pas de régressions ou de blocages inattendus dans vos containers (même si la prod est un très bon environnement de test en soit).

Version finales des documents

En appliquant ces recommandations, on peux par exemple obtenir des fichiers tel que :

Dockerfile

FROM nginx:1.29-alpine

RUN addgroup -S appgroup && adduser -S -u 1000 -G appgroup appuser

COPY --chown=appuser:appgroup ./projet /usr/share/nginx/html

COPY nginx.conf /etc/nginx/nginx.conf

RUN mkdir -p /tmp/client_temp /tmp/proxy_temp /tmp/fastcgi_temp /tmp/uwsgi_temp /tmp/scgi_temp \
    && chown -R appuser:appgroup /tmp /usr/share/nginx/html

USER appuser

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

nginx.conf

worker_processes auto;

# Redirection des logs vers stdout/stderr (gestion via Docker)
error_log  /dev/stderr warn;
pid        /tmp/nginx.pid;

events {
    worker_connections 1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    access_log  /dev/stdout;

    sendfile        on;
    keepalive_timeout  65;

    # Répertoires temporaires montés en tmpfs
    client_body_temp_path /tmp/client_temp;
    proxy_temp_path       /tmp/proxy_temp;
    fastcgi_temp_path     /tmp/fastcgi_temp;
    uwsgi_temp_path       /tmp/uwsgi_temp;
    scgi_temp_path        /tmp/scgi_temp;

    # Sécurisation
    client_max_body_size 1M;
    server_tokens off;
    client_body_timeout 20s;
    client_header_timeout 20s;
    send_timeout 20s;

    server {
        listen       80;
        server_name  localhost;

        location / {
            root   /usr/share/nginx/html;
            index  index.html index.htm;
            limit_except GET HEAD POST { deny all; }
        }
    }
}

docker-compose.yaml

On part du principe que l'on utilise un container "devant" le nginx pour y router les accès.

services:
  nginx:
    build:
      context: .
      dockerfile: Dockerfile
    image: deploy-site-nginx:secure
    container_name: deploy-site-nginx
    expose:
      - "80"
    read_only: true
    tmpfs:
      - /tmp:uid=1000,gid=1000,mode=1777
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: "0.15"
          memory: "128M"
        reservations:
          pids: 100
    networks:
      - site-network

networks:
  site-network:
    name: site-network

Maizencor ?

Mieux que du blabla, lire la doc et des articles ne fait pas de mal :


Durcir un container Nginx, ce n’est pas transformer l’environnement en forteresse imprenable, mais réduire au maximum l’impact d’une compromission probable. Pour un contexte étudiant, le trio gagnant reste : utilisateur non-root, container read-only avec tmpfs bien configurés, et scan continu des images avant déploiement.

En appliquant ces étapes, un étudiant curieux pourra tester, mais il n’aura pas les moyens de « faire exploser » l’infra. Et c’est bien ça l’équilibre à viser : donnez leurs un bac à sable, pas une boîte d’allumettes.