DEV Community

Harman Panwar
Harman Panwar

Posted on

Storing Uploaded Files and Serving Them in Express

File Storage and Serving in Node.js: From Upload to Access

When users upload files to your application — profile pictures, documents, or media — those files need to go somewhere. They need to be stored reliably, served efficiently, and protected from abuse. This guide explains where uploaded files live, how local storage differs from external storage, how Express serves files statically, and the security practices that keep your application safe.


Where Uploaded Files Are Stored

The Storage Decision

Every file upload creates data that must persist beyond the current request. Where you store that data is one of the first architectural decisions you make.

Storage Type What It Means Examples
Local disk storage Files saved on the same server's hard drive or SSD ./uploads/, /var/www/files/
External/cloud storage Files saved on a separate service accessed via API AWS S3, Google Cloud Storage, Azure Blob
Database storage File contents stored as binary data in a database PostgreSQL BYTEA, MongoDB GridFS

For learning and small applications, local disk storage is the natural starting point. It requires no external accounts, no API keys, and no network configuration. The trade-off is that files live on a single server and disappear if that server is replaced.

Local Storage Folder Structure

A well-organized upload directory prevents chaos as your application grows:

project-root/
├── server.js
├── uploads/                    ← Main upload directory
│   ├── avatars/                ← Profile pictures
│   │   ├── 1715432100000-user1.jpg
│   │   └── 1715432200000-user2.png
│   ├── documents/              ← User uploads
│   │   └── 1715432300000-report.pdf
│   └── temp/                   ← Temporary processing files
│       └── processing-123.tmp
└── public/                     ← Static assets (CSS, JS, images)
    └── logo.png
Enter fullscreen mode Exit fullscreen mode

Why organize by type?

  • Easier to apply different rules (avatars limited to images, documents allow PDFs)
  • Simpler cleanup (purge temp/ folder regularly)
  • Better permission control (avatars public, documents private)

Creating the Upload Directory

Always ensure the directory exists before saving files:

const fs = require('fs');
const path = require('path');

const uploadDir = path.join(__dirname, 'uploads');

// Create directory if it doesn't exist
if (!fs.existsSync(uploadDir)) {
  fs.mkdirSync(uploadDir, { recursive: true });
}
Enter fullscreen mode Exit fullscreen mode

Local Storage vs External Storage Concept

The Fundamental Trade-Off

Aspect Local Storage External/Cloud Storage
Setup complexity Zero — just a folder Requires account, API keys, SDK
Cost Free (uses server disk) Pay per GB stored and transferred
Scalability Tied to one server Unlimited, independent of app servers
Durability Disk failure = data loss 99.999999999% durability (S3)
Server replacement Files must be migrated Unchanged — files live elsewhere
CDN integration Manual setup required Built-in or one-click
Backup Your responsibility Usually automatic
Access control File system permissions IAM policies, signed URLs

When to Use Each

Use local storage when:

  • Learning or prototyping
  • Building a small application with single server
  • Files are temporary (cached thumbnails, processing intermediates)
  • You need zero latency file access
  • Budget is constrained

Use external storage when:

  • Running multiple servers behind a load balancer
  • Files must survive server replacement
  • Serving files to a global audience (CDN)
  • Storing large files (video, high-res images)
  • Compliance requires durable, versioned storage

The Migration Path

Most applications start local and migrate to cloud storage as they grow. The key is designing your storage layer so the migration is painless:

// Abstract storage behind an interface
class StorageService {
  async save(file, destination) { /* implementation */ }
  async read(filePath) { /* implementation */ }
  async delete(filePath) { /* implementation */ }
  getUrl(filePath) { /* implementation */ }
}

// Local implementation (start here)
class LocalStorage extends StorageService {
  async save(file, destination) {
    const targetPath = path.join('./uploads', destination);
    await fs.promises.rename(file.path, targetPath);
    return targetPath;
  }
  getUrl(filePath) {
    return `/uploads/${path.basename(filePath)}`;
  }
}

