Chapter 53 โ€” File Uploads with AWS S3

๐Ÿ“– Definition

File upload handling in Node.js typically uses Multer to parse multipart bodies, then streams the file to an object store like AWS S3 and saves only the key/URL in the database.

๐Ÿ’ก You already use this stack in production โ€” be ready to talk through it confidently.

๐Ÿ” Two Patterns

Pattern Flow When
Server-mediated Browser โ†’ API โ†’ S3 Small files, need server-side validation/transformation
Pre-signed URL Browser โ†’ S3 directly (API only signs the URL) Large files, high throughput, lower server cost

๐Ÿ’ป Code Example โ€” Multer Setup

import multer from "multer";

export const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 5 * 1024 * 1024 },          // 5 MB
  fileFilter: (req, file, cb) => {
    if (!/^image\/(png|jpe?g|webp)$/.test(file.mimetype)) {
      return cb(new Error("Only PNG/JPEG/WebP allowed"));
    }
    cb(null, true);
  },
});

๐Ÿ’ป Code Example โ€” Server-Mediated Upload to S3

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import crypto from "crypto";
import path from "path";

const s3 = new S3Client({ region: process.env.AWS_REGION });

app.post("/upload", upload.single("file"), async (req, res) => {
  if (!req.file) return res.status(400).json({ error: "No file" });

  const ext = path.extname(req.file.originalname);
  const key = `uploads/${crypto.randomUUID()}${ext}`;

  await s3.send(new PutObjectCommand({
    Bucket: process.env.S3_BUCKET,
    Key: key,
    Body: req.file.buffer,
    ContentType: req.file.mimetype,
    Metadata: { uploadedBy: String(req.user.id) },
  }));

  const url = `https://${process.env.S3_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;
  res.status(201).json({ url, key });
});

๐Ÿ’ป Code Example โ€” Pre-Signed URL (Direct Browser Upload)

import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

app.post("/upload/sign", async (req, res) => {
  const { filename, contentType } = req.body;
  const key = `uploads/${crypto.randomUUID()}-${filename}`;

  const command = new PutObjectCommand({
    Bucket: process.env.S3_BUCKET,
    Key: key,
    ContentType: contentType,
  });

  const url = await getSignedUrl(s3, command, { expiresIn: 60 });   // 60s window
  res.json({ uploadUrl: url, key });
});
// Browser-side
async function upload(file) {
  // 1. Ask server for a pre-signed URL
  const { uploadUrl, key } = await fetch("/upload/sign", {
    method: "POST",
    body: JSON.stringify({ filename: file.name, contentType: file.type }),
    headers: { "Content-Type": "application/json" },
  }).then(r => r.json());

  // 2. Upload directly to S3
  await fetch(uploadUrl, {
    method: "PUT",
    body: file,
    headers: { "Content-Type": file.type },
  });

  // 3. Tell server we're done
  await fetch("/upload/complete", {
    method: "POST",
    body: JSON.stringify({ key }),
    headers: { "Content-Type": "application/json" },
  });
}

Server CPU usage drops to near zero โ€” large file bytes never traverse it.

๐Ÿ’ป Code Example โ€” Streaming Upload (@aws-sdk/lib-storage)

import { Upload } from "@aws-sdk/lib-storage";

app.post("/upload-stream", upload.single("file"), async (req, res) => {
  const key = `uploads/${Date.now()}-${req.file.originalname}`;

  const uploader = new Upload({
    client: s3,
    params: {
      Bucket: process.env.S3_BUCKET,
      Key: key,
      Body: req.file.stream,        // for true streaming, use multer.diskStorage or busboy
      ContentType: req.file.mimetype,
    },
  });

  uploader.on("httpUploadProgress", (p) => {
    console.log(`Progress: ${p.loaded}/${p.total}`);
  });

  await uploader.done();
  res.json({ key });
});

lib-storage handles multipart uploads for files > 5 MB and retries failed parts automatically.

๐Ÿ’ป Code Example โ€” Generating Pre-Signed Download URLs

import { GetObjectCommand } from "@aws-sdk/client-s3";

app.get("/files/:key/download", async (req, res) => {
  const command = new GetObjectCommand({
    Bucket: process.env.S3_BUCKET,
    Key: req.params.key,
  });
  const url = await getSignedUrl(s3, command, { expiresIn: 300 });
  res.redirect(url);
});

Useful for protected files โ€” clients don't need long-term S3 credentials.

๐Ÿ’ป Code Example โ€” NestJS Version

@Controller("upload")
export class UploadController {
  constructor(private readonly s3: S3Service) {}

  @Post()
  @UseInterceptors(FileInterceptor("file", {
    limits: { fileSize: 5 * 1024 * 1024 },
    fileFilter: (req, file, cb) => {
      const ok = /^image\//.test(file.mimetype);
      cb(ok ? null : new BadRequestException("Bad type"), ok);
    },
  }))
  async upload(@UploadedFile() file: Express.Multer.File) {
    return this.s3.upload(file);
  }
}

@Injectable()
export class S3Service {
  private client = new S3Client({ region: process.env.AWS_REGION });

  async upload(file: Express.Multer.File) {
    const key = `uploads/${randomUUID()}-${file.originalname}`;
    await this.client.send(new PutObjectCommand({
      Bucket: process.env.S3_BUCKET,
      Key: key,
      Body: file.buffer,
      ContentType: file.mimetype,
    }));
    return { key, url: `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${key}` };
  }
}

๐Ÿ”’ Security Checklist

  • โœ… Validate MIME type server-side, not just file extension.
  • โœ… Enforce size limit in Multer + S3 policy.
  • โœ… Use randomized keys to prevent guessing.
  • โœ… Set S3 bucket to private by default; serve via pre-signed URLs.
  • โœ… Strip EXIF/metadata from images before storing.
  • โœ… Scan for malware on user uploads (e.g., ClamAV, S3 Lambda triggers).
  • โœ… Set tight CORS rules on the bucket.

๐ŸŽฏ Likely Interview Questions

  1. How does file upload work in Node?
  2. What's a pre-signed URL?
  3. Why use pre-signed URLs for large files?
  4. How do you validate file types securely?
  5. How would you stream a 1 GB upload to S3? โ€” @aws-sdk/lib-storage's Upload does multipart streaming with retries.
  6. Story to tell: Mention your NestJS + AWS S3 work โ€” "I implemented a scalable file-upload service using NestJS and AWS S3 with pre-signed URLs and MIME validation. Server CPU dropped ~50% on the upload route once we moved to direct uploads."

โ† Error Handling | Index | Next: Pagination โ†’