Handling File Uploads in NestJS
- nestjs
- file-upload
- aws-s3
- multer
- backend-development
- system-design
- cloud
In the last article, we wired up Server-Sent Events to push live updates from the server without the overhead of a full WebSocket connection. This one covers a problem that shows up in almost every SaaS product eventually: letting users upload files. It sounds simple until you build it. The naive version works fine in a demo, then falls apart the moment someone uploads a 200 MB video or your API server runs out of disk space under load.
This post is part of the NestJS Production Guide series, where the goal is to build things the way you'd actually ship them rather than the way a tutorial shows them. File uploads are a good example: the "quick way" and the "right way" diverge early, and knowing when to pay that cost is most of the job.
We'll walk through handling multipart uploads in NestJS with Multer, validating what comes in, streaming to object storage instead of local disk, and the pitfalls that tend to surface only after the feature has been in production for a while.
Why file uploads deserve their own article
File uploads look like a solved problem because Express (and by extension NestJS) ships with Multer support out of the box. You add an interceptor, grab req.file, and you're uploading. That's true for a side project. It stops being true the moment you have more than one API instance behind a load balancer, or your users start uploading files large enough to matter.
The core issue is that a file upload is fundamentally different from a JSON request body. It's a stream. It can be large. It has a content type the client claims but doesn't guarantee. And storing it durably means it has to end up somewhere other than the container's local filesystem. Every one of those properties introduces a decision: where does validation happen, where does the file land, and what happens if the request is interrupted halfway through.
Get those decisions wrong early and you end up retrofitting object storage, virus scanning, and size limits into an API that a dozen other features already depend on. Get them right early, even at a basic level, and the feature scales without a rewrite.
Handling multipart requests with Multer
NestJS uses Multer under the hood for multipart form data, exposed through @nestjs/platform-express. For a single file field, the FileInterceptor decorator is the entry point.
import {
Controller,
Post,
UploadedFile,
UseInterceptors,
ParseFilePipeBuilder,
HttpStatus,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
@Controller('avatars')
export class AvatarsController {
@Post()
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
}),
)
async uploadAvatar(
@UploadedFile(
new ParseFilePipeBuilder()
.addFileTypeValidator({ fileType: /(jpg|jpeg|png|webp)$/ })
.addMaxSizeValidator({ maxSize: 5 * 1024 * 1024 })
.build({ errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY }),
)
file: Express.Multer.File,
) {
return { originalName: file.originalname, size: file.size };
}
}
Two choices here matter more than they look. First, memoryStorage() keeps the file in a Buffer rather than writing it to disk. That's deliberate: if the destination is object storage anyway, writing to local disk first is an extra I/O hop you don't need, and it avoids leaving orphaned temp files on the container's filesystem when a request fails midway. Second, the ParseFilePipeBuilder validators run inside NestJS's pipe pipeline, so a bad file gets rejected with a proper HTTP error before it ever reaches your service layer. That's a lot easier to trace than a validation failure buried deep inside business logic.
For multiple files or several named fields, FilesInterceptor and FileFieldsInterceptor cover those cases with the same validation pattern.
Streaming to object storage instead of local disk
Writing uploads to the container's disk is the single most common mistake in this space, and it's an easy one to make because it's the default behavior if you don't configure storage. It works until you scale past one instance. At that point, a file uploaded to instance A is invisible to a request served by instance B, and you've built a bug that only reproduces in production under load.
The fix is to treat local disk as scratch space at most, and push the actual file to S3 (or an S3-compatible service) as part of the request. Here's a service that takes the buffer from the interceptor and streams it to S3:
import { Injectable } from '@nestjs/common';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { randomUUID } from 'crypto';
@Injectable()
export class UploadsService {
private readonly s3 = new S3Client({ region: process.env.AWS_REGION });
private readonly bucket = process.env.UPLOADS_BUCKET as string;
async storeFile(file: Express.Multer.File, ownerId: string) {
const key = `avatars/${ownerId}/${randomUUID()}-${file.originalname}`;
await this.s3.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: file.buffer,
ContentType: file.mimetype,
ContentLength: file.size,
}),
);
return { key, url: `https://${this.bucket}.s3.amazonaws.com/${key}` };
}
}
For files large enough that buffering the whole thing in memory becomes a concern (say, anything past a few tens of megabytes), switch to @aws-sdk/lib-storage's Upload class, which handles multipart upload to S3 directly from a readable stream instead of requiring the entire file in memory first. Whether you need that depends entirely on your file size limits: if you cap uploads at 5 MB for avatars, buffering is fine and simpler. If you're accepting video or large document uploads, stream it.
One more thing worth doing here: never trust the client-supplied filename or content type for the S3 key or for deciding how to serve the file back. Generate your own key (a UUID plus a sanitized extension) and store the original filename separately as metadata if you need it for display.
Validating uploads properly
The fileType validator in the Multer example checks the file extension and the Content-Type header the client sent, both of which a malicious or careless client can set to anything. That's a reasonable first filter, but it's not a security boundary by itself.
For anything where the uploaded file might be re-served to other users, opened by an admin, or processed by another service, verify the actual file signature (magic bytes) rather than trusting metadata. The file-type npm package reads the first few bytes of the buffer and tells you what it actually is:
import { fileTypeFromBuffer } from 'file-type';
async function assertRealFileType(file: Express.Multer.File, allowed: string[]) {
const detected = await fileTypeFromBuffer(file.buffer);
if (!detected || !allowed.includes(detected.mime)) {
throw new UnprocessableEntityException('File content does not match an allowed type');
}
}
This closes a real gap: a .jpg extension with a .exe payload passes the extension check but fails the signature check. Whether you need this depends on how the file gets used downstream. An avatar that only ever gets served back through an <img> tag with a strict Content-Type header is lower risk than a document upload that gets opened by staff or piped into another processing service.
If your product handles uploads at scale or across untrusted user bases, this is also where a virus scanning step (ClamAV, or a managed scanning service) tends to get added. It runs asynchronously after the upload lands in a quarantine prefix in S3, with the file only promoted to its public location once scanning passes.
Common production pitfalls
A few issues consistently show up once file uploads are live:
Request size limits set in one place but not another. NestJS respects the limits.fileSize option on the interceptor, but if you're behind Nginx, an ALB, or API Gateway, those layers have their own body size caps that will reject the request before it reaches your controller, often with a generic 413 that's hard to debug without checking every layer in the chain.
Content-Type sniffing on download. If you store the original mimetype and serve it back verbatim on download, a browser might render an uploaded HTML file inline instead of downloading it, which is a stored XSS vector. Set Content-Disposition: attachment for anything that isn't an image you deliberately want inline, and consider serving user uploads from a separate domain or subdomain so any script execution is scoped away from your main app's cookies.
No cleanup path for orphaned uploads. If a user starts an upload flow, the file lands in S3, and then the surrounding database transaction fails, you now have a file with no owning record. A scheduled job that reconciles S3 objects against database references (or a TTL-based lifecycle rule on a staging prefix) avoids a slow storage leak.
Presigned URLs treated as an afterthought. For anything beyond small files, having the client upload directly to S3 via a presigned URL, rather than routing the bytes through your API, removes your server from the request path entirely. It's more setup, so it's worth deferring until file sizes or upload volume actually justify it.
Key takeaways
- Use `memoryStorage()` with Multer for small-to-medium files and stream large ones with `lib-storage`'s `Upload` class; don't write user uploads to the container's local disk.
- Validate file type by checking the actual file signature with a library like `file-type`, not just the extension or client-supplied `Content-Type`.
- Generate your own storage key server-side; never trust a client-supplied filename for that purpose.
- Set size limits consistently across NestJS, any reverse proxy, and load balancer in front of it, since each layer enforces its own cap independently.
- Serve uploaded files with `Content-Disposition: attachment` unless they're meant to render inline, and isolate user-uploaded content from your main app's origin where possible.
- Defer presigned direct-to-S3 uploads until file size or volume actually justifies removing your API from the upload path.
Frequently asked questions
Should I store files in the database as BLOBs instead of object storage?
Generally no. PostgreSQL can store binary data, but every file adds to backup size, replication load, and query overhead for a table that has nothing to do with the file's content. Object storage like S3 is built for this, is cheaper at scale, and keeps your database focused on structured data. Store the S3 key and metadata in Postgres, not the bytes.
What's the difference between FileInterceptor and FilesInterceptor?
`FileInterceptor` handles a single file from one named form field. `FilesInterceptor` handles multiple files from the same field name (an array upload), and `FileFieldsInterceptor` handles multiple distinct fields, each potentially with its own file. Pick based on how your form is actually structured.
How do I handle very large file uploads without running out of memory?
Avoid buffering the entire file with `memoryStorage()` for large files. Either stream directly through `lib-storage`'s `Upload` class, which reads from the request stream in chunks, or better, have the client upload directly to S3 via a presigned URL so the bytes never pass through your NestJS process at all.
Is client-side file type validation enough?
No. Client-side checks (accept attributes, JavaScript validation) are a UX convenience, not a security control, since any client-side check can be bypassed by calling the API directly. Always re-validate on the server, and check the actual file signature for anything sensitive.
Do I need a CDN in front of uploaded files?
Not immediately. If files are private and accessed per-user, presigned GET URLs from S3 are enough. Once you're serving public assets like avatars or attachments at meaningful volume, fronting the bucket with CloudFront (or similar) reduces latency and offloads repeated reads from S3, but it's an optimization to add when traffic patterns actually call for it.
How should I handle upload progress for large files in the UI?
That's a client-side concern more than a server one: track `XMLHttpRequest` upload progress events or, if uploading directly to S3, use the multipart upload API and report progress per part. The NestJS side doesn't need to know about progress at all if you're using presigned URLs.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.