ITSkillsCenter
Blog

Upload de fichiers vers S3 avec URL signées dans NestJS 11

12 دقائق للقراءة

L’upload de fichiers est l’une des opérations qui font le plus mal à un backend mal architecturé. Faire transiter un PDF de 50 Mo par le serveur NestJS consomme de la mémoire, sature les connexions HTTP, et finit par faire tomber l’application sous une charge modérée. La bonne approche, devenue le standard depuis 2020, est de signer côté serveur une URL pré-autorisée et de laisser le client uploader directement vers le stockage objet. Ce tutoriel monte cette architecture complète sur NestJS 11 avec le SDK AWS v3, supporte les uploads multipart pour les très gros fichiers, et durcit la sécurité avec des validations strictes.

📍 Article principal : NestJS 11 pour startup : architecture production 2026. Le pattern est identique pour Amazon S3, Cloudflare R2, Backblaze B2, Hetzner Object Storage et MinIO — tous compatibles avec l’API S3.

Prérequis

  • API NestJS 11 avec authentification JWT
  • Bucket S3-compatible accessible avec une paire access-key/secret-key
  • Notions de signed URL et de CORS
  • Temps estimé : 75 minutes

Étape 1 — Choisir le fournisseur de stockage

Tous les fournisseurs S3-compatibles partagent la même API mais leur tarification diverge fortement. Amazon S3 standard facture environ 23 USD/To/mois plus le trafic sortant à 90 USD/To. Cloudflare R2 facture 15 USD/To/mois sans aucun frais de trafic sortant, ce qui en fait l’option la plus économique pour les contenus très consultés. Backblaze B2 reste compétitif sur le stockage froid à 6 USD/To/mois. Hetzner Object Storage tourne autour de 6 USD/To/mois et constitue une option européenne intéressante pour la conformité RGPD.

// uploads/uploads.config.ts
export const s3Config = {
  endpoint: process.env.S3_ENDPOINT, // https://r2.cloudflarestorage.com pour R2
  region: process.env.S3_REGION ?? 'auto',
  credentials: {
    accessKeyId: process.env.S3_ACCESS_KEY!,
    secretAccessKey: process.env.S3_SECRET_KEY!,
  },
  forcePathStyle: process.env.S3_PATH_STYLE === 'true', // true pour MinIO
};

L’option forcePathStyle est nécessaire pour MinIO et certains hébergeurs qui n’utilisent pas le DNS virtual-hosted style d’Amazon. Pour Cloudflare R2, l’endpoint pointe vers https://<account>.r2.cloudflarestorage.com. La région compte peu pour R2 (utiliser auto) mais doit être exacte pour AWS S3 — une erreur de région sur AWS provoque une redirection 301 que le SDK gère mais qui ralentit chaque appel.

Étape 2 — Installer le SDK AWS v3

Le SDK v3 est modulaire : on n’installe que les paquets dont on a besoin. Pour signer des URLs, deux paquets suffisent : @aws-sdk/client-s3 qui contient le client, et @aws-sdk/s3-request-presigner qui implémente la signature SigV4. Comparé au SDK v2, l’empreinte du node_modules est divisée par dix.

cd apps/api
pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
pnpm add zod # validation des inputs

Zod est ajouté ici pour valider les paramètres d’entrée du endpoint de signature : type MIME autorisé, taille maximale, nom de fichier sain. Cette validation côté serveur est non-négociable — un client malveillant n’hésitera pas à demander une URL signée pour un fichier de 100 Go ou pour un type MIME exécutable.

Étape 3 — Exposer un endpoint de signature

Le pattern standard est un endpoint POST /uploads/sign qui prend en entrée le nom du fichier, son type MIME et sa taille, et retourne une URL signée valable cinq minutes plus la clé S3 où le client doit uploader. La validation rejette les types MIME non autorisés et les tailles abusives avant même de signer.

// uploads/uploads.controller.ts
@Post('sign')
@UseGuards(JwtAuthGuard)
async signUpload(@Body() body: SignDto, @CurrentUser() user: User) {
  const allowedTypes = ['image/png','image/jpeg','image/webp','application/pdf'];
  if (!allowedTypes.includes(body.contentType)) throw new BadRequestException();
  if (body.size > 50 * 1024 * 1024) throw new PayloadTooLargeException();

  const key = `users/${user.id}/${randomUUID()}.${extension(body.contentType)}`;
  const command = new PutObjectCommand({
    Bucket: process.env.S3_BUCKET,
    Key: key,
    ContentType: body.contentType,
    ContentLength: body.size,
  });
  const url = await getSignedUrl(this.s3, command, { expiresIn: 300 });
  return { url, key };
}

Trois choix de sécurité critiques. La clé S3 est préfixée par l’ID utilisateur, ce qui empêche un client de prédire ou de deviner la clé d’un autre utilisateur. Le nom du fichier est remplacé par un UUID — le nom original peut être stocké en base si besoin de l’afficher. ContentLength est inclus dans la signature : si le client envoie un fichier plus gros que déclaré, S3 rejette l’upload avec une erreur 403, ce qui empêche un contournement de la limite de taille.

