Where Do Uploaded Files Actually Go? A Deep Dive into File Storage in Express.js
When someone uploads a profile picture or a PDF to your Express app, have you ever stopped to wonder — where does that file actually land? In this post, we'll peel back the curtain on how file uploads work, how to serve those files back to users, and how to do it all without opening up security holes.
1. Where Uploaded Files Are Stored
When a user uploads a file and your server receives it, the file needs a home. By default, libraries like Multer (the most popular Express middleware for handling multipart/form-data) store incoming files in one of two places:
Memory (RAM) — the file lives as a
Bufferin your application's memory until you do something with it.Disk (local filesystem) — the file is written to a folder on your server.
For most real-world applications, disk storage is the practical choice. A typical local folder structure looks like this:
your-express-app/
├── server.js
├── routes/
├── uploads/ ← uploaded files land here
│ ├── images/
│ │ ├── avatar-1712300000000.png
│ │ └── banner-1712300000001.jpg
│ └── documents/
│ └── resume-1712300000002.pdf
└── public/ ← static assets (CSS, JS, etc.)
Here's how to configure Multer to use disk storage and organise files into a dedicated folder:
const multer = require("multer");
const path = require("path");
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, "uploads/"); // files go into the /uploads folder
},
filename: (req, file, cb) => {
const uniqueName = `\({Date.now()}-\){file.originalname}`;
cb(null, uniqueName); // e.g. 1712300000000-resume.pdf
},
});
const upload = multer({ storage });
The Date.now() prefix ensures filenames are unique and never overwrite each other.
2. Local Storage vs. External Storage
Before you build your upload pipeline, you need to make a fundamental architectural decision: store files locally, or push them to an external service?
Local Storage
Files are saved directly on the disk of the machine running your Node.js server.
Pros:
Zero configuration — just a folder path
No additional cost
Works great for development and small projects
Cons:
Doesn't scale across multiple servers
Files are lost if the server is wiped or replaced
Increases your server's storage burden and backup complexity
External / Cloud Storage
Files are uploaded to a dedicated storage service like AWS S3, Cloudinary, Google Cloud Storage, or Backblaze B2.
Pros:
Infinitely scalable
Files persist independently of your server
Built-in CDN, redundancy, and access controls
Works seamlessly in multi-server or serverless environments
Cons:
Adds a dependency and cost
Slightly more complex setup
Rule of thumb: Use local storage during development and for small internal tools. Use cloud storage for any production app that serves real users.
3. Serving Static Files in Express
Once a file is saved to disk, your server needs a way to expose it over HTTP so users can actually access it. This is where Express's built-in express.static middleware comes in.
const express = require("express");
const app = express();
// Serve everything inside /uploads at the /uploads URL path
app.use("/uploads", express.static(path.join(__dirname, "uploads")));
This one line tells Express: "For any request that starts with /uploads, look for a matching file inside the ./uploads directory and send it."
How the Static File Serving Flow Works
Browser Express Server Disk
| | |
| GET /uploads/avatar.png | |
|--------------------------->| |
| | Read file from disk |
| |----------------------->|
| | File contents |
| |<-----------------------|
| 200 OK + image data | |
|<---------------------------| |
The browser requests
/uploads/avatar.pngExpress intercepts it via
express.staticIt maps the URL to the physical file
./uploads/avatar.pngThe file is streamed back to the browser
4. Accessing Uploaded Files via URL
After a file is uploaded and saved, you'll typically want to return a URL that the client can use to view or download it. Here's a complete example:
app.post("/upload", upload.single("file"), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: "No file uploaded" });
}
// Construct a public URL for the uploaded file
const fileUrl = `\({req.protocol}://\){req.get("host")}/uploads/${req.file.filename}`;
res.json({
message: "File uploaded successfully",
url: fileUrl,
filename: req.file.filename,
size: req.file.size,
mimetype: req.file.mimetype,
});
});
If your server is running at http://localhost:3000 and the uploaded file is named 1712300000000-avatar.png, the response will look like:
{
"message": "File uploaded successfully",
"url": "http://localhost:3000/uploads/1712300000000-avatar.png",
"filename": "1712300000000-avatar.png",
"size": 204800,
"mimetype": "image/png"
}
The client can now use that URL directly in an <img> tag, a download link, or store it in a database for later retrieval.
5. Security Considerations for File Uploads
File uploads are one of the most commonly exploited attack vectors in web applications. Here's what you need to lock down:
✅ Validate File Types
Never trust the file extension alone — it can be faked. Always validate the MIME type, and for critical applications, inspect the file's magic bytes.
const allowedMimeTypes = ["image/jpeg", "image/png", "application/pdf"];
const fileFilter = (req, file, cb) => {
if (allowedMimeTypes.includes(file.mimetype)) {
cb(null, true); // accept the file
} else {
cb(new Error("Invalid file type. Only JPEG, PNG, and PDF allowed."), false);
}
};
const upload = multer({ storage, fileFilter });
✅ Limit File Size
A missing size limit lets attackers exhaust your server's storage or memory with a single request.
const upload = multer({
storage,
limits: {
fileSize: 5 * 1024 * 1024, // 5 MB maximum
},
});
✅ Rename Files — Never Use the Original Name
User-supplied filenames can contain path traversal attacks like ../../etc/passwd. Always generate your own safe filename (as shown in the Multer config above with Date.now()).
✅ Store Uploads Outside the Web Root (Optional but Safer)
If your files shouldn't be publicly accessible without authentication, store them outside your Express static directory and serve them through a controlled route:
// Uploads stored at /private-uploads (not exposed via express.static)
// Only authenticated users can access files via this route
app.get("/files/:filename", authenticate, (req, res) => {
const safeName = path.basename(req.params.filename); // strip any path components
const filePath = path.join(__dirname, "private-uploads", safeName);
res.sendFile(filePath);
});
✅ Sanitise the uploads/ Folder from Version Control
Add your uploads folder to .gitignore so user data never ends up in your repository:
# .gitignore
uploads/
Putting It All Together
Here's a minimal but complete Express upload server incorporating everything above:
const express = require("express");
const multer = require("multer");
const path = require("path");
const app = express();
// --- Storage configuration ---
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, "uploads/"),
filename: (req, file, cb) => cb(null, `\({Date.now()}-\){path.basename(file.originalname)}`),
});
const fileFilter = (req, file, cb) => {
const allowed = ["image/jpeg", "image/png", "application/pdf"];
allowed.includes(file.mimetype)
? cb(null, true)
: cb(new Error("File type not allowed"), false);
};
const upload = multer({
storage,
fileFilter,
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
});
// --- Serve uploaded files statically ---
app.use("/uploads", express.static(path.join(__dirname, "uploads")));
// --- Upload endpoint ---
app.post("/upload", upload.single("file"), (req, res) => {
if (!req.file) return res.status(400).json({ error: "No file provided" });
const fileUrl = `\({req.protocol}://\){req.get("host")}/uploads/${req.file.filename}`;
res.json({ url: fileUrl });
});
// --- Error handling ---
app.use((err, req, res, next) => {
res.status(400).json({ error: err.message });
});
app.listen(3000, () => console.log("Server running on http://localhost:3000"));
Key Takeaways
Uploaded files land in a disk folder (or memory) depending on your Multer configuration.
Use local storage for dev/small projects; use cloud storage (S3, Cloudinary, etc.) for production.
express.staticmaps a URL path to a filesystem directory, enabling file serving with a single line.Return a constructed URL from your upload endpoint so clients know where to access the file.
Always validate MIME types, limit file sizes, rename files, and keep uploads out of version control.
File uploads seem simple on the surface, but getting the storage, serving, and security right is what separates a robust API from a fragile — and potentially dangerous — one. Build it right from the start.

