Letting users upload a file — an avatar, a screenshot, a document — is a common feature and an easy one to get wrong. The two things people forget are limits (nothing stops a 4 GB upload by default) and where the file actually goes. This guide handles both with multer, the standard Express upload middleware, and is honest about disk on a Falix server.
| At a glance | |
|---|---|
| You need | A server running the Node.js application |
| Background | Build an Express website + API |
| Plan | Any plan |
| Time | Twenty-five minutes |
Why you need a library for this
Uploads don't arrive as JSON — they come as multipart/form-data, a streamed format express.json() can't parse. multer reads that stream, writes each file where you tell it, and hands your route a tidy req.file describing what landed. Install express and multer from the Packages page (search each, install, restart).
Save to disk, with limits from the start
Here's a complete index.js. It writes uploads into an uploads/ folder, gives each a collision-proof name, caps the size at 2 MB, and only accepts images:
const express = require('express');
const multer = require('multer');
const fs = require('node:fs');
const app = express();
fs.mkdirSync('uploads', { recursive: true }); // ensure the folder exists
const storage = multer.diskStorage({
destination: 'uploads/',
filename: (req, file, cb) => {
// Never trust the original name — sanitize it and add a timestamp.
const safe = Date.now() + '-' + file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
cb(null, safe);
},
});
const upload = multer({
storage,
limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB — set this before you need it
fileFilter: (req, file, cb) => {
if (['image/png', 'image/jpeg', 'image/gif'].includes(file.mimetype)) cb(null, true);
else cb(new Error('Only PNG, JPEG, and GIF images are allowed'));
},
});
// upload.single('file') = one file, from a form field named "file"
app.post('/upload', upload.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
res.status(201).json({ stored: req.file.filename, size: req.file.size });
});
// Turn multer's errors into clean JSON instead of a stack trace.
app.use((err, req, res, next) => {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'File too large (max 2 MB)' });
}
return res.status(400).json({ error: err.message });
});
const PORT = process.env.SERVER_PORT || 8080;
app.listen(PORT, '0.0.0.0', () => console.log(`Listening on port ${PORT}`));
Three deliberate choices carry this file:
- The size limit is not optional.
limits.fileSizemakes multer stop reading and throwLIMIT_FILE_SIZEthe moment a file crosses the line — before it fills your disk. Pick a number that fits your use and set it now. - The filename is sanitized. A user could name a file
../../index.js. Stripping everything but letters, digits, dots and dashes and prefixing a timestamp kills both path tricks and name collisions. - The type is checked.
fileFilterrejects anything that isn't an image, so/uploadcan't become a place to stash arbitrary files.
Verify it works
Start the server and upload from any terminal with curl -F (the flag that sends multipart/form-data):
# a valid image -> 201 Created
curl -F "[email protected];type=image/png" http://YOUR_ADDRESS:PORT/upload
# no file -> 400
curl -X POST http://YOUR_ADDRESS:PORT/upload
# a .txt -> 400, rejected by fileFilter
curl -F "[email protected];type=text/plain" http://YOUR_ADDRESS:PORT/upload
# a 3 MB file -> 413, rejected by the size limit
curl -F "[email protected];type=image/png" http://YOUR_ADDRESS:PORT/upload
A successful upload returns {"stored":"...","size":...} and the file appears in uploads/ in your File Manager. That's the whole loop.
Serving the files back
Once files are on disk, hand them back to browsers with express.static, pointed at the same folder:
app.use('/uploads', express.static('uploads'));
// an uploaded photo is now at http://YOUR_ADDRESS:PORT/uploads/<filename>
⚠️ Heads up:
express.staticserves whatever is in the folder to anyone who knows the URL. If uploads should be private, don't serve the folder directly — gate a download route behind a login (Sessions, JWT & passwords) instead.
The disk reality on Falix
This is the part tutorials skip. Files you save go to your server's disk under /home/container, and that has consequences worth knowing before you build on it:
| Question | The honest answer |
|---|---|
| Do uploads survive a restart? | Yes — they're on disk, not in memory. |
| Do they survive a reinstall / switching the application? | No. That wipes the server's files. Back up anything you can't lose. |
| How much space is there? | Free plans share a modest disk; premium is effectively unlimited. Large media fills a small disk fast. |
| Are they backed up? | Only if you back them up — see the Backups page (free plans back up to your own Google Drive). |
| Is there a CDN in front? | No. Every download comes straight from your one server on your one port. |
The takeaway: the local disk is fine for small, replaceable files — avatars, attachments a database also references. For lots of large media, or anything you must never lose, keep the file somewhere durable (object storage like S3-compatible buckets) and store only its URL in your database. Multer also has a memoryStorage mode that keeps the upload in RAM as a buffer — handy when you immediately forward it elsewhere, but it competes with your app for the free plan's 2.5 GB, so cap the size hard.
Everything past this — multiple files (upload.array), mixed fields, custom storage engines — is standard multer. The official docs live at github.com/expressjs/multer.
Troubleshooting
req.fileis undefined — the form field name doesn't matchupload.single('file'), or the request wasn'tmultipart/form-data. Withcurl, use-F; in a browser form, setenctype="multipart/form-data"and matching field names.LIMIT_FILE_SIZE— the upload exceeded yourlimits.fileSize. That's the guard working; raise the limit only if you truly need to.ENOENT: no such file or directory 'uploads/'— the destination folder didn't exist. Thefs.mkdirSync('uploads', { recursive: true })line above prevents this.- Disk keeps filling — old uploads never get deleted on their own. Clean them up, or move files off the server to durable storage. Watch usage on the Console status bar.
Next steps
- Sessions, JWT & passwords — gate private uploads behind a login
- Add a database — store file metadata durably
- Image resize API with sharp — process images after upload
- Out of memory — when memoryStorage bites