Skip to content
ExponaBLOG
NESTJS · DAY 058 / 100

File uploads with Multer and S3: lessons from production

From simple uploads to presigned URLs.

Expona Admin8 min read

File uploads with Multer and S3: From simple uploads to presigned URLs

Handling file uploads in production is trickier than it looks—you need validation, storage, and a way to keep your server from drowning under the I/O load. I've learned this the hard way, watching a ride-hailing platform grind to a halt because we were buffering driver documents through Node instead of letting the browser talk directly to S3. In this post, I'll walk you through the progression from basic Multer uploads to presigned URLs, a pattern I now use for nearly every file-heavy system I build.

FileInterceptor: Quick uploads with validation

Let's start with the simplest case: a user uploads a profile picture, it's small, and you want to validate it before storing it. NestJS gives you FileInterceptor, which integrates Multer under the hood and lets you grab the file from the request.

typescript
import { Controller, Post, UseInterceptors, UploadedFile, BadRequestException } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';

@Controller('uploads')
export class UploadsController {
  @Post('avatar')
  @UseInterceptors(
    FileInterceptor('file', {
      storage: diskStorage({
        destination: './uploads',
        filename: (req, file, cb) => {
          const uniqueName = `${Date.now()}-${file.originalname}`;
          cb(null, uniqueName);
        },
      }),
    }),
  )
  uploadAvatar(@UploadedFile() file: Express.Multer.File) {
    if (!file) {
      throw new BadRequestException('No file provided');
    }
    return { filename: file.filename, size: file.size };
  }
}

This works, but it's hitting your local disk. For a distributed system, you'll want to write to a cloud service instead. Also, notice we're not validating the file type or size yet—let's fix that next.

Validate files with ParseFilePipe

NestJS 8+ introduced ParseFilePipe, which chains validators together. This is cleaner than manually checking file.size in your handler. I typically validate two things: file type (image, PDF, etc.) and size (no 1GB PDFs for avatars).

typescript
import { Controller, Post, UseInterceptors, UploadedFile, ParseFilePipe, MaxFileSizeValidator, FileTypeValidator } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';

@Controller('uploads')
export class UploadsController {
  @Post('avatar')
  @UseInterceptors(FileInterceptor('file'))
  uploadAvatar(
    @UploadedFile(
      new ParseFilePipe({
        validators: [
          new MaxFileSizeValidator({ maxSize: 2 * 1024 * 1024 }), // 2MB
          new FileTypeValidator({ fileType: /(jpg|jpeg|png|gif)$/ }),
        ],
        fileIsRequired: true,
      }),
    )
    file: Express.Multer.File,
  ) {
    return { filename: file.filename, size: file.size, mimetype: file.mimetype };
  }
}

If the file is too large or the wrong type, NestJS automatically returns a 400 error with a descriptive message. No try-catch noise in your handler. You can also create custom validators if you need something fancy—like checking image dimensions or scanning for viruses.

Presigned URLs for big files

Here's where things get interesting. For large uploads—documents, media, anything over a few megabytes—you don't want the browser sending data to your Node server, then your server relaying it to S3. That's wasteful and slow. Instead, you generate a presigned URL: S3 signs a URL that lets the browser upload directly, bypassing your API.

The flow is simple:

  1. Client asks your API: "I want to upload this file."
  2. API generates a presigned PUT URL good for 5 minutes and returns it.
  3. Client POSTs or PUTs the file directly to that URL.
  4. Browser uploads to S3 in parallel with your app doing other work.
  5. Client tells your API when done, API verifies the object exists, saves metadata.
