Cloud Storage with Cloudinary — Production File Uploads

Local disk storage is convenient in development but unsuitable for production — files are lost when the server restarts or scales, and serving images from the same server as your API adds unnecessary load. Cloudinary is the most popular cloud media management platform for MERN applications: you upload a file to your Express server, Cloudinary’s Node.js SDK pushes it to the CDN, and you receive back a permanent, CDN-delivered URL that you store in MongoDB. In this lesson you will swap Multer’s disk storage for Cloudinary storage using multer-storage-cloudinary, configure transformations, and update the MERN Blog’s upload routes to use the cloud.

Setting Up Cloudinary

cd server
npm install cloudinary multer-storage-cloudinary
# server/.env
CLOUDINARY_CLOUD_NAME=your_cloud_name
CLOUDINARY_API_KEY=your_api_key
CLOUDINARY_API_SECRET=your_api_secret

# Get these from https://cloudinary.com → Dashboard → API Keys
# Never commit API credentials to source control

The Cloudinary Configuration Module

// server/src/config/cloudinary.js
const cloudinary = require('cloudinary').v2;

cloudinary.config({
  cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
  api_key:    process.env.CLOUDINARY_API_KEY,
  api_secret: process.env.CLOUDINARY_API_SECRET,
  secure:     true, // always use HTTPS URLs
});

module.exports = cloudinary;

Multer with Cloudinary Storage

// server/src/config/multer.js — production version with Cloudinary
const multer                   = require('multer');
const { CloudinaryStorage }    = require('multer-storage-cloudinary');
const cloudinary               = require('./cloudinary');

// ── Avatar storage — uploads to mernblog/avatars folder on Cloudinary ─────────
const avatarStorage = new CloudinaryStorage({
  cloudinary,
  params: async (req, file) => ({
    folder:         'mernblog/avatars',
    public_id:      `avatar-${req.user._id}-${Date.now()}`, // unique ID on Cloudinary
    allowed_formats: ['jpg', 'jpeg', 'png', 'webp', 'gif'],
    transformation: [
      { width: 200, height: 200, crop: 'fill', gravity: 'face' }, // square face crop
      { quality: 'auto', fetch_format: 'auto' }, // auto-format and compress
    ],
  }),
});

// ── Cover image storage ────────────────────────────────────────────────────────
const coverStorage = new CloudinaryStorage({
  cloudinary,
  params: async (req, file) => ({
    folder:         'mernblog/covers',
    public_id:      `cover-${req.user._id}-${Date.now()}`,
    allowed_formats: ['jpg', 'jpeg', 'png', 'webp'],
    transformation: [
      { width: 1200, height: 630, crop: 'fill' }, // Open Graph dimensions
      { quality: 'auto', fetch_format: 'auto' },
    ],
  }),
});

// ── File filter — unchanged from disk storage ──────────────────────────────────
const imageFileFilter = (req, file, cb) => {
  const allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
  if (allowed.includes(file.mimetype)) cb(null, true);
  else cb(new Error(`File type '${file.mimetype}' is not allowed`), false);
};

const uploadAvatar = multer({
  storage:    avatarStorage,
  fileFilter: imageFileFilter,
  limits:     { fileSize: 2 * 1024 * 1024 },
}).single('avatar');

const uploadCover = multer({
  storage:    coverStorage,
  fileFilter: imageFileFilter,
  limits:     { fileSize: 5 * 1024 * 1024 },
}).single('coverImage');

module.exports = { uploadAvatar, uploadCover };
Note: When using multer-storage-cloudinary, Multer sends the file directly to Cloudinary instead of saving it to disk. The file is held in memory by Multer, uploaded to Cloudinary, and then req.file is populated with Cloudinary’s response — including the path (the CDN URL) and filename (the public_id). You access the permanent CDN URL via req.file.path — not a local file path.
Tip: Cloudinary’s transformation parameter applies image transformations on upload — resizing, cropping, format conversion, and quality optimisation. The fetch_format: 'auto' transformation tells Cloudinary to serve WebP to browsers that support it and JPEG to those that do not — automatically. Combined with quality: 'auto', this can reduce image file sizes by 30–70% without visible quality loss.
Warning: Cloudinary upload is asynchronous and relatively slow (network round-trip to Cloudinary’s servers). During upload, the Express request hangs while the file transfers. For files larger than 1–2 MB, consider implementing a pre-signed URL pattern: the client gets a temporary upload URL directly from Cloudinary and uploads directly to Cloudinary without going through your Express server. This removes the file transfer load from your server entirely.

Updated Upload Controller for Cloudinary

// server/src/controllers/uploadController.js
const cloudinary   = require('../config/cloudinary');
const User         = require('../models/User');
const asyncHandler = require('../utils/asyncHandler');
const AppError     = require('../utils/AppError');

const uploadAvatarHandler = asyncHandler(async (req, res) => {
  if (!req.file) throw new AppError('No file provided', 400);

  // With Cloudinary storage: req.file.path = permanent CDN URL
  const avatarUrl = req.file.path;

  // Delete previous avatar from Cloudinary (if exists and is a Cloudinary URL)
  if (req.user.avatar && req.user.avatar.includes('cloudinary')) {
    // Extract public_id from existing URL
    const parts    = req.user.avatar.split('/');
    const publicId = parts.slice(-2).join('/').split('.')[0]; // 'mernblog/avatars/avatar-...'
    await cloudinary.uploader.destroy(publicId).catch(console.error);
  }

  // Update user document with new avatar URL
  const user = await User.findByIdAndUpdate(
    req.user._id,
    { avatar: avatarUrl },
    { new: true }
  ).select('-password');

  res.json({
    success: true,
    message: 'Avatar updated successfully',
    data: { avatar: avatarUrl, user },
  });
});

module.exports = { uploadAvatarHandler };

Deleting Files from Cloudinary

// Delete a file from Cloudinary by its public_id
// cloudinary.uploader.destroy(publicId)

// Extract public_id from URL:
// URL: https://res.cloudinary.com/yourcloud/image/upload/v1234/mernblog/avatars/avatar-64a1.jpg
// public_id: mernblog/avatars/avatar-64a1

const extractPublicId = (cloudinaryUrl) => {
  // Split on '/upload/' then take path without extension
  const afterUpload = cloudinaryUrl.split('/upload/')[1];
  // Remove the version prefix (v1234567/) if present
  const withoutVersion = afterUpload.replace(/^v\d+\//, '');
  // Remove the file extension
  return withoutVersion.replace(/\.[^.]+$/, '');
};

// Usage
const publicId = extractPublicId(user.avatar);
await cloudinary.uploader.destroy(publicId);
// File is deleted from Cloudinary's CDN

Common Mistakes

Mistake 1 — Hardcoding Cloudinary credentials

❌ Wrong — credentials in source code:

cloudinary.config({
  cloud_name: 'mycloud',
  api_key:    '123456789012345',
  api_secret: 'abcdefghijklmnopqrstuvwxyz123456', // committed to git!
});

✅ Correct — always use environment variables:

cloudinary.config({
  cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
  api_key:    process.env.CLOUDINARY_API_KEY,
  api_secret: process.env.CLOUDINARY_API_SECRET,
}); // ✓

Mistake 2 — Using req.file.filename for the URL with Cloudinary storage

❌ Wrong — filename is the public_id, not the URL:

const url = `https://res.cloudinary.com/${req.file.filename}`;
// req.file.filename is 'mernblog/avatars/avatar-123' — not the full URL

✅ Correct — use req.file.path which contains the full CDN URL:

const url = req.file.path; // 'https://res.cloudinary.com/.../avatar-123.jpg' ✓

Mistake 3 — Not deleting the old image when replacing it

❌ Wrong — old avatar accumulates on Cloudinary forever:

await User.findByIdAndUpdate(req.user._id, { avatar: newUrl });
// Old image still on Cloudinary — storage costs accumulate

✅ Correct — destroy old image before updating:

if (req.user.avatar?.includes('cloudinary')) {
  const publicId = extractPublicId(req.user.avatar);
  await cloudinary.uploader.destroy(publicId).catch(console.error); // ✓
}

Quick Reference

Task Code
Install npm install cloudinary multer-storage-cloudinary
Configure SDK cloudinary.config({ cloud_name, api_key, api_secret })
Cloud storage engine new CloudinaryStorage({ cloudinary, params: { folder, public_id, transformation } })
Get CDN URL req.file.path
Delete file cloudinary.uploader.destroy(publicId)
Face crop { width: 200, height: 200, crop: 'fill', gravity: 'face' }
Auto-optimise { quality: 'auto', fetch_format: 'auto' }

🧠 Test Yourself

After switching from disk storage to Cloudinary storage, your controller tries to build the avatar URL with `${process.env.SERVER_URL}/uploads/${req.file.filename}`. The URL is wrong and the image does not display. Why?