Build a File Upload API with Node.js and Cloud Storage
File uploads seem simple until they aren't. This guide builds a production-ready upload API — with validation, security, and cloud storage — from the ground up.
Loading articles...
File uploads seem simple until they aren't. This guide builds a production-ready upload API — with validation, security, and cloud storage — from the ground up.

By the end of this guide you'll have a Node.js REST API that:
multipart/form-dataThe stack: Node.js, Express, Multer, and your cloud provider of choice.
mkdir file-upload-api && cd file-upload-api
npm init -y
npm install express multer sharp dotenv
npm install -D typescript @types/node @types/express @types/multer tsx
Install your cloud SDK:
# Cloudinary
npm install cloudinary
# AWS S3
npm install @aws-sdk/client-s3 @aws-sdk/lib-storage
Create the base structure:
file-upload-api/
├── src/
│ ├── config/
│ │ └── storage.ts
│ ├── middleware/
│ │ ├── upload.ts
│ │ └── validate.ts
│ ├── routes/
│ │ └── upload.ts
│ ├── services/
│ │ ├── cloudinary.service.ts
│ │ └── s3.service.ts
│ └── index.ts
├── .env
└── tsconfig.json
Your .env:
PORT=3000
# Cloudinary
CLOUDINARY_CLOUD_NAME=your_cloud_name
CLOUDINARY_API_KEY=your_api_key
CLOUDINARY_API_SECRET=your_api_secret
# AWS S3
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
AWS_REGION=us-east-1
S3_BUCKET_NAME=your_bucket_name
Multer is the de facto middleware for handling multipart/form-data in Express. It parses incoming form data and makes files available on req.file (single) or req.files (multiple).
Multer has two storage strategies:
| Strategy | Where files land | Best for |
|---|---|---|
memoryStorage | RAM as Buffer | Cloud uploads, image processing |
diskStorage | Local filesystem | Large files, video, temporary files |
For cloud uploads, memoryStorage is the right choice — you get the file as a Buffer and stream it directly to S3 or Cloudinary without writing to disk.
// src/middleware/upload.ts
import multer from 'multer'
import { Request } from 'express'
const storage = multer.memoryStorage()
// File filter — first line of defense
const fileFilter = (
req: Request,
file: Express.Multer.File,
cb: multer.FileFilterCallback
) => {
const ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
]
if (ALLOWED_MIME_TYPES.includes(file.mimetype)) {
cb(null, true)
} else {
cb(new Error(`File type not allowed: ${file.mimetype}`))
}
}
export const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 5 * 1024 * 1024, // 5 MB
files: 1,
},
})
Note on
limits.fileSize: Multer enforces this limit during parsing, before your route handler fires. This prevents the server from buffering a 4 GB file before rejecting it — crucial for preventing memory exhaustion attacks.
Multer's fileFilter checks the MIME type the client claims — but the client controls that header. A malicious actor can upload a PHP script with Content-Type: image/jpeg and it'll pass the filter.
Real validation means inspecting the file's magic bytes — the first few bytes of the binary content that identify its actual format, regardless of what the client says.
npm install file-type
// src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express'
import { fileTypeFromBuffer } from 'file-type'
const ALLOWED_TYPES = new Set([
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
])
const MAX_DIMENSIONS = { width: 8000, height: 8000 }
const MIN_DIMENSIONS = { width: 10, height: 10 }
export async function validateUpload(
req: Request,
res: Response,
next: NextFunction
) {
if (!req.file) {
return res.status(400).json({ error: 'No file provided' })
}
// 1. Magic byte validation
const detected = await fileTypeFromBuffer(req.file.buffer)
if (!detected || !ALLOWED_TYPES.has(detected.mime)) {
return res.status(422).json({
error: 'Invalid file content. Only JPEG, PNG, WebP, and GIF are allowed.',
})
}
// Overwrite the client-supplied MIME with the detected one
req.file.mimetype = detected.mime
next()
}
Original filenames are a security risk — they can contain path traversal sequences (../../etc/passwd), null bytes, or reserved OS characters. Never use them verbatim for storage keys.
import { randomUUID } from 'crypto'
import path from 'path'
function generateSafeFilename(originalName: string, mimeType: string): string {
const ext = mimeType.split('/')[1].replace('jpeg', 'jpg')
return `${randomUUID()}.${ext}`
}
Cloudinary is an excellent choice for images — it handles format conversion, responsive resizing, and CDN delivery out of the box.
// src/services/cloudinary.service.ts
import { v2 as cloudinary } from 'cloudinary'
cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME!,
api_key: process.env.CLOUDINARY_API_KEY!,
api_secret: process.env.CLOUDINARY_API_SECRET!,
secure: true,
})
interface UploadResult {
url: string
publicId: string
width: number
height: number
bytes: number
format: string
}
export async function uploadToCloudinary(
buffer: Buffer,
folder = 'uploads'
): Promise<UploadResult> {
return new Promise((resolve, reject) => {
const stream = cloudinary.uploader.upload_stream(
{
folder,
resource_type: 'image',
// Auto-generate a unique public_id
unique_filename: true,
// Strip EXIF metadata (privacy)
exif: false,
// Enforce image format
allowed_formats: ['jpg', 'png', 'webp', 'gif'],
},
(error, result) => {
if (error || !result) return reject(error)
resolve({
url: result.secure_url,
publicId: result.public_id,
width: result.width,
height: result.height,
bytes: result.bytes,
format: result.format,
})
}
)
stream.end(buffer)
})
}
One of Cloudinary's killer features is server-side transformation. You can normalize uploads before storing:
// Resize to max 1200px wide, convert to WebP, quality 80
cloudinary.uploader.upload_stream(
{
folder: 'uploads',
transformation: [
{ width: 1200, crop: 'limit' },
{ format: 'webp', quality: 80 },
],
},
callback
)
S3 is the right choice when you need fine-grained IAM control, compliance, or you're already deep in the AWS ecosystem.
// src/services/s3.service.ts
import {
S3Client,
PutObjectCommand,
DeleteObjectCommand,
} from '@aws-sdk/client-s3'
import { randomUUID } from 'crypto'
const s3 = new S3Client({
region: process.env.AWS_REGION!,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
})
const BUCKET = process.env.S3_BUCKET_NAME!
interface UploadResult {
url: string
key: string
bucket: string
}
export async function uploadToS3(
buffer: Buffer,
mimeType: string,
folder = 'uploads'
): Promise<UploadResult> {
const ext = mimeType.split('/')[1].replace('jpeg', 'jpg')
const key = `${folder}/${randomUUID()}.${ext}`
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: buffer,
ContentType: mimeType,
// Prevent the upload from being publicly accessible by default
// Use CloudFront or pre-signed URLs to serve files
ACL: 'private',
// Strip any metadata
Metadata: {},
// Server-side encryption
ServerSideEncryption: 'AES256',
})
)
// Construct URL (use CloudFront domain in production)
const url = `https://${BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`
return { url, key, bucket: BUCKET }
}
export async function deleteFromS3(key: string): Promise<void> {
await s3.send(
new DeleteObjectCommand({
Bucket: BUCKET,
Key: key,
})
)
}
Never set your bucket to public. Instead, configure a bucket policy that only allows your application's IAM role:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::YOUR_ACCOUNT_ID:role/YOUR_APP_ROLE"
},
"Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::YOUR_BUCKET_NAME/uploads/*"
}
]
}
Before uploading, you can normalize images server-side using Sharp — the fastest Node.js image processing library.
// src/services/image.service.ts
import sharp from 'sharp'
interface ProcessOptions {
maxWidth?: number
maxHeight?: number
quality?: number
stripMetadata?: boolean
}
export async function processImage(
buffer: Buffer,
options: ProcessOptions = {}
): Promise<{ buffer: Buffer; metadata: sharp.Metadata }> {
const {
maxWidth = 2048,
maxHeight = 2048,
quality = 85,
stripMetadata = true,
} = options
let pipeline = sharp(buffer)
// Strip EXIF (location data, device info, etc.)
if (stripMetadata) {
pipeline = pipeline.withMetadata({})
}
// Resize if larger than max dimensions
pipeline = pipeline.resize(maxWidth, maxHeight, {
fit: 'inside', // preserve aspect ratio
withoutEnlargement: true, // never upscale
})
// Re-encode with controlled quality
pipeline = pipeline.webp({ quality })
const processed = await pipeline.toBuffer()
const metadata = await sharp(processed).metadata()
return { buffer: processed, metadata }
}
Stripping metadata is not optional for user-facing apps — photos taken on smartphones embed GPS coordinates, device model, and sometimes the owner's name into EXIF data. Without stripping, you're redistributing that private information to anyone who downloads the image.
Now assemble the pieces:
// src/routes/upload.ts
import { Router, Request, Response } from 'express'
import { upload } from '../middleware/upload'
import { validateUpload } from '../middleware/validate'
import { processImage } from '../services/image.service'
import { uploadToCloudinary } from '../services/cloudinary.service'
const router = Router()
router.post(
'/upload',
upload.single('file'), // 1. Parse multipart
validateUpload, // 2. Validate magic bytes
async (req: Request, res: Response) => {
try {
const file = req.file!
// 3. Process: resize, strip metadata, re-encode
const { buffer, metadata } = await processImage(file.buffer, {
maxWidth: 1920,
stripMetadata: true,
quality: 85,
})
// 4. Upload to cloud storage
const result = await uploadToCloudinary(buffer, 'user-uploads')
// 5. Respond
return res.status(201).json({
success: true,
data: {
url: result.url,
publicId: result.publicId,
width: result.width,
height: result.height,
size: result.bytes,
format: result.format,
},
})
} catch (error) {
console.error('[Upload Error]', error)
return res.status(500).json({
success: false,
error: 'Upload failed. Please try again.',
})
}
}
)
export default router
Multer throws specific error types you should handle explicitly:
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express'
import multer from 'multer'
export function uploadErrorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction
) {
if (err instanceof multer.MulterError) {
const messages: Record<string, string> = {
LIMIT_FILE_SIZE: 'File is too large. Maximum size is 5 MB.',
LIMIT_FILE_COUNT: 'Too many files. Only one file per request.',
LIMIT_UNEXPECTED_FILE: 'Unexpected field name. Use "file" as the field name.',
}
return res.status(413).json({
success: false,
error: messages[err.code] ?? 'Upload error.',
})
}
if (err.message.startsWith('File type not allowed')) {
return res.status(415).json({
success: false,
error: err.message,
})
}
next(err)
}
Register it after your routes in index.ts:
// src/index.ts
import express from 'express'
import uploadRouter from './routes/upload'
import { uploadErrorHandler } from './middleware/errorHandler'
const app = express()
app.use('/api', uploadRouter)
app.use(uploadErrorHandler) // Must be last
app.listen(process.env.PORT ?? 3000, () => {
console.log(`Server running on port ${process.env.PORT ?? 3000}`)
})
An unprotected upload endpoint is an open invitation to abuse — storage costs money, and processing images consumes CPU. Add rate limiting as a baseline:
npm install express-rate-limit
import rateLimit from 'express-rate-limit'
export const uploadRateLimit = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20, // 20 uploads per window per IP
standardHeaders: true,
legacyHeaders: false,
message: {
success: false,
error: 'Too many uploads from this IP. Please try again later.',
},
})
Apply it before the Multer middleware:
router.post('/upload', uploadRateLimit, upload.single('file'), validateUpload, handler)
For authenticated APIs, rate limit by user ID rather than IP — IP-based limits are easily bypassed with VPNs.
# Upload a JPEG
curl -X POST http://localhost:3000/api/upload \
-F "file=@/path/to/photo.jpg" \
-H "Accept: application/json"
# Try to upload a text file disguised as an image (should be rejected)
curl -X POST http://localhost:3000/api/upload \
-F "file=@/path/to/script.php;type=image/jpeg"
async function uploadImage(file: File) {
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
// Do NOT set Content-Type manually — the browser sets it with the boundary
})
if (!response.ok) {
const { error } = await response.json()
throw new Error(error)
}
return response.json()
}
Common mistake: Setting
Content-Type: multipart/form-datamanually in fetch. Don't. The browser must set it automatically so it can include theboundaryparameter — without it, the server can't parse the body.
Success (201)
{
"success": true,
"data": {
"url": "https://res.cloudinary.com/your-cloud/image/upload/v1234567890/user-uploads/uuid.webp",
"publicId": "user-uploads/uuid",
"width": 1920,
"height": 1080,
"size": 142310,
"format": "webp"
}
}
File too large (413)
{
"success": false,
"error": "File is too large. Maximum size is 5 MB."
}
Invalid content (422)
{
"success": false,
"error": "Invalid file content. Only JPEG, PNG, WebP, and GIF are allowed."
}
Before deploying, verify each of these:
Streaming large files: memoryStorage loads the entire file into RAM. For files larger than ~10 MB, switch to diskStorage and stream from disk to S3 using the @aws-sdk/lib-storage Upload class — it handles multipart uploads automatically.
Multiple upload variants: Generate thumbnails on upload rather than on every request. Use Sharp to create multiple sizes, upload all variants in parallel with Promise.all, and store the key set in your database.
const [original, thumbnail] = await Promise.all([
uploadToS3(buffer, 'image/webp', 'originals'),
uploadToS3(thumbBuffer, 'image/webp', 'thumbnails'),
])
Virus scanning: For platforms where untrusted users upload arbitrary files, integrate a scanning step between processing and cloud upload. AWS offers Amazon Macie for S3; Cloudinary has third-party add-ons; or run ClamAV in a sidecar container.
Pre-signed URLs: For private S3 objects, never expose the s3.amazonaws.com URL to clients. Generate short-lived pre-signed URLs server-side on demand:
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { GetObjectCommand } from '@aws-sdk/client-s3'
const url = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: BUCKET, Key: key }),
{ expiresIn: 3600 } // 1 hour
)
A robust file upload API is more than a Multer configuration and a cloud SDK call. The layers that matter are:
Each layer has a distinct responsibility. Together they form a pipeline you can trust with user-submitted files in production.

Create elementAI Explainer Videos That Convert With Simple Text Prompts.
Learn More