Aller au contenu
English
Compte : non connecté, synchronisation possible Mon apprentissage
Selectionner le thème
English

Nginx : servir des fichiers et une application statique

Par mis à jour le

Améliorer cette page

Logo NGINX

Servir des fichiers avec Nginx, c'est faire correspondre une URL à un fichier du disque, lui donner le bon type MIME, et refuser tout le reste. Cette leçon s'adresse à une personne qui sait choisir le server et la location d'une requête et qui veut publier un site statique ou une application monopage (SPA, une application dont le navigateur exécute le JavaScript et dont toutes les routes sont servies par un seul index.html). Elle couvre root et alias (les deux directives qui rattachent une URL à un répertoire), try_files (la liste de candidats que Nginx essaie pour répondre), les types MIME (l'étiquette qui dit au navigateur la nature d'un fichier), le fallback d'une SPA sans avaler les erreurs, le cache du navigateur, les fichiers sensibles et les permissions de lecture.

La leçon suit le chemin d'une URL jusqu'au fichier, puis ce qu'il faut interdire.

  • Choisir entre root et alias et prédire le fichier lu.
  • Écrire un try_files qui sert un fichier, un répertoire ou une erreur.
  • Servir chaque ressource avec le bon type MIME et la bonne durée de cache.
  • Distinguer le fallback légitime d'une SPA d'une vraie absence de ressource.
  • Refuser les fichiers cachés et privés sans bloquer le défi ACME.
  • Donner aux workers (les processus de Nginx qui lisent les fichiers et répondent aux clients) la lecture sans ouvrir les fichiers à tous.

La leçon suppose que vous savez prédire quel location Nginx choisit, et que vous comprenez les droits Unix de base.

Comment Nginx trouve-t-il le fichier d'une URL : root ou alias ?

Section intitulée « Comment Nginx trouve-t-il le fichier d'une URL : root ou alias ? »

root ajoute l'URL entière à un répertoire, alors qu'alias remplace le préfixe de la location par un répertoire. Les confondre est une cause courante d'erreur 404 sur un site statique : dans les deux cas Nginx lit un fichier, mais il ne le cherche pas au même endroit.

server {
listen 80;
server_name vitrine.lab.test;
# root : /assets/app.js -> /srv/vitrine/assets/app.js
location /assets/ {
root /srv/vitrine;
}
# alias : /telechargements/guide.pdf -> /srv/documents/guide.pdf
location /telechargements/ {
alias /srv/documents/;
}
}

Avec root, le chemin du fichier est la concaténation du répertoire et de l'URL complète : le préfixe /assets/ se retrouve dans le chemin. Avec alias, le préfixe disparaît : /telechargements/ est remplacé par /srv/documents/. Quand l'URL publique n'a pas la même forme que le dossier, on utilise alias ; sinon root suffit, et on le pose de préférence une fois au niveau du server pour qu'il vaille partout.

Pour alias, une règle de cohérence évite un défaut sérieux : la location et l'alias doivent finir tous deux par une barre oblique, ou aucun des deux. C'est une bonne pratique bien connue, pas une règle énoncée par la documentation de nginx.org, qui se borne à dire que alias remplace le préfixe. Une location /img (sans barre finale) associée à alias /srv/images/; (avec barre) fait correspondre l'URL /img../secret : Nginx remplace /img par /srv/images/ et lit /srv/images/../secret, donc un fichier hors du dossier. C'est le défaut classique de traversée de chemin appelé off-by-slash. Posez la barre des deux côtés, par exemple location /img/ avec alias /srv/images/;.

index désigne le fichier servi quand l'URL est un répertoire, et try_files essaie plusieurs candidats dans l'ordre jusqu'au premier qui existe. Les deux sont des directives de fichiers, pas de routage : elles s'exécutent une fois la location retenue.

server {
listen 80;
server_name vitrine.lab.test;
root /srv/vitrine;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}

Lisez try_files de gauche à droite. $uri teste le fichier de même chemin que l'URL. $uri/ teste un répertoire, et Nginx y sert alors le fichier d'index. Le dernier argument n'est pas testé, il est la réponse de secours : soit un code comme =404, soit une URL vers laquelle Nginx fait une redirection interne, comme /index.html. Un répertoire demandé sans barre finale reçoit d'abord une redirection 301 vers la même adresse avec la barre.

Ce try_files $uri $uri/ =404 est la forme saine d'un site statique : un fichier absent produit une vraie 404. Gardez cette forme en tête, car la section sur les applications monopages explique pourquoi on ne la remplace pas à la légère.

Nginx déduit le type de contenu (Content-Type) de l'extension du fichier, grâce à une table : sans elle, tout sort en application/octet-stream et le navigateur télécharge au lieu d'exécuter. Un fichier app.js servi sans type JavaScript est refusé par les navigateurs dans plusieurs cas (script de type module, en-tête X-Content-Type-Options: nosniff) et téléchargé au lieu d'être affiché quand on ouvre son adresse : l'application reste blanche, sans erreur côté serveur.

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

La directive include mime.types charge la table qui associe .html, .css, .js, .svg et des dizaines d'autres extensions à leur type. Le paquet Nginx de l'éditeur (Debian 13, Nginx 1.31.8) la charge dans nginx.conf ; une configuration minimale écrite à la main l'oublie souvent. default_type s'applique à tout ce que la table ne reconnaît pas, et vaut application/octet-stream par défaut. Pour le JavaScript, le type du fichier mime.types varie selon la version et la distribution : application/javascript figure dans le conf/mime.types du dépôt amont de Nginx, alors que la RFC 9239 enregistre text/javascript comme type à utiliser et déclare l'autre obsolète. Un navigateur accepte les deux ; lisez le type réellement envoyé plutôt que de le supposer.

Vérifiez le type réellement envoyé, et pas seulement le code de statut, avec curl :

Fenêtre de terminal
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' -H 'Host: vitrine.lab.test' http://127.0.0.1/assets/app.js

La commande affiche le code puis le type. Un 200 accompagné de application/octet-stream est un défaut : la ressource est trouvée mais inutilisable.

Comment servir une application monopage sans cacher les erreurs ?

Section intitulée « Comment servir une application monopage sans cacher les erreurs ? »

Une SPA a besoin que toute route inconnue du serveur (/accueil, /compte/12) retombe sur index.html, car c'est le JavaScript du navigateur qui l'interprète ; mais ce fallback ne doit s'appliquer qu'aux routes de l'application, jamais aux ressources ni à l'API. Un fallback posé sur toute la racine transforme chaque erreur en succès : une ressource absente répond 200 avec du HTML, et un appel d'API inconnu aussi.

# ❌ fallback trop général : /assets/absent.js et /api/inconnu répondent 200 avec du HTML
location / {
try_files $uri $uri/ /index.html;
}

Le défaut est invisible à l'œil : la page s'affiche, tout semble fonctionner. Il se découvre des semaines après la mise en ligne, quand un fetch reçoit du HTML à la place de son JSON, ou quand un fichier manquant n'est signalé par aucune erreur dans les journaux ni dans la supervision. Les 404 disparaissent des mesures, alors qu'elles étaient le signal utile.

La parade est de limiter le fallback à son périmètre en s'appuyant sur le choix de location : une location plus spécifique prend la main pour les ressources et l'API, et laisse le fallback au reste.

Arbre de décision pour une application monopage. Un chemin caché ou un fichier privé est refusé. Une URL qui commence par /api/ va au backend, qui répond lui-même, y compris par une 404. Une URL qui commence par /assets/ sert le fichier ou une 404 franche. Toute autre URL sert le fichier s'il existe, sinon index.html.ouinonouinonouinonRequête vers le siteChemin cachéou fichier privé ?Commence par /api/ ?Commence par /assets/ ?Refus : 404 ou 403Backend : il répondlui-même, 404 comprisFichier ou 404 francFichier si présentsinon index.html
Le fallback sur index.html ne s'applique qu'aux routes de l'application : les ressources et l'API répondent pour elles-mêmes. Source · SVG
Échap pour fermer, + et − pour zoomer, défilement pour se déplacer. 100 %

Suivez le schéma de haut en bas : chaque question écarte un cas avant que le fallback n'intervienne. La configuration correspondante est la suivante, avec des noms de dossiers d'exemple :

server {
listen 80;
server_name vitrine.lab.test;
root /srv/vitrine;
index index.html;
location /assets/ {
try_files $uri =404;
}
location /api/ {
proxy_pass http://127.0.0.1:9000;
}
location / {
try_files $uri $uri/ /index.html;
}
}

Trois location se partagent le travail : /assets/ sert un fichier ou une 404 franche, /api/ envoie la requête à l'application via proxy_pass et le backend répond lui-même, et seule la racine garde le fallback. Le détail de proxy_pass (chemin, en-têtes, serveurs multiples) est le sujet de Publier une application avec Nginx. Pour qu'un appel d'API inconnu reçoive une 404 en JSON plutôt que du HTML, il suffit que ce soit le backend qui le renvoie : Nginx transmet sa réponse telle quelle.