Étape 4 — Upload côté client

Côté client, le flow est en deux temps : appeler l’API pour obtenir l’URL signée, puis faire un PUT vers cette URL avec le binaire. Le serveur NestJS n’est jamais touché par le binaire lui-même, ce qui supprime le goulot mémoire. Sur un VPS modeste à 1 vCPU et 2 Go de RAM, on peut soutenir des centaines d’uploads simultanés sans saturer.

// côté front (TypeScript)
const { url, key } = await api.post('/uploads/sign', {
  contentType: file.type, size: file.size,
}).then(r => r.data);

await fetch(url, {
  method: 'PUT',
  body: file,
  headers: { 'Content-Type': file.type },
});

await api.post('/uploads/confirm', { key }); // enregistrer la référence en base

L’appel /uploads/confirm permet au backend d’enregistrer la référence du fichier en base après l’upload réussi. Sans cette confirmation, le fichier vit sur S3 mais n’est rattaché à aucun objet métier. Une tâche périodique BullMQ peut purger les fichiers orphelins (présents sur S3 mais sans entrée correspondante en base depuis plus de 24 h) pour limiter les coûts de stockage.

Étape 5 — Configurer le CORS du bucket

Sans configuration CORS appropriée sur le bucket, le navigateur refuse le PUT direct depuis un domaine différent du bucket. Cette configuration se fait une fois par bucket, soit via la console du fournisseur, soit en CLI. La règle minimale autorise les méthodes PUT/POST depuis le domaine du frontend, et expose les headers de réponse standard.

