Aller au contenu

Ma configuration Nginx

··14 mins
Sommaire

Il existe bien des manières de configurer son serveur web sans qu’une soit nécessairement meilleure que les autres. Mais parce que l’on me demande régulièrement des conseils sur ce sujet et que je veux bien aider mais pas me répéter, j’explique ici ma manière de voir les choses. Copiez, modifiez, inspirez-vous, critiquez, c’est open-bar.

Compression
#

Avant de rentrer dans le vif du sujet, il est important de faire un point sur la compression des données car il existe plusieurs stratégies possibles. Le choix de la stratégie influençant directement sur les directives de configuration, il est donc nécessaire de bien la définir avant de se lancer dans la configuration elle-même.

Afin de réduire la volume des données envoyées au client, le serveur peut compresser les données avant de les transmettre. Pour ça, le client indique dans sa requête les algorithmes de compression qu’il supporte à l’aide de l’en-tête Accept-Encoding et le serveur lui répond en compressant avec un de ces algorithmes qu’il supporte, en indiquant lequel dans l’en-tête Content-Encoding.

Il existe plusieurs algorithmes de compression, les trois principaux étant les suivants :

  • gzip: algorithme historique supporté par quasiment tout le monde, compresse peu mais le fait rapidement en consommant peu de ressources ;
  • Brotli: algorithme récent supporté par les navigateurs modernes, compresse beaucoup mais consomme des ressources ;
  • Zstd: algorithme encore plus récent supporté par les navigateurs modernes, compresse beaucoup mieux que gzip, un peu moins bien que Brotli, mais le fait rapidement en consommant peu de ressources.
Information

En réalité les niveaux de compression sont configurables, ce que je viens d’indiquer ne sont que des ordres de grandeur à un niveau de compression similaire.

Nginx supporte nativement gzip. Pour utiliser la Brotli ou Zstd, il est nécessaire d’utiliser respectivement les plugin ngx_brotli et zstd-nginx-module. Ces plugins sont généralement inclus dans les paquets de la plupart des distributions, par exemple les paquets nginx-mod-http-brotli et nginx-mod-http-zstd pour Alpine Linux.

A priori, choisir quel algorithme de compression supporter peut ne pas sembler évident car il ne semble pas possible d’avoir à la fois une très large compatibilité, un fort niveau de compression et ne pas augmenter la charge du processeur. Cependant, il existe un cheat-code : il est possible de pré-compresser les données statiques (typiquement les images, feuilles de style, etc.) afin que le serveur n’ai pas besoin de les compresser à chaque fois. Afin que ça fonctionne, il faut que le fichier présente l’extension correspondant au mode de compression (.gz, .br ou .zst). Ainsi, afin de servir le fichier index.html pré-compressé, il faut avoir :

  • index.html (non compressé, conservé pour les clients ne supportant pas la compression)
  • index.html.gz (compression gzip)
  • index.html.br (compression Brotli)
  • index.html.zst (compression Zstd)

La stratégie que j’ai adopté est de désactiver la compression à la volée (directives gzip et assimilées à off) mais de servir les fichiers statiques compressés dès qu’une version pré-compressée existe (directives gzip_static et assimilées à on).

La génération des fichiers pré-compressé peut se faire avec les commandes suivantes qu’il convient, bien entendu, d’adapter à vos besoins spécifiques. La première produira des versions compressées de tous les fichiers, la seconde se limitera aux fichiers ayant une certaine extension.

find "./chemin/vers/le/dossier" \
    -type f \
    -exec gzip --keep --force '{}' \; \
    -exec brotli -9 '{}' \; \
    -exec zstd -z -19 '{}' \;
find "./chemin/vers/le/dossier" \
    -type f \
    \( \
    -name "*\.css" \
    -or -name "*\.html" \
    -or -name "*\.jpeg" \
    -or -name "*\.jpg" \
    -or -name "*\.js" \
    -or -name "*\.json" \
    -or -name "*\.png" \
    -or -name "*\.svg" \
    -or -name "*\.txt" \
    -or -name "*\.xml" \
    \) \
    -exec gzip --keep --force '{}' \; \
    -exec brotli -9 '{}' \; \
    -exec zstd -z -19 '{}' \;