Comment refuser les fichiers cachés et les fichiers privés ?

Section intitulée « Comment refuser les fichiers cachés et les fichiers privés ? »

Un répertoire web ne doit contenir que ce qui peut être publié, et ce qui s'y trouve malgré tout se refuse explicitement. Le premier réflexe est de ne pas y mettre les secrets. Le second est de les refuser, y compris les fichiers cachés (dotfiles, dont le nom commence par un point), parce qu'un .env, un répertoire .git ou une sauvegarde .bak finissent par atterrir sous la racine web.

# Fichiers cachés (dotfiles) : refusés, sauf le défi ACME
location ^~ /.well-known/acme-challenge/ {
root /var/www/acme;
}
location ~ /\. {
return 404;
}
# Un fichier privé précis
location = /deploiement.interne {
return 404;
}

La règle location ~ /\. est une regex qui capte tout chemin contenant /. : /.env, /.git/config, /.htpasswd. Trop large, elle bloque aussi /.well-known/acme-challenge/, le chemin où l'autorité de certification vient lire le jeton du défi HTTP-01 : le renouvellement du certificat échouerait en silence. C'est pourquoi la location du défi est un préfixe prioritaire ^~, qui l'emporte sur la regex, comme vu dans la leçon sur la sélection. Le refus par return 404 donne la même réponse qu'un fichier absent ; deny all; répondrait 403 Forbidden, qui révèle qu'une règle protège ce chemin.

Les traversées de chemin (/../../etc/passwd, /..%2f..%2fetc%2fpasswd) sont déjà neutralisées pour un root bien posé, parce que Nginx décode et normalise l'URL avant de la comparer : un .. ne remonte pas au-dessus de la racine. Le danger vient des cas où la normalisation ne voit rien à corriger, comme l'alias dont la barre finale n'est pas symétrique de celle de sa location, vu plus haut : img.. est pour elle un nom de segment ordinaire, et le .. n'apparaît qu'après le remplacement du préfixe. L'autre cas est celui des liens symboliques (des entrées du disque qui renvoient vers un autre chemin) pointant hors de la racine. La directive disable_symlinks vaut off par défaut : Nginx suit alors les liens sans les contrôler. Avec disable_symlinks on;, l'accès est refusé dès qu'un composant du chemin est un lien, racine comprise ; disable_symlinks on from=$document_root; ne contrôle que les composants situés sous la racine. Elle ajoute un coût de traitement, et le module autoindex l'ignore.

Pour tester un refus, cherchez le secret dans le corps, pas seulement le code de statut : un 200 qui rend index.html sur /.env n'est pas une fuite, un 200 qui rend la clé en est une.

Fenêtre de terminal
curl -s -H 'Host: vitrine.lab.test' http://127.0.0.1/.env | grep -c 'CLE_API' || true

La commande doit afficher 0 : aucune ligne du corps ne contient la clé. Répétez le contrôle sur chaque fichier privé et sur les URL de traversée, en ajoutant --path-as-is à curl : sans lui, curl simplifie lui-même les .. avant d'envoyer la requête, et le serveur ne voit jamais l'attaque.

Les ressources dont le nom change à chaque version (app.4f2a1c.js) se mettent en cache longtemps ; la page d'entrée index.html, dont le nom ne change pas, ne se met pas en cache sans revalidation. Nginx envoie déjà Last-Modified et ETag pour un fichier, ce qui permet au navigateur de redemander seulement si le fichier a changé (réponse 304). Les directives de cache ajoutent la durée.

location /assets/ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
}
location = /index.html {
add_header Cache-Control "no-cache";
}

La directive expires 1y fixe Cache-Control: max-age à un an ; immutable dit au navigateur que le fichier ne changera jamais sous ce nom. Cette durée n'est sûre que si le nom du fichier contient une empreinte de son contenu : sans cela, un correctif déployé n'atteindrait pas les visiteurs. no-cache n'interdit pas le cache : il oblige à revalider avant de réutiliser. Attention enfin à un piège : un add_header écrit dans une location remplace ceux du niveau supérieur au lieu de s'y ajouter.

Les workers de Nginx tournent sous un compte non privilégié (nginx avec le paquet de l'éditeur, créé à l'installation, comme l'indique user nginx; en tête de nginx.conf) : il leur faut la lecture sur chaque fichier servi et le droit de traverser chaque répertoire du chemin (le droit x d'un répertoire, qui permet de le franchir pour atteindre son contenu), mais personne d'autre n'a à pouvoir lire le site. La bonne pratique est donc de donner la lecture par le groupe, et de retirer les droits « autres ».

