Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
Node.jsTutorial

File uploads in NestJS with Fastify: the new upload interceptors

Use the NestJS 12.1 Fastify upload interceptors: limits, magic-number validation and streaming large files to S3, with the errors we hit in testing and how we fixed them.

Cloudsoft Zone

12 min read

An open red Backblaze Storage Pod 2.0 chassis on a bench, filled with 45 hard drives in rows, with three cooling fans on the front panel
Photograph: ChrisDag / CC BY 2.0, via Wikimedia Commons
In this article

Since NestJS 12.1, a Fastify application can accept file uploads with the same FileInterceptor, @UploadedFile() and ParseFilePipe API that Express users have had for years, plus a Fastify-only FileStreamInterceptor that hands your handler a stream. This tutorial builds two endpoints with it: a small avatar upload that is buffered and checked by size and magic number, and a large document upload that is type-sniffed and streamed straight into S3-compatible object storage without ever being held in memory. Every response quoted below comes from our own test run, including two failures we hit and fixed.

Tested with: Node.js 24.19.0, NestJS 12.1.2 (@nestjs/platform-fastify), Fastify 5.12.5, @fastify/multipart 10.1.3, file-type 22.1.1, AWS SDK for JavaScript v3 (@aws-sdk/client-s3 and @aws-sdk/lib-storage 3.1149.0), TypeScript 5.9 with nodenext modules, against a local S3-compatible server.

What changed in 12.1, and what stays the same

The upload interceptors arrived in NestJS 12.1.0 (PR #17835). They live in @nestjs/platform-fastify/multipart and are backed by @fastify/multipart instead of multer. The file upload documentation maps the names one to one:

Express (multer)Fastify (12.1+)
FileInterceptor, FilesInterceptor, FileFieldsInterceptor, AnyFilesInterceptor, NoFilesInterceptorSame names, arguments and options, imported from @nestjs/platform-fastify/multipart
MulterOptions, MulterModuleMultipartOptions, MultipartModule
Express.Multer.FileUploadedMultipartFile (same shape, no @types/multer needed)
memoryStorage(), diskStorage()Same, from the Fastify package
No equivalentFileStreamInterceptor: one file as a stream

The interceptors fail at startup on the Express adapter. If you are moving an Express app to Fastify, the controller code can stay as it is; only imports and types change. If you are still deciding between the two adapters, our Express 5 vs Fastify 5 comparison covers the wider trade-offs.

Diagram of two NestJS 12.1 upload paths on Fastify: FileInterceptor with ParseFilePipe for small files, and FileStreamInterceptor with type sniffing and multipart upload to S3
Guards run first; small files are buffered and validated, large files are sniffed and streamed to object storage.

Install and bootstrap

Shell
npm install @nestjs/core@^12.1.2 @nestjs/common@^12.1.2 @nestjs/platform-fastify@^12.1.2 \
  @fastify/multipart@^10.1.0 file-type @aws-sdk/client-s3 @aws-sdk/lib-storage

You do not register the multipart plugin yourself: the adapter registers it the first time an upload interceptor is used. Pass plugin-wide options through the adapter's multipart option instead. Keep bodyLimit small for JSON; it does not need to grow for uploads, because each interceptor sets its own file size limit.

TypeScript
import 'reflect-metadata';
import { Module } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { UploadsController } from './uploads.controller.js';
import { StorageService } from './storage.service.js';

@Module({ controllers: [UploadsController], providers: [StorageService] })
class AppModule {}

const app = await NestFactory.create<NestFastifyApplication>(
  AppModule,
  new FastifyAdapter({
    bodyLimit: 1024 * 1024,                         // JSON bodies stay at 1 MiB
    multipart: { limits: { parts: 20, fieldSize: 64 * 1024 } }, // app-wide multipart defaults
  }),
);
app.enableShutdownHooks();
await app.listen(Number(process.env.PORT ?? 4710), '127.0.0.1');

Limits: the default that will surprise you

Multer defaults fileSize to unlimited. @fastify/multipart does not: an interceptor with no limits falls back to Fastify's bodyLimit, which is 1 MiB. We confirmed it with a bare FileInterceptor('file'): a 66-byte PNG returned 201 and a 3 MiB file returned 413 {"message":"File too large"}. That is safer than unlimited, but it means every upload route needs an explicit fileSize. Limits merge key by key, so a route that sets only fileSize still inherits parts and fieldSize from the module or the adapter.

Diagram of upload limit precedence in NestJS 12.1: interceptor options override MultipartModule, then the FastifyAdapter multipart option, then @fastify/multipart defaults
Limits merge key by key, and the fallback file size is Fastify's 1 MiB bodyLimit, not unlimited.

Endpoint 1: small files, buffered and validated

For avatars and other small files, memory storage (the default) is fine: the interceptor collects the file into file.buffer, and ParseFilePipe validates it before your handler runs. FileTypeValidator checks the file's magic number with the file-type package by default, not just the client-supplied Content-Type.

TypeScript
import {
  BadRequestException, Body, HttpException, Controller, HttpStatus, ParseFilePipeBuilder,
  Post, UnsupportedMediaTypeException, UploadedFile, UseInterceptors,
} from '@nestjs/common';
import {
  FileInterceptor, FileStreamInterceptor,
  type MultipartFileStream, type UploadedMultipartFile,
} from '@nestjs/platform-fastify/multipart';
import { randomUUID } from 'node:crypto';
import { StorageService } from './storage.service.js';
import { sniff } from './sniff.js';

const AVATAR_MAX = 2 * 1024 * 1024;        // 2 MiB
const DOCUMENT_MAX = 50 * 1024 * 1024;     // 50 MiB
const DOCUMENT_TYPES = new Set(['application/pdf', 'image/png', 'image/jpeg']);

@Controller('uploads')
export class UploadsController {
  constructor(private readonly storage: StorageService) {}

  /** Small files: buffered in memory, validated by size and magic number. */
  @Post('avatar')
  @UseInterceptors(FileInterceptor('file', { limits: { fileSize: AVATAR_MAX, files: 1, fields: 5 } }))
  async avatar(
    @UploadedFile(
      new ParseFilePipeBuilder()
        .addMaxSizeValidator({ maxSize: AVATAR_MAX })
        .addFileTypeValidator({ fileType: /^image\/(png|jpeg|webp)$/ })
        .build({ errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY }),
    )
    file: UploadedMultipartFile,
  ) {
    const key = `avatars/${randomUUID()}`;
    await this.storage.put(key, file.buffer!, file.mimetype);
    return { key, size: file.size, type: file.mimetype };
  }

  /** Large files: streamed straight to object storage, never buffered whole. */
  @Post('document')
  @UseInterceptors(FileStreamInterceptor('file', { limits: { fileSize: DOCUMENT_MAX, fields: 5 } }))
  async document(
    @UploadedFile() file: MultipartFileStream | undefined,
    @Body() body: { title?: string },
  ) {
    if (!file) throw new BadRequestException('A file is required in the "file" field');
    const { mime, ext, stream } = await sniff(file.stream);
    if (!mime || !DOCUMENT_TYPES.has(mime)) {
      file.stream.resume(); // let the interceptor drain the rest
      throw new UnsupportedMediaTypeException(`Detected type ${mime ?? 'unknown'} is not allowed`);
    }
    // Remember why the upload stream failed: when the size limit trips, the
    // S3 SDK reports its own abort error, which would otherwise become a 500.
    let streamError: unknown;
    file.stream.once('error', (err) => { streamError = err; });
    const key = `documents/${randomUUID()}.${ext}`;
    let size: number;
    try {
      size = await this.storage.put(key, stream, mime);
    } catch (err) {
      if (streamError instanceof HttpException) throw streamError; // e.g. 413 File too large
      throw err;
    }
    return { key, size, type: mime, title: body.title?.slice(0, 200) ?? null };
  }
}

The storage service wraps the AWS SDK's managed upload, which splits a body into parts and uploads them concurrently. Memory use is roughly queueSize times partSize per upload, here 16 MiB; S3 requires parts of at least 5 MiB except the last. The same code works against AWS S3, Cloudflare R2, MinIO or any S3-compatible store by setting S3_ENDPOINT and path-style addressing.

TypeScript
import { Injectable } from '@nestjs/common';
import { S3Client } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import type { Readable } from 'node:stream';

@Injectable()
export class StorageService {
  private readonly s3 = new S3Client({
    region: process.env.S3_REGION ?? 'us-east-1',
    endpoint: process.env.S3_ENDPOINT, // unset for AWS; set for MinIO, R2 and similar
    forcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true',
  });
  private readonly bucket = process.env.S3_BUCKET ?? 'uploads';

  async put(key: string, body: Buffer | Readable, contentType: string): Promise<number> {
    let bytes = 0;
    const upload = new Upload({
      client: this.s3,
      params: { Bucket: this.bucket, Key: key, Body: body, ContentType: contentType },
      queueSize: 2,                 // parts in flight at once
      partSize: 8 * 1024 * 1024,    // 8 MiB parts: memory use is about queueSize x partSize
      leavePartsOnError: false,     // abort the multipart upload if anything fails
    });
    upload.on('httpUploadProgress', (p) => { bytes = p.loaded ?? bytes; });
    await upload.done();
    return bytes;
  }
}

Endpoint 2: large files, streamed to object storage

FileStreamInterceptor gives the handler a MultipartFileStream with a stream property and no buffer. Because the size is unknown up front, MaxFileSizeValidator cannot apply and neither can FileTypeValidator's magic number check; the stream itself errors with a PayloadTooLargeException once limits.fileSize is exceeded. So you sniff the type yourself from the first bytes, then pass the same stream on:

TypeScript
import { Readable } from 'node:stream';
import type { ReadableStream as WebReadableStream } from 'node:stream/web';
import { fileTypeStream } from 'file-type';

/**
 * Reads the first bytes of an upload, detects the real type from its
 * magic number and returns a stream that still contains every byte.
 */
export async function sniff(stream: Readable) {
  const web = Readable.toWeb(stream) as WebReadableStream<Uint8Array>;
  const detected = await fileTypeStream(web, { sampleSize: 4100 });
  return {
    mime: detected.fileType?.mime,
    ext: detected.fileType?.ext,
    stream: Readable.fromWeb(detected as WebReadableStream<Uint8Array>),
  };
}

fileTypeStream() reads a sample (4,100 bytes here), detects the type, and returns a stream that still contains every byte, so nothing is lost between detection and upload. Three rules from the interceptor's documentation shape the handler:

  • Text fields must come before the file. Fields after it are not parsed. In our run, sending title after file stored the file and returned "title": null. Tell API clients the field order, or put metadata in a separate JSON request.
  • One file, one field. A file in any other field fails with 400.
  • Unread data is discarded before the response is sent, so a handler can reject early (as ours does with 415) and the client still finishes its upload cleanly.

Several files, module-wide defaults and disk storage

FilesInterceptor(field, maxCount, options) accepts several files in one field, and MultipartModule.registerAsync() sets defaults for every upload interceptor in the importing module, here from configuration. diskStorage() writes each file to the destination with a random 32-character hex name and no extension, exactly like multer:

TypeScript
import { Controller, Module, Post, UploadedFiles, UseInterceptors } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { FilesInterceptor, MultipartModule, diskStorage, type UploadedMultipartFile } from '@nestjs/platform-fastify/multipart';

@Controller('gallery')
class GalleryController {
  @Post()
  @UseInterceptors(FilesInterceptor('photos', 10, { limits: { fileSize: 5 * 1024 * 1024 } }))
  upload(@UploadedFiles() photos: UploadedMultipartFile[]) {
    return photos.map((p) => ({ name: p.filename, size: p.size }));
  }
}

@Module({
  imports: [
    MultipartModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        storage: diskStorage({ destination: config.get<string>('UPLOAD_TMP_DIR') ?? '/tmp/uploads' }),
        limits: { files: 10, fields: 10, parts: 25 },
      }),
    }),
  ],
  controllers: [GalleryController],
})
export class GalleryModule {}

Posting two images returned 201 with two random file names; posting eleven returned 400 {"message":"Too many files"} because of maxCount. Disk storage leaves the files behind, so delete them in a finally block once they have been processed or moved, and point the destination at a volume with its own size limit.

Testing it: the results, and two things we had to fix

We ran the app against s3rver, a local S3-compatible server, and sent real files with curl:

Terminal output of curl tests against the scratch NestJS app showing 201 for valid files, 422 for a fake PNG, 413 for oversize files and 415 for an executable labelled PDF
Our test run: each rejection carries the status code a client can act on.

The text file renamed to .png and sent as image/png was rejected with 422 by the magic number check; a Windows executable labelled application/pdf was detected as application/x-msdownload and refused with 415; a 20 MiB PDF streamed through in about 0.2 seconds while the process stayed at roughly 170 MB resident memory. Note that the validator's message reports the type the client claimed ("current file type is image/png"), not the detected one, so do not show that message to users as proof of what the file is.

First failure: a 60 MiB upload to the 50 MiB route returned 500, not 413. The stream errored with PayloadTooLargeException as documented, the SDK then tried to abort the multipart upload, and the error that reached Nest was the SDK's (our local server did not implement AbortMultipartUpload, so it was a 405 MethodNotAllowed). Whatever the backend does, the cause the client needs is the size limit, so the handler now records the stream's own error and rethrows it when the upload fails. After the change the same request returned 413 File too large. Because aborts can fail, also add a bucket lifecycle rule that cleans up incomplete multipart uploads after a day or two (AbortIncompleteMultipartUpload on S3), or orphaned parts will cost storage silently.

Second failure: the default limit. Our first draft used FileInterceptor('file') with validators only, and every file over 1 MiB came back 413 before the pipe ran. The fix is the explicit limits.fileSize on each route shown above.

Security checklist for upload endpoints

  • Authenticate before parsing. Guards run before interceptors in Nest, so an AuthGuard on the route refuses anonymous uploads before a byte is parsed. Add rate limiting per user as well.
  • Never trust the client's type or name. Check magic numbers, generate your own object key (a UUID), and never build a filesystem path from originalname.
  • Bound everything: fileSize, files, fields, parts and fieldSize. Text fields count toward memory too.
  • Serve uploads from a separate origin or with Content-Disposition: attachment, and store the detected ContentType, so an uploaded HTML or SVG file cannot run script on your main domain.
  • Scan before you trust. Magic numbers say what a file claims to be, not that it is safe. For files other people will open, put them in a quarantine prefix and scan them (for example with ClamAV) before moving them to the public bucket.
  • Use presigned URLs for very large files. If users upload hundreds of megabytes, let the browser upload directly to object storage with a presigned URL and have your API record the result; your servers then never carry the bytes.

Upload endpoints are where small API mistakes become storage bills or security incidents. If you would like a second pair of eyes on an existing upload flow, or need one built with scanning and presigned uploads, our API development and integration service covers exactly this.

Common mistakes

  • Leaving out limits.fileSize and wondering why files over 1 MiB fail.
  • Using FileStreamInterceptor and expecting MaxFileSizeValidator to work; it cannot without a buffer.
  • Sending text fields after the file to a streaming route.
  • Raising Fastify's global bodyLimit to "fix" uploads, which also raises the JSON body limit for every route.
  • Registering @fastify/multipart manually with attachFieldsToBody; the Nest interceptors do not support that mode.
  • Returning the S3 URL of a private object directly instead of a short-lived presigned download URL.

When not to use this approach

Do not stream multi-gigabyte files through your API: use presigned or multipart uploads from the client to the bucket. Do not use memory storage for anything larger than a few megabytes or for endpoints with many concurrent uploads. If you are on the Express adapter and happy with multer, there is no reason to switch adapters for uploads alone; take the multer 2.4.0 fix shipped in @nestjs/platform-express 11.2.6 and 12.0.3 and carry on. And if the application only accepts the occasional small attachment, a base64 field in a JSON body with a strict size limit can be simpler, at about a third more bytes on the wire.

Need uploads, scanning and object storage wired into a NestJS API without the surprises above? Cloudsoft Zone designs and builds NestJS APIs and integrations, from the endpoint and its limits to the bucket policy and lifecycle rules.

Frequently asked questions

  • Do I need to register @fastify/multipart myself?

    No. Install the package and the Fastify adapter registers it when an upload interceptor is first used. Pass plugin-wide options through new FastifyAdapter({ multipart: { ... } }), or set multipart: false if you register the plugin yourself.

  • Why do uploads over 1 MiB fail with 413 on Fastify?

    When no fileSize limit is set, @fastify/multipart falls back to Fastify's bodyLimit, which defaults to 1 MiB. Set limits.fileSize on the interceptor rather than raising bodyLimit for every route.

  • Can FileStreamInterceptor validate file size and type?

    Not with the built-in validators, because there is no buffer. The stream errors with PayloadTooLargeException when fileSize is exceeded, and you detect the type from the first bytes yourself, for example with fileTypeStream() from the file-type package.

  • Does this work with Cloudflare R2 or MinIO?

    Yes. The storage code uses the standard S3 API through the AWS SDK; set the endpoint and path-style addressing for your provider. Check that the provider supports multipart uploads and lifecycle rules for incomplete uploads.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. File upload — NestJS documentation
  2. feat(fastify): add file upload interceptors backed by @fastify/multipart (#17835) — NestJS (GitHub), 2026-09-23
  3. Release v12.1.0 — NestJS (GitHub), 2026-09-23
  4. @fastify/multipart — Fastify (GitHub)
  5. file-type — GitHub
  6. Uploading and copying objects using multipart upload — Amazon Web Services
  7. Configuring a bucket lifecycle configuration to delete incomplete multipart uploads — Amazon Web Services
  8. File Upload Cheat Sheet — OWASP

Comments

No comments yet. Be the first to join the conversation.

Leave a comment

Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.