C’est encore un des bénéfices des sites statiques : pré-compresser tout le site est extrêmement simple. Pour un site dynamique ce n’est pas possible, vous devez compresser à la volée, ce qui consomme des ressources. Donc double peine vu que vous avez déjà consommé des ressources pour générer la réponse. En fait, avec un site dynamique, la seule option que vous avez c’est de devoir garder les réponses en cache, avec toute la complexité de la chose ainsi que les problèmes récurrents inhérents à cette méthode.

Arborescence des fichiers
#

/etc/nginx
├── custom
│   ├── anti-bots.conf
│   ├── force-download.conf
│   ├── headers.conf
│   ├── headers-nocsp.conf
│   ├── php.conf
│   ├── tls.conf
│   └── tls-headers.conf
├── fastcgi.conf
├── fastcgi_params
├── koi-utf
├── koi-win
├── mime.types
├── modules.d
├── nginx.conf
├── scgi_params
├── uwsgi_params
├── websites
│   ├── default.conf
│   ├── example.com.conf
│   └── example.org.conf
└── win-utf

À la racine, on retrouve tous les fichiers par défaut fournis avec l’installation du paquet. Je vous laisse lire la documentation de votre distribution pour savoir comment les utiliser. Il n’y a véritablement que le fichier principal, nginx.conf, que j’ai modifié comme suit :

/etc/nginx/nginx.conf
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
user             http;
worker_processes auto;

events {
    worker_connections 1024;
}

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

    server_tokens          off;
    port_in_redirect       off;

    types_hash_bucket_size 128;

    charset                utf-8;
    index                  index.html;

    sendfile               on;
    keepalive_timeout      65;

    gzip                   off;
    gzip_static            on;
    brotli                 off;
    brotli_static          on;
    zstd                   off;
    zstd_static            on;

    access_log             off;

    # Localhost
    resolver               [::1] 127.0.0.1;
    # FDN (France) https://www.fdn.fr/actions/dns/
    # resolver               [2001:910:800::12] 80.67.169.12 [2001:910:800::40] 80.67.169.40;
    # DNSForge.de (Germany) https://dnsforge.de/
    # resolver               [2a01:4f8:c17:7aa5::249] 138.199.149.249 [2a01:4f8:c013:aae9::194] 78.47.71.194;

    acme_issuer letsencrypt_staging {
        uri         https://acme-staging-v02.api.letsencrypt.org/directory;
        state_path  /var/lib/nginx/letsencrypt_staging;
        account_key ecdsa:256;
        accept_terms_of_service;
    }
    acme_issuer letsencrypt_staging_shortlived {
        uri         https://acme-staging-v02.api.letsencrypt.org/directory;
        state_path  /var/lib/nginx/letsencrypt_staging;
        account_key ecdsa:256;
        profile     shortlived require;
        accept_terms_of_service;
    }
    acme_issuer letsencrypt_production {
        uri         https://acme-v02.api.letsencrypt.org/directory;
        state_path  /var/lib/nginx/letsencrypt_production;
        account_key ecdsa:256;
        accept_terms_of_service;
    }
    acme_issuer letsencrypt_production_shortlived {
        uri         https://acme-v02.api.letsencrypt.org/directory;
        state_path  /var/lib/nginx/letsencrypt_production;
        account_key ecdsa:256;
        profile     shortlived require;
        accept_terms_of_service;
    }
    acme_shared_zone zone=ngx_acme_shared:16M;

    include websites/default.conf;
    include websites/example.com.conf;
    include websites/example.org.conf;
}

Pour user, laissez donc la valeur indiquée de base qui sera en accord avec d’éventuels changements dans la conception du paquet de votre distribution.