{
  "CORSRules": [{
    "AllowedOrigins": ["https://app.acme.io"],
    "AllowedMethods": ["PUT", "POST", "GET"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }]
}

Ne jamais utiliser "AllowedOrigins": ["*"] en production : cette ouverture permet à n’importe quel site de demander un upload, ce qui combiné à une faille XSS sur un autre site pourrait exfiltrer des données utilisateur vers le bucket. Restreindre aux origines précises du produit est la bonne discipline. Pour un environnement de développement, une seconde règle peut autoriser http://localhost:3000.

Étape 6 — Multipart upload pour les très gros fichiers

Au-delà de 100 Mo, l’upload en un seul PUT devient fragile : une coupure réseau au milieu oblige à recommencer depuis zéro. S3 propose le multipart upload qui découpe le fichier en parts entre 5 Mo (minimum, sauf la dernière) et 5 Go (maximum), uploadées en parallèle, avec reprise possible part par part. AWS recommande de basculer en multipart dès que le fichier dépasse 100 Mo. Le serveur signe trois URL différentes : une pour initier, une par part, et une pour compléter.

// uploads/uploads.controller.ts
@Post('multipart/init')
async initMultipart(@Body() body: InitDto) {
  const cmd = new CreateMultipartUploadCommand({
    Bucket: process.env.S3_BUCKET, Key: body.key, ContentType: body.contentType,
  });
  const { UploadId } = await this.s3.send(cmd);
  return { uploadId: UploadId };
}

@Post('multipart/sign-part')
async signPart(@Body() { key, uploadId, partNumber }: SignPartDto) {
  const cmd = new UploadPartCommand({ Bucket: process.env.S3_BUCKET, Key: key, UploadId: uploadId, PartNumber: partNumber });
  const url = await getSignedUrl(this.s3, cmd, { expiresIn: 600 });
  return { url };
}

Le client appelle init, puis pour chaque chunk demande une URL signée via sign-part, upload le chunk, et collecte l’ETag retourné par S3. À la fin, un appel à CompleteMultipartUploadCommand côté serveur assemble les parts. Cette mécanique permet de gérer des fichiers de plusieurs gigaoctets avec reprise automatique en cas de coupure. Pour les charges vraiment volumineuses, des bibliothèques comme uppy côté frontend implémentent ce protocole sans effort.

Étape 7 — Servir les fichiers en lecture

Pour la consultation, deux stratégies cohabitent. Pour un fichier public (avatar, image de produit), faire pointer un sous-domaine CDN vers le bucket et exposer l’URL publique. Pour un fichier privé (facture, document personnel), signer une URL de lecture courte (5 minutes) à chaque demande, et inclure des contrôles d’autorisation Casbin avant la signature.

@Get(':key/url')
@UseGuards(JwtAuthGuard, PoliciesGuard)
async getReadUrl(@Param('key') key: string, @CurrentUser() user: User) {
  if (!key.startsWith(`users/${user.id}/`) && user.role !== 'ADMIN') {
    throw new ForbiddenException();
  }
  const cmd = new GetObjectCommand({ Bucket: process.env.S3_BUCKET, Key: key });
  return { url: await getSignedUrl(this.s3, cmd, { expiresIn: 300 }) };
}

Le contrôle key.startsWith est une vérification primaire qui empêche un utilisateur authentifié de signer une URL pour le fichier d’un autre. Cette défense en profondeur s’ajoute à la politique Casbin sans la remplacer. Un attaquant qui contournerait le guard se heurterait encore à cette vérification de cohérence préfixe-utilisateur.

Étape 8 — Antivirus et validation post-upload

Pour les fichiers entrants, deux validations supplémentaires renforcent la sécurité. Le scan antivirus avec ClamAV se déclenche via une fonction Lambda S3 ou via un job BullMQ qui télécharge le fichier, le scanne, et le supprime ou le marque selon le résultat. La validation du magic byte (les premiers octets du fichier) empêche un attaquant d’uploader un fichier exécutable renommé en .pdf.

// uploads/scan.processor.ts
@Processor('scan')
export class ScanProcessor extends WorkerHost {
  async process(job: Job<{ key: string }>) {
    const obj = await this.s3.send(new GetObjectCommand({ Key: job.data.key, Bucket: bucket }));
    const buffer = Buffer.from(await obj.Body.transformToByteArray());
    const result = await this.clam.scanBuffer(buffer);
    if (result.isInfected) {
      await this.s3.send(new DeleteObjectCommand({ Key: job.data.key, Bucket: bucket }));
      throw new Error(`Infected: ${result.viruses.join(',')}`);
    }
  }
}

Le job s’enfile depuis l’endpoint /uploads/confirm. Tant que le scan n’a pas validé, l’objet métier reste dans un état pending_scan en base et n’est pas exposé aux autres utilisateurs. Cette quarantaine évite la diffusion de malware dans une plateforme collaborative.

Erreurs fréquentes

Erreur Cause Solution
Upload bloqué CORS Bucket sans règle CORS Configurer CORS avec origine précise
Signature invalide Différence d’horloge serveur/client > 15 min NTP sync sur l’hôte
Fichier 100x plus gros que prévu ContentLength absent de la signature Inclure dans PutObjectCommand
Trafic facturé énorme Lecture publique sans CDN R2 ou CDN devant S3
Fichiers orphelins en bucket Confirm jamais appelé Job BullMQ de purge nocturne

L’erreur la plus coûteuse en démarrage est la quatrième : un produit qui sert ses images directement depuis S3 standard se retrouve avec une facture surprise au premier mois où le trafic explose. Migrer vers Cloudflare R2 plus tard est techniquement simple mais demande de réécrire toutes les URLs en base. Choisir la bonne plateforme dès le premier mois économise des heures de migration et beaucoup d’argent.

Observabilité des uploads

Tracer chaque upload avec un log structuré {event:"upload",userId,key,size,contentType,duration} permet de détecter immédiatement les anomalies : un utilisateur qui uploade soudainement 100 fichiers en une minute, des types MIME inhabituels, des erreurs de signature en série. Une alerte Grafana sur plus de 10 erreurs 4xx d’upload sur cinq minutes attrape les régressions de configuration CORS ou de signature avant que les utilisateurs ne se plaignent. Le coût d’instrumentation est minime, le bénéfice opérationnel considérable.

FAQ

Faut-il chiffrer les fichiers côté client ?
Pour des données sensibles (documents médicaux, financiers), oui. Le chiffrement côté client avec une clé dérivée du mot de passe utilisateur garantit que même un accès au bucket ne permet pas la lecture. C’est complexe à mettre en place et à gérer pour la récupération de mot de passe. Pour la majorité des cas, le chiffrement au repos S3 (SSE-S3) côté serveur suffit.

Comment limiter le débit d’upload par utilisateur ?
Le rate-limiter @nestjs/throttler sur l’endpoint /uploads/sign bloque la fréquence de demandes de signature, ce qui limite indirectement le débit. Pour un contrôle plus fin (Mo/jour), un compteur dédié en Redis est plus adapté.

Quelle politique de durée de vie sur les fichiers ?
Configurer une lifecycle rule sur le bucket qui transitionne les fichiers vers une classe Glacier après 90 jours et les supprime après 7 ans (ou la durée requise par la réglementation). Cette politique automatique évite l’accumulation indéfinie.

Peut-on remplacer S3 par un volume Coolify ?
Pour des fichiers de petite taille en faible volume, oui — c’est même plus simple. Pour des centaines de Go ou des fichiers consultés depuis plusieurs régions, S3-compatible reste indispensable parce qu’il découple stockage et calcul.

Sauvegardes et résilience

Activer le versioning S3 sur le bucket protège contre les suppressions accidentelles : chaque version reste disponible pendant la durée définie par la lifecycle rule. La réplication cross-region (CRR) ajoute une protection contre les sinistres régionaux pour les données critiques. Pour Cloudflare R2, la sauvegarde se fait via une copie planifiée vers un second bucket dans une autre zone — pas de réplication native à ce jour.

Tutoriels associés

Références

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité