Back to Articles
25 min read

Managing Media in NestJS: Efficient File Uploads, Streaming, and Cloud Storage (S3/GCS)

Storing files on the server disk is a recipe for disaster in containerized environments. This guide transitions you from basic Multer setups to scalable, stream-based architectures. We cover security validation, on-the-fly image optimization, and direct integrations with object storage providers like AWS S3 and GCS.

File Handling

File Upload with Multer

Multer is the standard middleware for handling multipart/form-data file uploads; NestJS provides decorators like @UploadedFile() and FileInterceptor for clean, type-safe file handling.

import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express'; import { diskStorage } from 'multer'; import { extname } from 'path'; @Controller('upload') export class UploadController { // Single file @Post('single') @UseInterceptors(FileInterceptor('file', { storage: diskStorage({ destination: './uploads', filename: (req, file, cb) => { const uniqueName = `${Date.now()}-${Math.random().toString(36).substr(2)}`; cb(null, `${uniqueName}${extname(file.originalname)}`); }, }), limits: { fileSize: 5 * 1024 * 1024 }, // 5MB })) uploadFile(@UploadedFile() file: Express.Multer.File) { return { filename: file.filename, size: file.size }; } // Multiple files @Post('multiple') @UseInterceptors(FilesInterceptor('files', 10)) uploadFiles(@UploadedFiles() files: Express.Multer.File[]) { return files.map(f => ({ name: f.originalname, size: f.size })); } }

Streaming Files

Streaming allows efficient handling of large files without loading them entirely into memory; use Node.js streams for downloads and uploads to reduce memory footprint.

import { createReadStream, createWriteStream } from 'fs'; import { pipeline } from 'stream/promises'; @Controller('files') export class FileController { // Stream download @Get(':filename') downloadFile(@Param('filename') filename: string, @Res() res: Response) { const filePath = `./uploads/${filename}`; const stat = statSync(filePath); res.set({ 'Content-Type': 'application/octet-stream', 'Content-Length': stat.size, 'Content-Disposition': `attachment; filename="${filename}"`, }); createReadStream(filePath).pipe(res); } // Stream upload to disk @Post('stream') async streamUpload(@Req() req: Request) { const writeStream = createWriteStream('./uploads/large-file.bin'); await pipeline(req, writeStream); return { message: 'Upload complete' }; } }
┌─────────────────────────────────────────────────┐
│          MEMORY COMPARISON                      │
├─────────────────────────────────────────────────┤
│  Buffer (100MB file):  📦📦📦📦📦 → 100MB RAM   │
│  Stream (100MB file):  📦 → 📦 → 📦  ~16KB RAM  │
└─────────────────────────────────────────────────┘

File Validation

File validation ensures uploaded files meet security and business requirements by checking MIME types, file extensions, sizes, and even file contents (magic bytes) to prevent malicious uploads.

import { FileTypeValidator, MaxFileSizeValidator, ParseFilePipe } from '@nestjs/common'; import * as fileType from 'file-type'; // Using built-in validators @Post('upload') uploadFile( @UploadedFile( new ParseFilePipe({ validators: [ new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }), new FileTypeValidator({ fileType: /(jpg|jpeg|png|gif)$/ }), ], }), ) file: Express.Multer.File, ) { return { filename: file.originalname }; } // Custom validator checking magic bytes @Injectable() export class MagicBytesValidator implements FileValidator { async isValid(file: Express.Multer.File): Promise<boolean> { const type = await fileType.fromBuffer(file.buffer); const allowed = ['image/jpeg', 'image/png', 'image/gif']; return type && allowed.includes(type.mime); } buildErrorMessage(): string { return 'Invalid file type (magic bytes check failed)'; } }

Cloud Storage Integration (S3, GCS)

Integrate with cloud storage services like AWS S3 or Google Cloud Storage for scalable, durable file storage with features like signed URLs, versioning, and CDN integration.

import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; @Injectable() export class S3Service { private s3 = new S3Client({ region: 'us-east-1' }); private bucket = process.env.S3_BUCKET; async upload(file: Express.Multer.File): Promise<string> { const key = `uploads/${Date.now()}-${file.originalname}`; await this.s3.send(new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: file.buffer, ContentType: file.mimetype, })); return `https://${this.bucket}.s3.amazonaws.com/${key}`; } async getSignedDownloadUrl(key: string): Promise<string> { const command = new GetObjectCommand({ Bucket: this.bucket, Key: key }); return getSignedUrl(this.s3, command, { expiresIn: 3600 }); } }
┌──────────┐    ┌──────────┐    ┌─────────┐
│  Client  │───▶│  NestJS  │───▶│   S3    │
│          │    │  (proxy) │    │  Bucket │
└──────────┘    └──────────┘    └─────────┘
      │                              │
      └──────── Signed URL ──────────┘
           (Direct download)

Image Processing

Image processing with libraries like Sharp enables resizing, cropping, format conversion, and optimization on upload, creating thumbnails and multiple sizes for responsive applications.

import * as sharp from 'sharp'; @Injectable() export class ImageService { async processImage(buffer: Buffer): Promise<ProcessedImages> { const baseImage = sharp(buffer); const metadata = await baseImage.metadata(); const [thumbnail, medium, large] = await Promise.all([ sharp(buffer).resize(150, 150, { fit: 'cover' }).webp({ quality: 80 }).toBuffer(), sharp(buffer).resize(800, 600, { fit: 'inside' }).webp({ quality: 85 }).toBuffer(), sharp(buffer).resize(1920, 1080, { fit: 'inside' }).webp({ quality: 90 }).toBuffer(), ]); return { thumbnail, medium, large, originalWidth: metadata.width }; } async addWatermark(imageBuffer: Buffer, watermarkPath: string): Promise<Buffer> { return sharp(imageBuffer) .composite([{ input: watermarkPath, gravity: 'southeast' }]) .toBuffer(); } }
Original Image (5MB JPEG)
         │
         ▼
┌─────────────────────────────────────┐
│            Sharp Pipeline           │
├─────────────────────────────────────┤
│  ├─▶ thumbnail.webp  (150x150)  5KB │
│  ├─▶ medium.webp     (800x600)  50KB│
│  └─▶ large.webp     (1920x1080) 150KB│
└─────────────────────────────────────┘

File Compression

File compression reduces storage costs and transfer times; use gzip/brotli for text-based responses and archive libraries like archiver for creating zip files on-the-fly.

import * as compression from 'compression'; import * as archiver from 'archiver'; // Enable HTTP compression (main.ts) app.use(compression({ filter: (req, res) => { if (req.headers['x-no-compression']) return false; return compression.filter(req, res); }, level: 6, // 1-9, higher = more compression })); // Create zip archive @Controller('download') export class DownloadController { @Get('archive') async downloadArchive(@Res() res: Response) { res.set({ 'Content-Type': 'application/zip', 'Content-Disposition': 'attachment; filename="files.zip"', }); const archive = archiver('zip', { zlib: { level: 9 } }); archive.pipe(res); archive.file('./uploads/file1.pdf', { name: 'documents/file1.pdf' }); archive.file('./uploads/file2.pdf', { name: 'documents/file2.pdf' }); archive.directory('./uploads/images/', 'images'); await archive.finalize(); } }