Ce qu’il est intéressant de voir ici, c’est que j’ai mis l’intégralité des blocs server dans des fichiers séparés par domaines et localisés dans le répertoire websites. Bien entendu, default.conf correspond à ce qui sera servi par défaut si une personne demande un domaine non existant sur l’IPv4. Pour l’IPv6, on utilisera une adresse différente par serveur afin de ne pas avoir ce genre de problèmes.

Notez également qu’il est fait usage du module nginx-acme afin de prendre en charge les certificats X.509 utilisés pour TLS. Il est donc nécessaire d’installer ce module. Au besoin, vous pouvez retirer les éléments de configuration propres à ce module afin de gérer les certificats par un autre moyen.

De plus, pour fonctionner, nginx-acme nécessite que l’on configure un résolveur DNS. Ici j’ai mis la boucle locale et indiqué en commentaire des alternatives publiques possibles. Configurez ce paramètre suivant votre cas.

/etc/nginx/websites/default.conf
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
server {
    listen      198.51.100.42:80 default_server;
    server_name "";

    include     custom/headers.conf;

    return      403;
}

server {
    listen                198.51.100.42:443 default_server ssl;
    http2                 on;
    server_name           "198.51.100.42";

    include               custom/headers.conf;
    include               custom/tls.conf;
    include               custom/anti-bots.conf;

    acme_certificate      letsencrypt_staging_shortlived;
    ssl_certificate       $acme_certificate;
    ssl_certificate_key   $acme_certificate_key;
    ssl_certificate_cache max=2;

    return                403;
}

Dans le serveur par défaut, nous demandons des certificats non pas pour un nom de domaine, mais pour des adresses IP. Let’s Encrypt ne permet pas cela dans le profil par défaut, c’est pour ça qu’il est impératif d’utiliser le profil shortlived.

Chaque véritable application web est configurée dans un fichier au nom du domaine sur laquelle elle se trouve :

/etc/nginx/websites/example.com.conf
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
server {
    listen      198.51.100.42:80;
    listen      [2001:db8::42]:80;
    server_name example.com;

    include     custom/headers.conf;

    return      301 https://example.com$request_uri;
}

server {
    listen                198.51.100.42:443 ssl;
    listen                [2001:db8::42]:443 quic reuseport;
    listen                [2001:db8::42]:443 ssl;
    http2                 on;
    server_name           example.com;

    include               custom/headers.conf;
    include               custom/tls.conf;
    include               custom/anti-bots.conf;

    acme_certificate      letsencrypt_staging;
    ssl_certificate       $acme_certificate;
    ssl_certificate_key   $acme_certificate_key;
    ssl_certificate_cache max=2;

    root                  /srv/http/example.com;
    error_page            404 /404/index.html;
}

Ici, le premier bloc server sert à rediriger les requêtes HTTP vers HTTPS. Les points relatifs au service lui-même se trouvent dans le second bloc server qui, lui, est réservé à HTTPS, d’où l’écoute sur le port 443.

À noter que, bien qu’il ne soit théoriquement pas nécessaire d’utiliser TLS sur HTTP/2, c’est en réalité imposé par la plupart des implémentations, d’où le fait qu’on ne le retrouve pas sur le bloc réservé à HTTP.

Notez la présence d’un listen en double sur l’adresse IPv6. Le premier contient quic reuseport, ce qui permet d’accepter HTTP/3. Si cela fonctionne également en IPv4, il n’est en revanche possible de le spécifier qu’une seule fois par adresse IP. Dans la mesure où la plupart du temps l’adresse IPv4 est réutilisée alors que l’on utilise des adresses IPv6 uniques, j’ai ici préféré ne le mettre que pour l’IPv6. Au besoin, pensez à adapter à votre situation.

Et maintenant, passons aux choses sérieuses…

Les fichiers de configuration personalisés
#

Au nombre de 7, ils sont situés dans le répertoire custom. C’est dans ces fichiers que je mets des bouts de configuration réutilisables dans différentes applications, il n’y a qu’à les inclure dans le bloc server correspondant.

anti-bots.conf
#

S’il y a une chose que je déteste voir sur mon serveur web, ce sont des robots qui cherchent des failles de sécurité de manière totalement automatisée. J’ai donc préparé un fichier un peu spécial qui leur sera servi. Pour cela, commençons par générer un fichier de 300 GiB remplis de zéros et compressons le avec gzip, Brotli et Zstd afin qu’il ne prenne qu’environ 300 MiB d’espace disque pour gzip, moins de 300 Kio pour Brotli et environ 9,5 Mio for Zstd.

$ dd if=/dev/zero bs=1GiB count=300 | gzip >/srv/http/example.com/anti_bots/big_bomb.gz
$ dd if=/dev/zero bs=1GiB count=300 | brotli -9 -o /srv/http/example.com/anti_bots/big_bomb.br
$ dd if=/dev/zero bs=1GiB count=300 | zstd -z -19 -o /srv/http/example.com/anti_bots/big_bomb.zst

Et maintenant on créé le fichier anti-bots.conf qui, si l’URL contient au moins un des motifs définis, retourne le contenu de ce fichier. Ainsi, grâce aux directives gzip_static, brotli_static et zstd_static nous n’avons pas besoin du fichier initial, le fichier compressé sera renvoyé tel quel avec l’en-tête HTTP indiquant que le contenu est compressé, ce qui forcera la bibliothèque HTTP du bot à le décompresser afin d’en lire le contenu.

/etc/nginx/custom/anti-bots.conf
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# Environment and configuration
location ~* /(\.env|\.git|\.vscode|composer\.json|composer\.lock|mysql\.env|mysql\.yml|mysql\.yaml|config\.bak\.php) {
    alias /srv/http/example.com/anti_bots/big_bomb;
}

# Docker
location ~* /(docker\.env|docker\.yml|docker\.yaml|Docker\.env|Docker\.yml|Docker\.yaml) {
    alias /srv/http/example.com/anti_bots/big_bomb;
}

# WordPress
location ~* /(wp-login\.php|atomlib\.php|wordpress|wp|wp2|old-wp|wp-includes) {
    alias /srv/http/example.com/anti_bots/big_bomb;
}

# phpMyAdmin
location ~* /(phpmyadmin|pma) {
    alias /srv/http/example.com/anti_bots/big_bomb;
}

# CGI
location ~* /(alfacgiapi|cgi-bin) {
    alias /srv/http/example.com/anti_bots/big_bomb;
}

# PHP pages
location ~* /(class\.api\.php|cloud\.php|frontend_dev\.php|github\.php|wso112233\.php|xmlrpc\.php) {
    alias /srv/http/example.com/anti_bots/big_bomb;
}

# Misc
location ~* /(\.DS_Store|apple-app-site-association|cms|_all_dbs|server-status|login\.jsp|logon\.htm|login\.action) {
    alias /srv/http/example.com/anti_bots/big_bomb;
}

Bien entendu, pensez à adapter les motifs en fonction de votre situation. Ce serait dommage d’accidentellement vous servir votre propre bombe de compression parce que vous avez un phpMyAdmin.

force-download.conf
#

Parfois vous ne souhaitez pas que le client ouvre un fichier dans son navigateur, vous souhaitez au contraire forcer le téléchargement du fichier en question. Voici la configuration qui permet de faire ça, il ne reste plus qu’à l’inclure là où c’est pertinent.

/etc/nginx/custom/force-download.conf
1
2
types        {}
default_type application/octet-stream;

headers-nocsp.conf
#

C’est ici que sont ajoutés toute une série d’en-têtes HTTP communes à chaque application.

/etc/nginx/custom/headers-nocsp.conf
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# HTTP/3
add_header 'Alt-Svc'                'h3=":443"; ma=86400';

# https://developer.mozilla.org/fr/docs/Web/HTTP/Headers/X-Frame-Options
add_header 'X-Frame-Options'        'DENY';

# https://developer.mozilla.org/fr/docs/Web/HTTP/Headers/X-Content-Type-Options
add_header 'X-Content-Type-Options' 'nosniff';

# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy
add_header 'Referrer-Policy'        'same-origin';

# https://xclacksoverhead.org/home/about
# http://www.gnuterrypratchett.com/
add_header 'X-Clacks-Overhead'      'GNU Terry Pratchett';

Les paramètres sont intentionnellement en « mode paranoïaque » afin d’éviter tout problème.

headers.conf
#

Inclue le fichier précédent et ajoute une politique de sécurité de contenu très stricte. C’est le fichier que j’utilise par défaut : si une application nécessite une CSP plus souple j’utilise headers-nocsp.conf afin de préciser autre chose.

/etc/nginx/custom/headers.conf
1
2
include     custom/headers-nocsp.conf;
add_header  'Content-Security-Policy'   "default-src 'self'";

php.conf
#

Tout simplement ma configuration PHP, utilisant chez moi FastCGI pour interfacer Nginx avec PHP-FPM.

/etc/nginx/custom/php.conf
1
2
3
4
5
6
7
8
9
index  index.php;

location ~ \.php$ {
    try_files $uri  $document_root$fastcgi_script_name =404;
    fastcgi_pass    unix:/run/php-fpm/php-fpm.sock;
    fastcgi_index   index.php;
    fastcgi_param   SCRIPT_FILENAME $document_root$fastcgi_script_name;
    include         fastcgi.conf;
}

tls.conf
#

Et enfin, le meilleur étant pour la fin, la configuration TLS.

/etc/nginx/custom/tls.conf
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# Recommandations de l'ANSSI
# https://www.ssi.gouv.fr/administration/guide/recommandations-de-securite-relatives-a-tls/

# ANSSI R3 : Privilégier TLS 1.3 et accepter TLS 1.2
# ANSSI R4 : Ne pas utiliser SSLv2, SSLv3, TLS 1.0 et TLS 1.1
ssl_protocols             TLSv1.2 TLSv1.3;

# ANSSI R13 : Préférer l’ordre de suites du serveur
ssl_prefer_server_ciphers on;

# ANSSI R6 : Échanger les clés en assurant toujours la PFS
# ANSSI R7 : Échanger les clés avec l’algorithme ECDHE
# ANSSI R9 : Privilégier AES ou ChaCha20
# ANSSI R10 : Utiliser un mode de chiffrement intègre
# ANSSI R12 : Disposer de plusieurs suites cryptographiques
#
# openssl ciphers -v -stdname 'ECDH:!SHA1:!SHA256:!SHA384:!aRSA:!AESCCM8:!ARIA'
# man openssl-ciphers
ssl_ciphers               ECDH:!SHA1:!SHA256:!SHA384:!aRSA:!AESCCM8:!ARIA;

# ANSSI R7 : Échanger les clés avec l’algorithme ECDHE
# openssl ecparam -list_curves
# Nota : la recommandation de l'ANSSI n'est pas à jour et ne prend pas en
# compte l'arrivée de l'algorithme post-quantique hybride basé sur X25519 et
# ML-KEM, nous devons donc compenser.
ssl_ecdh_curve            X25519MLKEM768:X25519:prime256v1:secp384r1:secp521r1:brainpoolP256r1:brainpoolP384r1:brainpoolP512r1;

# ANSSI R20 : Limiter la durée de vie des tickets
ssl_session_tickets       on;
ssl_session_timeout       2m;
ssl_session_cache         shared:SSL:10m;

# ANSSI R23 : Ne pas transmettre de données 0-RTT
ssl_early_data            off;

# /!\ ANSSI R35 : Préférer l’agrafage OCSP
# OCSP Stapling: https://en.wikipedia.org/wiki/OCSP_stapling
#
# Bien que l'ANSSI recommande d'activer l'OCSP Stapling, Let's Encrypt ne
# supporte plus cette fonctionnalité.
# https://letsencrypt.org/2024/12/05/ending-ocsp/
ssl_stapling              off;
ssl_stapling_verify       off;
ssl_trusted_certificate   /etc/ssl/certs/ca-certificates.crt;

