Uploading to Cloud Storage
Storing user uploads on your application server is a trap: disks fill up, files vanish when you redeploy a container, and you can’t scale horizontally because each instance has a different set of files. The fix is to push uploads straight to object storage — Amazon S3 or any S3-compatible service like Cloudflare R2, DigitalOcean Spaces, or MinIO. This page shows how to stream Multer uploads to S3 (either with the multer-s3 engine or the AWS SDK directly), how to hand out signed URLs so clients never proxy large files through your app, and the trade-offs between them.
Why keep files off the app server
When Express writes uploads to local disk, every file is tied to one machine. Object storage decouples that: any number of app instances can write to and read from the same bucket, storage is effectively unlimited, and durability is handled by the provider. Your Express process only ever touches metadata (keys, sizes, content types) — never the bytes after the initial transfer — which keeps memory and bandwidth predictable.
There are two patterns for getting a file into a bucket:
| Pattern | How it works | Best for |
|---|---|---|
| Server-side upload | Client uploads to Express, which forwards the bytes to S3. | Small/medium files, server-side validation or processing. |
| Direct (presigned) upload | Client uploads straight to S3 using a presigned URL; Express only signs it. | Large files, video, keeping load off the app server entirely. |
Streaming uploads with multer-s3
The multer-s3 package is a Multer storage engine: instead of writing to disk it streams each part directly into a bucket as it arrives, so the file never lands on your server’s filesystem or fully buffers in RAM.
npm install multer multer-s3 @aws-sdk/client-s3
const express = require("express");
const multer = require("multer");
const multerS3 = require("multer-s3");
const crypto = require("crypto");
const { S3Client } = require("@aws-sdk/client-s3");
const app = express();
const s3 = new S3Client({ region: process.env.AWS_REGION });
const upload = multer({
storage: multerS3({
s3,
bucket: process.env.S3_BUCKET,
contentType: multerS3.AUTO_CONTENT_TYPE,
key: (req, file, cb) => {
const ext = file.originalname.split(".").pop();
const name = crypto.randomBytes(16).toString("hex");
cb(null, `uploads/${name}.${ext}`);
}
}),
limits: { fileSize: 10 * 1024 * 1024 } // 10 MB
});
app.post("/files", upload.single("file"), (req, res) => {
res.json({
key: req.file.key,
location: req.file.location,
size: req.file.size,
type: req.file.contentType
});
});
When the upload finishes, req.file carries the S3-specific fields the engine set.
Output:
{
"key": "uploads/3f9a1c0b8e2d4f67a1b2c3d4e5f60718.png",
"location": "https://my-bucket.s3.us-east-1.amazonaws.com/uploads/3f9a1c0b8e2d4f67a1b2c3d4e5f60718.png",
"size": 48213,
"type": "image/png"
}
Tip:
contentType: multerS3.AUTO_CONTENT_TYPEdetects the MIME type from the file’s content rather than trusting the client. Without it, every object is stored asapplication/octet-streamand browsers will download instead of rendering it.
Uploading with the AWS SDK directly
If you need to transform a file first (resize an image, scan for malware, extract metadata) or you’re using a non-S3 engine, pair multer.memoryStorage() with a manual PutObjectCommand. The file lands in req.file.buffer, you process it, then push the result.
const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3");
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 }
});
app.post("/avatar", upload.single("avatar"), async (req, res, next) => {
try {
if (!req.file) return res.status(400).json({ error: "No file" });
const key = `avatars/${crypto.randomUUID()}.webp`;
await s3.send(
new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
Body: req.file.buffer,
ContentType: req.file.mimetype
})
);
res.status(201).json({ key });
} catch (err) {
next(err);
}
});
For S3-compatible providers, point the client at their endpoint — for example Cloudflare R2:
const s3 = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_KEY_ID,
secretAccessKey: process.env.R2_SECRET
}
});
Serving files back with signed URLs
Keep buckets private. Rather than making objects public, generate a short-lived presigned GET URL on demand so only authorized users can read a file, and the link expires automatically.
npm install @aws-sdk/s3-request-presigner
const { GetObjectCommand } = require("@aws-sdk/client-s3");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
app.get("/files/:key/url", async (req, res, next) => {
try {
const command = new GetObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: `uploads/${req.params.key}`
});
const url = await getSignedUrl(s3, command, { expiresIn: 300 }); // 5 min
res.json({ url });
} catch (err) {
next(err);
}
});
Output:
{
"url": "https://my-bucket.s3.us-east-1.amazonaws.com/uploads/3f9a...718.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300&X-Amz-Signature=..."
}
Direct browser uploads (presigned PUT)
For large files, don’t proxy the bytes through Express at all. Sign a PutObjectCommand and return the URL; the browser then PUTs the file straight to S3. Your server stays idle during the transfer.
const { PutObjectCommand } = require("@aws-sdk/client-s3");
app.post("/uploads/sign", async (req, res, next) => {
try {
const key = `uploads/${crypto.randomUUID()}`;
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
ContentType: req.body.contentType
});
const url = await getSignedUrl(s3, command, { expiresIn: 120 });
res.json({ uploadUrl: url, key });
} catch (err) {
next(err);
}
});
The client then runs fetch(uploadUrl, { method: "PUT", body: file }). Configure the bucket’s CORS policy to allow PUT from your front-end origin, and validate the returned key server-side before recording it in your database.
Express 5 notes
The AWS SDK v3 is fully promise-based, so all of the handlers above are plain async functions. On Express 5 a rejected promise in an async route is forwarded to your error middleware automatically — you can drop the surrounding try/catch and the next(err) calls. On Express 4 the try/catch shown here (or an asyncHandler wrapper) is required, otherwise an S3 failure leaves the request hanging.
Best Practices
- Keep buckets private and serve reads through short-lived presigned URLs, never public ACLs.
- Use presigned
PUTuploads for large files so bytes never flow through your Express process. - Always set
ContentType(ormulterS3.AUTO_CONTENT_TYPE) so files render correctly and aren’t forced to download. - Generate random, unguessable keys (
crypto.randomUUID()); never use the client’soriginalnameas the object key. - Enforce
limits.fileSizein Multer even for direct uploads, and validate file type by magic bytes when content matters. - Store AWS credentials in environment variables or an IAM role — never hard-code them — and grant the narrowest bucket permissions needed.
- Record only the object key in your database and resolve URLs at request time, so you can rotate buckets or CDNs without rewriting rows.