Un fichier est lisible par un worker si l'une de ces conditions est vraie : il appartient à nginx et le propriétaire a la lecture, ou son groupe est nginx et le groupe a la lecture, ou « autres » a la lecture. La troisième est celle à éviter : elle ouvre le fichier à tout compte de la machine, y compris un service compromis qui n'a rien à voir avec le site. Ouvrir tout en 0777 règle toujours le problème de lecture, et crée un problème de sécurité bien plus grave.

Fenêtre de terminal
sudo chown -R deploy:nginx /srv/vitrine
sudo find /srv/vitrine -type d -exec chmod 750 {} +
sudo find /srv/vitrine -type f -exec chmod 640 {} +

Ici deploy est le compte qui publie, et nginx est le groupe des workers. Les répertoires reçoivent 750 (le droit x pour traverser, nécessaire au groupe) et les fichiers 640 (lecture pour le groupe, rien pour les autres). Pour contrôler, listez ce qui reste lisible par tous, et prouvez que les workers lisent bien :

Fenêtre de terminal
sudo find /srv/vitrine -type f -perm -o=r -printf '%M %p\n'
sudo -u nginx test -r /srv/vitrine/assets/app.js && echo "lisible par nginx"
namei -l /srv/vitrine/assets/app.js

Le premier find doit ne rien afficher. Le deuxième doit afficher lisible par nginx : un durcissement qui retire aussi la lecture aux workers est une panne, pas une protection. namei -l détaille les droits de chaque répertoire du chemin, ce qui révèle le dossier parent qui bloque. Un 403 accompagné dans le journal d'erreurs de open() ... failed (13: Permission denied) désigne ce cas ; un 404 avec (2: No such file or directory) signale plutôt un root ou un alias mal calculé.

Le lab publie une application monopage, ses ressources, un fichier privé et une API par une configuration qui sert tout sans distinction : aucun type MIME correct, un fallback qui avale les erreurs, des fichiers privés accessibles et des permissions ouvertes à tous. Vous publiez ce qui est prévu avec son type, vous conservez les vraies erreurs, vous refusez le privé et vous resserrez les droits sans cesser de servir. La validation mesure le corps et le type MIME de chaque ressource, le fallback sur les routes de l'application et lui seul, le refus des fichiers privés et des traversées en cherchant le secret dans le corps, et l'absence de tout fichier lisible par tous, ressources toujours servies.

  • root ajoute l'URL entière au répertoire, alias remplace le préfixe de la location ; un alias garde la même barre finale que sa location (bonne pratique), sinon location /img avec alias /srv/images/; ouvre la traversée /img../.
  • try_files essaie ses candidats dans l'ordre, et son dernier argument est la réponse de secours : =404 ou une URL interne.
  • include mime.types donne à chaque ressource son type : sans lui, tout sort en application/octet-stream et un script risque de ne plus être exécuté ; vérifiez le type envoyé avec curl, text/javascript étant le type recommandé par la RFC 9239.
  • Un fallback sur index.html posé sur toute la racine transforme les erreurs en succès : limitez-le aux routes de l'application, avec des location propres aux ressources et à l'API.
  • Un dotfile se refuse par regex, mais la règle ne doit pas bloquer /.well-known/acme-challenge/ : un préfixe ^~ l'en protège.
  • Un refus se teste en cherchant le secret dans le corps, pas seulement le code de statut.
  • Les workers lisent par le groupe (640 et 750), jamais par un droit ouvert à tous : find -perm -o=r doit être vide et les ressources toujours servies.
  • Cache : immutable pour les fichiers à empreinte, no-cache pour index.html.

Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.

Contrôle de connaissances

Validez vos connaissances avec ce quiz interactif

8 questions
8 min.
70% requis

Informations

  • Le chronomètre démarre au clic sur Démarrer
  • Questions à choix multiples, vrai/faux et réponses courtes
  • Vous pouvez naviguer entre les questions
  • Les résultats détaillés sont affichés à la fin

Lance le quiz et démarre le chronomètre

Ce projet vous est utile ?

Les guides, formations, quiz et labs restent gratuits. Si vous les utilisez régulièrement, vous pouvez contribuer à leur maintenance et à leur développement.

Devenir membre de soutien (nouvel onglet) Soutenir une fois (nouvel onglet) En savoir plus

Suivez aussi le projet sur LinkedIn