include                   custom/tls-headers.conf;

Il ne reste plus qu’à indiquer dans chaque bloc server où se trouvent la clé privée ainsi que le certificat. Pour ce dernier, on n’oubliera pas d’inclure les certificats intermédiaires.

Avec tout ça, si vos logiciels sont à jour, vous devriez avoir un joli A+ sur les test de configuration les plus stricts. À cette fin, citons le Qualys SSL Server test, nmap et testssl.sh.

$ nmap --script ssl-enum-ciphers -p 443 example.org

D’autres éléments de votre configuration peuvent être testés avec le Mozilla observatory.

tls-headers.conf
#

Ce fichier est inclut par le fichier tls.conf, cependant, dans certains cas de configuration, il ne sera pas possible d’ajouter de header à l’endroit où l’on configure TLS et l’import ne fonctionnera donc pas. J’ai donc créé un fichier séparé afin de pouvoir l’inclure là où c’est pertinent.

/etc/nginx/custom/tls-headers.conf
1
2
3
4
# HSTS
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security
# 31536000 sec = 365 days
add_header 'Strict-Transport-Security' 'max-age=31536000; includeSubDomains; preload';

L’exemple de la fin : gogs/gitea/forgejo
#

Afin de montrer un exemple de configuration avec des CSP ajustées au plus près d’une application, je vous propose ma configuration pour gogs/gitea/forgejo :

/etc/nginx/websites/git.example.com.conf
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
server {
    listen                 198.51.100.42:443 ssl;
    listen                 [2001:db8::69]:443 quic reuseport;
    listen                 [2001:db8::69]:443 ssl;
    http2                  on;
    server_name            git.example.com;

    error_log              /var/log/nginx/git.example.com-error.log;

    include                custom/anti-bots.conf;

    include                custom/headers-nocsp.conf;
    add_header             'Content-Security-Policy'   "default-src 'self'; connect-src 'self'; font-src 'self' data:; form-action 'self'; img-src 'self' https: data:; manifest-src 'self' data:; object-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; worker-src 'self'";

    include                custom/tls.conf;
    acme_certificate       letsencrypt_staging;
    ssl_certificate        $acme_certificate;
    ssl_certificate_key    $acme_certificate_key;
    ssl_certificate_cache  max=2;

    location / {
        proxy_pass         http://127.0.0.1:3000/;
        proxy_http_version 1.1;
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection "Upgrade";
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

HTTP/3
#

HTTP/3 n’utilise pas TCP mais QUIC, ce qui fait qu’un client commence par initier une connexion TCP en HTTP/1.1 ou HTTP/2. Le support d’HTTP/3 ne lui est annoncé que lors de la réponse à cette première requête à l’aide de l’en-tête Alt-Svc (cf. fichier /etc/nginx/custom/headers-nocsp.conf).

Cette première requête dégrade fortement les performances. Afin qu’elle s’effectue directement en HTTP/3, il est donc nécessaire d’indiquer au client que HTTP/3 est supporté avant même qu’il n’initie sa première connexion. C’est possible grâce au DNS avec une entrée de type HTTPS. Ce sujet dépassant le sujet de la configuration d’Nginx, je vous laisse vous renseigner plus en détail et tester votre configuration sur le site savearoundtrip.

Le résultat
#

Si vous avez suivi ce billet et que vous avez également bien configuré votre DNS, alors vous devriez obtenir une excellente notation sur les différents outils de test. À titre d’information, voici les résultats pour ce blog à date de la dernière mise à jour du présent billet.

Qualys SSL Labs
#

Capture d’écran du Qualys SSL Labs montrant un résultat A+
Qualys SSL Labs A+

Mozilla HTTP Observatory
#

Capture d’écran de l’observatoire HTTP de Mozilla montrant un résultat A+.
Mozilla HTTP Observatory A+