typescript
import { Injectable } from '@nestjs/common';
import { S3Client, PutObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

@Injectable()
export class S3Service {
  private s3Client = new S3Client({ region: 'us-east-1' });

  async generatePresignedUrl(
    fileName: string,
    fileType: string,
  ): Promise {
    const key = `uploads/${Date.now()}-${fileName}`;
    const command = new PutObjectCommand({
      Bucket: process.env.AWS_S3_BUCKET,
      Key: key,
      ContentType: fileType,
    });
    const url = await getSignedUrl(this.s3Client, command, { expiresIn: 300 });
    return url;
  }

  async verifyObjectExists(key: string): Promise {
    try {
      const command = new HeadObjectCommand({
        Bucket: process.env.AWS_S3_BUCKET,
        Key: key,
      });
      await this.s3Client.send(command);
      return true;
    } catch (error) {
      return false;
    }
  }
}

And in your controller:

typescript
@Controller('uploads')
export class UploadsController {
  constructor(private s3Service: S3Service) {}

  @Post('presigned-url')
  async generatePresignedUrl(
    @Body() body: { fileName: string; fileType: string },
  ) {
    const url = await this.s3Service.generatePresignedUrl(
      body.fileName,
      body.fileType,
    );
    return { uploadUrl: url };
  }
}

Notice the 5-minute expiration (expiresIn: 300). That's a security measure—if someone intercepts the URL, it's only useful for a short window. Adjust based on your use case; for mobile with spotty internet, I sometimes bump it to 30 minutes.

Confirm after upload and save metadata

Once the browser finishes uploading to S3, you need to confirm the object exists and store metadata in your database. This is a separate API call from the client.

typescript
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FileMetadata } from './file-metadata.entity';

@Injectable()
export class FileService {
  constructor(
    @InjectRepository(FileMetadata)
    private fileRepo: Repository,
    private s3Service: S3Service,
  ) {}

  async confirmUpload(
    key: string,
    userId: string,
    description: string,
  ): Promise {
    // Verify the object actually exists in S3
    const exists = await this.s3Service.verifyObjectExists(key);
    if (!exists) {
      throw new BadRequestException('File not found in S3');
    }

    // Save metadata
    const metadata = this.fileRepo.create({
      key,
      userId,
      description,
      uploadedAt: new Date(),
    });
    return this.fileRepo.save(metadata);
  }
}

The metadata includes the S3 key, user ID, timestamp, and any custom fields you need. When the user later requests the file, you fetch it from S3 using that key. This two-step process—upload to S3, then confirm in your database—ensures you don't have orphaned objects cluttering your bucket.

Putting it together: Full example

Here's a realistic flow for a travel platform storing visa documents:

typescript
@Controller('visas')
export class VisaController {
  constructor(
    private s3Service: S3Service,
    private fileService: FileService,
  ) {}

  @Post('request-upload')
  async requestUpload(@Body() body: { documentName: string; mimeType: string }) {
    // Step 1: Generate presigned URL
    const uploadUrl = await this.s3Service.generatePresignedUrl(
      body.documentName,
      body.mimeType,
    );
    return { uploadUrl };
  }

  @Post('confirm-upload')
  async confirmUpload(
    @Body() body: { s3Key: string },
    @Req() req: any,
  ) {
    // Step 2: Client confirms file was uploaded
    const userId = req.user.id;
    const metadata = await this.fileService.confirmUpload(
      body.s3Key,
      userId,
      'visa_document',
    );
    return { fileId: metadata.id, uploadedAt: metadata.uploadedAt };
  }
}

On the frontend, you'd call requestUpload, wait for the URL, upload the file using fetch or axios with a PUT request, then call confirmUpload. The entire process keeps your API lightweight and S3 handles the heavy lifting.

Common mistakes

  • Forgetting to validate presigned URLs expiration: If you set expiresIn: 3600 (1 hour) for every upload, you risk clients uploading stale URLs. Keep it short—5 to 30 minutes depending on your network conditions.
  • Storing files locally on ephemeral instances: If you're running on AWS Lambda or a container that resets, local disk storage evaporates. Always use S3 or a persistent volume.
  • Trusting the file extension: Don't assume .pdf means it's actually a PDF. Validate the MIME type, and ideally, scan the file header (magic bytes) to confirm.
  • Orphaning objects in S3: If the database save fails after the client uploads, you've got a dangling file. Either use transactions or add a cleanup job that removes old, unverified uploads.
  • Not handling concurrent uploads: If two clients try to upload with the same presigned URL, the second one will overwrite the first. Use unique keys with timestamps or UUIDs.

Key takeaways

  • Use FileInterceptor for small, synchronous uploads; pair it with ParseFilePipe to validate size and type before they hit your code.
  • Presigned URLs are your friend for files over a few megabytes—they offload upload I/O to the browser and S3, keeping your Node process responsive.
  • Always verify objects exist in S3 before saving metadata; this prevents orphaned files and data inconsistency.
  • Keep presigned URL expiration short (5–30 minutes) to minimize the window for abuse or accidental sharing.
  • For production systems, add logging and monitoring around uploads—S3 costs and bandwidth usage scale fast if something goes wrong.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles