Lorsqu'une application NestJS gère des uploads (images, documents, avatars), il devient rapidement nécessaire de servir ces fichiers via une URL publique. Plutôt que de coder manuellement un contrôleur qui lit le système de fichiers, NestJS propose un module dédié : @nestjs/serve-static. Dans ce tutoriel, nous allons voir comment l'intégrer proprement, en se basant sur la configuration réelle du projet site-voyance (Le Murmure des Cartes).
Prérequis
- Node.js 18+ et un projet NestJS 10+ déjà initialisé
- Un dossier
uploads/à la racine du projet (où seront stockés les fichiers) - Notions de base sur les modules NestJS
1. Installation du module
La première étape consiste à installer le package officiel @nestjs/serve-static. Il s'appuie sur Express (ou Fastify) pour exposer un dossier du système de fichiers comme une route HTTP statique.
npm install @nestjs/serve-static
Aucune dépendance supplémentaire n'est requise si vous utilisez l'adaptateur Express par défaut, qui est embarqué dans NestJS.
2. Configuration dans AppModule
Le module s'enregistre via la méthode statique ServeStaticModule.forRoot(). Voici l'extrait directement issu du fichier app.module.ts du projet :
import { Module } from '@nestjs/common';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@Module({
imports: [
// Serve uploaded files
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'uploads'),
serveRoot: '/uploads',
}),
// ... autres modules
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
Deux options principales sont utilisées ici :
- rootPath : le chemin absolu vers le dossier physique à exposer. On utilise
join(__dirname, '..', 'uploads')pour remonter d'un niveau depuis le dossierdist/compilé jusqu'à la racine du projet. - serveRoot : le préfixe d'URL public sous lequel les fichiers seront accessibles. Ici, un fichier
uploads/avatar.jpgsera disponible à l'URLhttp://localhost:3002/uploads/avatar.jpg.
3. Pourquoi utiliser path.join ?
Construire un chemin avec une simple concaténation de chaînes (__dirname + '/../uploads') fonctionne sur Linux et macOS, mais pose problème sur Windows où le séparateur est \. La fonction join() de Node.js gère automatiquement le bon séparateur selon le système d'exploitation, ce qui rend votre code portable.

4. Options avancées de ServeStaticModule
Au-delà de rootPath et serveRoot, le module accepte plusieurs options utiles :
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'uploads'),
serveRoot: '/uploads',
exclude: ['/api/(.*)'],
serveStaticOptions: {
index: false,
maxAge: 86400000, // 1 jour de cache navigateur
immutable: true,
etag: true,
setHeaders: (res, path) => {
if (path.endsWith('.jpg') || path.endsWith('.png') || path.endsWith('.webp')) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}
},
},
}),
Détaillons :
- exclude : exclut certaines routes du middleware statique. Indispensable si votre API expose un préfixe global comme
/api(c'est le cas dansmain.tsavecapp.setGlobalPrefix('api')) afin que les requêtes API ne soient pas interceptées. - serveStaticOptions.index : désactive le service automatique de
index.htmldans les sous-dossiers. - maxAge et setHeaders : permettent de contrôler les en-têtes de cache HTTP, essentiels pour les performances.
- etag : active la génération d'ETag pour des revalidations efficaces côté navigateur.
5. CORS et accès aux images depuis le frontend
Lorsque le frontend (par exemple un Next.js sur localhost:3000) charge des images servies par l'API sur localhost:3002, des restrictions CORS peuvent s'appliquer, notamment pour des opérations comme <canvas> ou fetch. Le projet configure déjà CORS dans main.ts :
app.enableCors({
origin: origins,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'stripe-signature'],
credentials: true,
});
Notez aussi cette ligne dans la configuration helmet :
app.use(
helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false, // Nécessaire pour les images uploadées
}),
);
Le fait de désactiver crossOriginEmbedderPolicy est explicitement justifié par le besoin d'embarquer des images uploadées sur des pages tierces (frontend séparé). Sans cela, helmet ajoute un en-tête Cross-Origin-Embedder-Policy: require-corp qui bloquerait le chargement des images.
6. Sécuriser certains fichiers
Par défaut, ServeStaticModule rend tous les fichiers du dossier publiquement accessibles à condition de connaître leur URL. C'est acceptable pour des images publiques (avatars, illustrations), mais pas pour des documents sensibles (factures, justificatifs).
Pour des fichiers privés, deux stratégies coexistent :
Option A : séparer les dossiers
Conservez un dossier uploads/public/ servi par ServeStaticModule, et un dossier uploads/private/ jamais exposé. Pour servir ces derniers, créez un contrôleur protégé par un guard JWT :
import { Controller, Get, Param, Res, UseGuards } from '@nestjs/common';
import { Response } from 'express';
import { join } from 'path';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@Controller('files')
@UseGuards(JwtAuthGuard)
export class PrivateFilesController {
@Get(':filename')
serveFile(@Param('filename') filename: string, @Res() res: Response) {
const filePath = join(__dirname, '..', '..', 'uploads', 'private', filename);
return res.sendFile(filePath);
}
}
Option B : URLs signées
Générez des URLs temporaires avec un token signé, validez-les dans un middleware, puis laissez le module statique servir le fichier. Cette approche est plus complexe mais évite de surcharger l'API.
7. Optimisation : compression
Pour les fichiers texte (SVG, JSON, CSS), activer la compression gzip réduit drastiquement la bande passante. Installez et configurez le middleware compression :
npm install compression
npm install --save-dev @types/compression
import * as compression from 'compression';
// Dans bootstrap(), avant app.listen()
app.use(compression());
Pour les images JPEG/PNG/WebP, la compression à la volée est inutile (les formats sont déjà compressés). En revanche, pré-générer plusieurs résolutions au moment de l'upload reste une excellente pratique.
8. En production : déléguer à Nginx ou Traefik
Faire transiter chaque requête de fichier statique par Node.js fonctionne, mais reste sous-optimal. Un reverse proxy comme Nginx peut servir les fichiers directement depuis le disque, beaucoup plus rapidement, et libérer Node.js pour les requêtes applicatives.
Exemple de configuration Nginx :
server {
listen 80;
server_name api.murmure-des-cartes.cloud;
# Fichiers statiques servis directement
location /uploads/ {
alias /var/www/site-voyance/uploads/;
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
# Reste de l'API vers NestJS
location / {
proxy_pass http://localhost:3002;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Vous pouvez alors conserver ServeStaticModule en développement pour la simplicité, et laisser Nginx prendre le relais en production. Le code applicatif n'a pas besoin de changer.
Conclusion
Servir des fichiers statiques avec @nestjs/serve-static est extrêmement simple : une dépendance, trois lignes de configuration et vos fichiers sont accessibles. Mais derrière cette simplicité, il faut penser aux chemins cross-platform (avec path.join), à la sécurité (séparer public et privé, configurer helmet), au cache HTTP et à la répartition des responsabilités entre Node.js et un reverse proxy en production.
Pour aller plus loin, explorez la documentation officielle NestJS, ainsi que la mise en place d'un module d'upload avec @nestjs/platform-express et multer pour gérer le pendant écriture de cette architecture.

Commentaires