// Cloud implementation (migrate to this later)
class S3Storage extends StorageService {
  async save(file, destination) {
    // Upload to S3
    return s3Url;
  }
  getUrl(filePath) {
    return `https://cdn.example.com/${filePath}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

Serving Static Files in Express

The Static File Serving Concept

Static file serving means making files on your server's disk accessible via HTTP URLs. When a browser requests http://your-site.com/uploads/photo.jpg, the server reads that file from disk and sends it back as the response.

Express provides built-in middleware for this: express.static().

Basic Static Serving

const express = require('express');
const path = require('path');
const app = express();

// Serve files from the 'public' directory at the root URL
app.use(express.static('public'));

// Files in public/ are now accessible:
// public/style.css    →  http://localhost:3000/style.css
// public/logo.png     →  http://localhost:3000/logo.png
// public/js/app.js   →  http://localhost:3000/js/app.js
Enter fullscreen mode Exit fullscreen mode

Serving Uploads with a URL Prefix

// Serve uploads at /uploads URL path
app.use('/uploads', express.static('uploads'));

// Files in uploads/ are accessible via:
// uploads/avatars/123.jpg  →  http://localhost:3000/uploads/avatars/123.jpg
// uploads/docs/report.pdf  →  http://localhost:3000/uploads/docs/report.pdf
Enter fullscreen mode Exit fullscreen mode

The first argument ('/uploads') is the URL prefix. The second argument ('uploads') is the folder path on disk. They can be different:

// Files in './storage/user-files/' served at '/files/'
app.use('/files', express.static('./storage/user-files'));
Enter fullscreen mode Exit fullscreen mode

Absolute vs Relative Paths

// Relative path (relative to where node is started)
app.use('/uploads', express.static('uploads'));

// Absolute path (recommended — works regardless of start location)
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
Enter fullscreen mode Exit fullscreen mode

Always use absolute paths in production. Relative paths break if you start your application from a different directory.

Multiple Static Directories

// Public assets (CSS, JS, images anyone can see)
app.use(express.static(path.join(__dirname, 'public')));

// Uploaded files (avatars, documents)
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));

// Admin dashboard assets (protected by middleware)
app.use('/admin-assets', authenticateAdmin, express.static(path.join(__dirname, 'admin')));
Enter fullscreen mode Exit fullscreen mode

Accessing Uploaded Files via URL

The Complete Upload-to-Access Flow

1. User selects file in browser
       ↓
2. Browser sends multipart form-data to POST /upload
       ↓
3. Multer saves file to ./uploads/avatars/1715432100000-photo.jpg
       ↓
4. Server responds with file URL: /uploads/avatars/1715432100000-photo.jpg
       ↓
5. Browser stores URL (in database or state)
       ↓
6. Later, browser requests: GET /uploads/avatars/1715432100000-photo.jpg
       ↓
7. express.static reads file from disk and streams it to browser
       ↓
8. Browser displays the image
Enter fullscreen mode Exit fullscreen mode

Complete Upload and Serve Example

const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');

const app = express();

// Ensure upload directories exist
const uploadDirs = ['uploads/avatars', 'uploads/documents'];
uploadDirs.forEach(dir => {
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir, { recursive: true });
  }
});

// Configure Multer storage
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    // Route files to appropriate subfolder based on type
    if (file.mimetype.startsWith('image/')) {
      cb(null, 'uploads/avatars');
    } else {
      cb(null, 'uploads/documents');
    }
  },
  filename: (req, file, cb) => {
    // Prevent filename collisions with timestamp + random suffix
    const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
    const ext = path.extname(file.originalname);
    cb(null, uniqueSuffix + ext);
  }
});

const upload = multer({ 
  storage,
  limits: { fileSize: 5 * 1024 * 1024 } // 5MB limit
});

// Upload endpoint
app.post('/upload/avatar', upload.single('avatar'), (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: 'No file uploaded' });
  }

  // Construct the public URL
  const fileUrl = `/uploads/avatars/${req.file.filename}`;

  res.json({
    message: 'Upload successful',
    fileUrl: fileUrl,
    originalName: req.file.originalname,
    size: req.file.size
  });
});

// Serve uploaded files statically
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));

// List all uploaded files
app.get('/files', (req, res) => {
  const avatars = fs.readdirSync('uploads/avatars').map(name => ({
    name,
    url: `/uploads/avatars/${name}`,
    type: 'image'
  }));

  const documents = fs.readdirSync('uploads/documents').map(name => ({
    name,
    url: `/uploads/documents/${name}`,
    type: 'document'
  }));

  res.json({ avatars, documents });
});

app.listen(3000, () => {
  console.log('Server: http://localhost:3000');
  console.log('Upload: POST /upload/avatar (multipart, field: "avatar")');
  console.log('Access: GET /uploads/avatars/<filename>');
  console.log('List: GET /files');
});
Enter fullscreen mode Exit fullscreen mode

Testing the Flow

# 1. Upload a file
curl -X POST http://localhost:3000/upload/avatar \
  -F "avatar=@/path/to/your/photo.jpg"

# Response:
# {
#   "message": "Upload successful",
#   "fileUrl": "/uploads/avatars/1715432100000-123456789.jpg",
#   "originalName": "photo.jpg",
#   "size": 245678
# }

# 2. Access the file via URL
curl http://localhost:3000/uploads/avatars/1715432100000-123456789.jpg

# 3. View in browser
# Open: http://localhost:3000/uploads/avatars/1715432100000-123456789.jpg
Enter fullscreen mode Exit fullscreen mode

Security Considerations for Uploads

File uploads are one of the most dangerous features in web applications. A mishandled upload can lead to server compromise, data theft, or denial of service.

1. File Type Validation

Never trust the filename or browser-provided MIME type. Validate both:

const upload = multer({
  storage,
  fileFilter: (req, file, cb) => {
    // Whitelist allowed types — reject everything else
    const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'];

    if (allowedTypes.includes(file.mimetype)) {
      cb(null, true);
    } else {
      cb(new Error(`File type ${file.mimetype} not allowed`), false);
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

Why this matters: A user can rename malware.exe to photo.jpg and trick a naive extension check. MIME type checking is more reliable, though still not foolproof. For critical security, inspect file contents (magic numbers) server-side.

2. File Size Limits

Prevent users from filling your disk:

const upload = multer({
  limits: {
    fileSize: 5 * 1024 * 1024,      // 5MB per file
    files: 5,                        // Max 5 files per upload
    fields: 10                       // Max 10 text fields
  }
});
Enter fullscreen mode Exit fullscreen mode

3. Filename Sanitization

Never use the original filename directly. User-provided filenames can contain:

  • Path traversal (../../../etc/passwd)
  • Null bytes (file.jpg\0.exe)
  • Special characters that break systems
const storage = multer.diskStorage({
  filename: (req, file, cb) => {
    // Generate safe filename: timestamp-random.ext
    const ext = path.extname(file.originalname).toLowerCase();
    const safeName = `${Date.now()}-${Math.random().toString(36).substring(2)}${ext}`;
    cb(null, safeName);
  }
});
Enter fullscreen mode Exit fullscreen mode

4. Path Traversal Prevention

When serving files dynamically (not through express.static), sanitize the requested path:

// DANGEROUS: Allows directory traversal
app.get('/download/:filename', (req, res) => {
  const filePath = path.join(__dirname, 'uploads', req.params.filename);
  res.sendFile(filePath);
});
// Attacker requests: /download/../../../etc/passwd

// SAFE: Prevents directory traversal
app.get('/download/:filename', (req, res) => {
  const requestedFile = path.basename(req.params.filename); // Strip path
  const filePath = path.join(__dirname, 'uploads', requestedFile);

  // Ensure the resolved path is still inside uploads directory
  const resolvedPath = path.resolve(filePath);
  const uploadDir = path.resolve(path.join(__dirname, 'uploads'));

  if (!resolvedPath.startsWith(uploadDir)) {
    return res.status(403).json({ error: 'Access denied' });
  }

  if (!fs.existsSync(filePath)) {
    return res.status(404).json({ error: 'File not found' });
  }

  res.sendFile(filePath);
});
Enter fullscreen mode Exit fullscreen mode

5. Executable File Prevention

Never allow uploads to directories from which the server executes code:

// NEVER DO THIS:
app.use('/uploads', express.static(path.join(__dirname, 'public')));
// If user uploads PHP/JS file, visiting the URL might execute it

// SAFE: Uploads directory is separate from executable code
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
// Ensure web server is NOT configured to execute scripts from uploads/
Enter fullscreen mode Exit fullscreen mode

6. Access Control

Not all uploaded files should be public:

// Public files (avatars, public documents)
app.use('/uploads/public', express.static(path.join(__dirname, 'uploads/public')));

// Private files — require authentication
app.get('/uploads/private/:filename', authenticateUser, (req, res) => {
  const filename = path.basename(req.params.filename);
  const filePath = path.join(__dirname, 'uploads/private', filename);

  // Optional: check if this user owns this file
  // const fileOwner = getFileOwner(filename);
  // if (fileOwner !== req.user.id) return res.status(403).send('Not authorized');

  res.sendFile(filePath);
});
Enter fullscreen mode Exit fullscreen mode

7. Storage Quotas

Prevent individual users from consuming all disk space:

async function checkUserQuota(userId, newFileSize) {
  const userFiles = await getUserFiles(userId);
  const totalUsed = userFiles.reduce((sum, f) => sum + f.size, 0);
  const quota = 100 * 1024 * 1024; // 100MB per user

  if (totalUsed + newFileSize > quota) {
    throw new Error('Storage quota exceeded');
  }
}
Enter fullscreen mode Exit fullscreen mode

Security Checklist

Practice Risk If Ignored
Validate file types Server executes malicious uploads
Limit file sizes Disk space exhaustion (DoS)
Sanitize filenames Path traversal attacks
Separate upload and code directories Remote code execution
Authenticate private file access Data leaks to unauthorized users
Set storage quotas Single user fills entire disk
Use HTTPS for uploads Intercepted files in transit

Summary

Concept What It Means Key Consideration
Local storage Files saved on application server's disk Simple, fast, but tied to one server
External storage Files saved on cloud services (S3, etc.) Scalable, durable, but adds complexity
Static serving Making disk files accessible via HTTP URLs express.static() maps folders to URL paths
File type validation Restricting allowed uploads to safe formats Prevents execution of malicious files
Path traversal Attack using ../ to access unauthorized directories Always use path.basename() and validate resolved paths
Access control Ensuring only authorized users can access files Public files via static; private files via authenticated routes

File handling seems simple — receive bytes, save bytes, serve bytes — but the security implications are significant. A single missed validation can turn an innocent avatar upload into a server compromise. Start with local storage for simplicity, validate everything, separate uploads from executable code, and migrate to external storage when scale demands it. The fundamentals of safe file handling apply regardless of where the bits ultimately live.

Remember: Your upload folder is like a mailbox that accepts packages from strangers. You would not let strangers drop any package in your living room, nor would you let them address packages to your neighbor's house. Treat your uploads directory with the same caution: inspect what arrives, control where it goes, and never let unknown packages sit where they could cause harm.

Top comments (0)