Where Do Uploaded Files Actually Go? A Question I Should Have Asked Sooner.

I built a profile picture upload feature for a side project. It worked. Users picked a file, hit submit, and the server saved it. I was proud of myself for about three hours until I deployed to a free hosting tier and every single uploaded image vanished after the server restarted. Gone. No error, no warning, just empty folders and broken <img> tags.
The problem was obvious once someone pointed it out: I was saving files to the server's local filesystem, and the hosting platform wiped the disk on every deploy. I had never actually thought about where uploaded files end up, or why that matters.
The two places files can live
When a user uploads a file to your Express app, it has to go somewhere. You have two real options: save it on the same machine running your server, or send it to an external storage service.
Local storage means writing the file to a folder on your server's disk. The file sits right there next to your code. You can open it in your terminal, check its size, delete it manually. It is simple and fast for development.
External storage means sending the file to a service like AWS S3, Google Cloud Storage, or Cloudinary. The file lives on their servers, not yours. You get back a URL. Your server never holds the file long term.
Local storage:
User uploads photo -> Express saves to /uploads/photo.jpg -> served from your disk
External storage:
User uploads photo -> Express sends to S3 -> S3 returns a URL -> you save that URL in your database
For learning and local development, saving to disk is fine. For anything you deploy, external storage is almost always the right call. Your server's disk can fill up, get wiped on redeploy, or just stop existing if the hosting provider recycles your instance. S3 does not have these problems.
I will focus on local storage for the rest of this post because that is what you need to understand first. External storage is a topic for later, and it only clicks once you understand what local storage actually does and where it falls short.
Where files land when you use Multer
Multer is the standard library for handling file uploads in Express. You have probably seen it in tutorials already. When a user submits a form with a file, Multer grabs that file from the incoming request and writes it to a destination you specify.
const express = require('express');
const multer = require('multer');
const path = require('path');
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, 'uploads/');
},
filename: function (req, file, cb) {
const uniqueName = Date.now() + '-' + Math.round(Math.random() * 1e9);
cb(null, uniqueName + path.extname(file.originalname));
},
});
const upload = multer({ storage });
const app = express();
app.post('/upload', upload.single('avatar'), (req, res) => {
console.log(req.file);
// {
// fieldname: 'avatar',
// originalname: 'selfie.png',
// filename: '1717012345678-482947123.png',
// path: 'uploads/1717012345678-482947123.png',
// size: 204800
// }
res.json({ message: 'uploaded', file: req.file.filename });
});
The destination function tells Multer which folder to write into. The filename function controls what the file gets named on disk. I am generating a unique name with a timestamp and random number because if two users both upload photo.jpg, the second one would overwrite the first. That is a bug you will not notice until a user complains that their profile picture is someone else's face.
The folder structure after a few uploads looks like this:
That uploads/ folder does not create itself. You need to make it before running the server, or Multer throws an error. A quick way to handle this:
const fs = require('fs');
const uploadDir = './uploads';
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
Put that near the top of your server file, before Multer initializes.
Serving uploaded files so the browser can actually see them
Saving a file to uploads/ is only half the job. If a user uploads a profile picture, they need to see it in the browser. Right now, if you try to visit http://localhost:3000/uploads/1717012345678-482947123.png, Express returns a 404. The file is on disk but Express does not know you want to serve it.
Express does not serve files from your project folders by default. This is a good thing. You do not want someone guessing a URL and downloading your .env file or your server.js. Express only serves what you explicitly tell it to serve.
The way you tell it is express.static():
app.use('/uploads', express.static('uploads'));
That single line says: when someone requests a URL starting with /uploads, look for a matching file in the uploads/ folder on disk and send it back. Now http://localhost:3000/uploads/1717012345678-482947123.png returns the actual image.
The flow looks like this:
You can use any URL prefix you want. If you use app.use('/files', express.static('uploads')), the browser URL becomes /files/1717012345678-482947123.png even though the folder on disk is still called uploads. The URL prefix and the folder name do not have to match, though keeping them the same is less confusing.
A common pattern is serving the uploaded file URL from your API and storing it in your database:
app.post('/upload', upload.single('avatar'), async (req, res) => {
const fileUrl = `/uploads/${req.file.filename}`;
await User.findByIdAndUpdate(req.user._id, { avatar: fileUrl });
res.json({ avatar: fileUrl });
});
On the frontend, you render it as:
<img src="/uploads/1717012345678-482947123.png" alt="User avatar" />
That is the entire chain from upload to display.
The security stuff you should not skip
File uploads are one of the most common attack vectors in web applications. Skipping these checks is how people end up with malware on their servers or their entire uploads folder exposed.
Check the file type. Multer has a fileFilter option that lets you reject files before they are saved. Do not trust the file extension alone. A file named cute-cat.jpg can contain executable code if someone renamed it. Check the MIME type too:
const upload = multer({
storage,
fileFilter: function (req, file, cb) {
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Only JPEG, PNG, and WebP images are allowed'));
}
},
limits: {
fileSize: 5 * 1024 * 1024, // 5 MB
},
});
The limits option caps file size. Without it, someone can upload a 2GB file and fill your disk. Your server stops responding, all users are affected, and you are debugging at midnight wondering why apt update fails because the disk is full.
Never use the original filename. I showed the random name approach earlier, and there is a real reason for it beyond avoiding collisions. If you save files with the original name, a user can upload a file called ../../server.js and potentially overwrite your actual server code. This is called a path traversal attack. The generated filename with Date.now() and a random number removes this risk entirely because the name has no directory separators and no meaningful path components.
Do not serve your project root as static. I have seen beginners write app.use(express.static('.')) to serve "everything" and wonder why their .env file is accessible from a browser. Only point express.static() at a dedicated upload folder that contains nothing sensitive.
Add authentication where it matters. If uploads should only be visible to the user who uploaded them, do not serve them with express.static() at all. Instead, write a route that checks permissions before sending the file:
app.get('/my-files/:filename', authMiddleware, async (req, res) => {
const file = await File.findOne({
filename: req.params.filename,
owner: req.user._id,
});
if (!file) return res.status(404).json({ error: 'Not found' });
res.sendFile(path.resolve('uploads', file.filename));
});
This way, a user cannot access another user's files by guessing the filename. The route checks ownership first.
A quick note on what happens when you deploy
When you push code to services like Railway, Render, or Heroku, the filesystem is ephemeral. Files saved to uploads/ survive until the next deploy or the next server restart, and then they are gone. This is not a bug. The platform provisions a fresh container each time.
That is the point where local storage stops being enough and external storage like S3 starts making sense. You store the file somewhere permanent, save the returned URL in your database, and your server never worries about disk space or lost files again. I will cover that setup in a later post. For now, knowing that this limitation exists saves you the confusion I had when my uploaded images all disappeared on deploy.
References
AWS S3 documentation (for when you are ready for external storage)





