How to Install a MERN Stack on Ubuntu 24.04 VPS: MongoDB + Express + React + Node.js
The MERN stack -- MongoDB, Express, React, and Node.js -- is the most widely adopted JavaScript-only architecture for building modern full-stack web applications. One language end to end, JSON-native data flow, and a rich ecosystem of battle-tested libraries make MERN a default choice for SaaS products, internal tools, marketplaces, and real-time dashboards. This guide walks you through deploying a production-ready MERN stack on a fresh Ubuntu 24.04 VPS, from first SSH connection to a public HTTPS URL serving your React single-page application and Express API.
Want to skip the manual setup? Deploy a pre-configured Node.js VPS with MongoDB, Nginx, and PM2 ready to go. Launch a CloudCore Professional VPS and start shipping in minutes.
Table of Contents
What is the MERN Stack?
The MERN stack is a JavaScript-first application architecture built on four open-source components:
- MongoDB -- A document-oriented NoSQL database that stores data as BSON (binary JSON). Its flexible schema, horizontal scaling, and native JSON-like documents make it a natural fit for JavaScript apps.
- Express -- A minimalist, unopinionated web framework for Node.js that handles HTTP routing, middleware, and request/response processing. It powers the API layer of most Node.js apps.
- React -- A component-based UI library maintained by Meta that renders the client-side single-page application (SPA). React's virtual DOM, hooks API, and vast ecosystem dominate modern frontend development.
- Node.js -- A JavaScript runtime built on Chrome's V8 engine. It executes server-side JavaScript, making it possible to write your backend in the same language as your frontend.
Typical MERN applications include real-time SaaS dashboards, collaborative editors, marketplaces, social platforms, and headless CMS backends. If you have ever built a React app that calls a REST API backed by a document database, you have built a MERN app.
Why Self-Host MERN on a VPS?
Managed platforms like Vercel, Render, and MongoDB Atlas make it easy to get started -- but they charge per request, per GB, per seat, and costs grow non-linearly with traffic. Self-hosting the full stack on a single VPS offers concrete benefits:
- Flat, predictable cost -- A EUR 19.99/month VPS runs your database, API, and frontend indefinitely. No metering, no surprise egress charges, no per-seat pricing.
- Full control over the stack -- Pick any Node.js or MongoDB version. Patch at your own cadence. Run custom middleware, cron jobs, background workers, and one-off scripts without platform restrictions.
- Data ownership -- Your MongoDB database lives on your disk, inside your firewall. You decide backup cadence, retention, and access controls.
- Low latency -- When your API and database share a loopback connection (
127.0.0.1), every database query avoids network round-trips. Typical query latency drops from 20-40 ms on a managed DB to under 1 ms locally. - No cold starts -- Serverless Node.js suffers from cold starts that add 200-800 ms to the first request. A long-running PM2 process on a VPS responds in single-digit milliseconds every time.
- Simple debugging -- One SSH session gets you logs, running processes, database shell, and Nginx config. No dashboards to click through, no vendor CLI quirks.
Cost Comparison: Managed vs. Self-Hosted MERN
| Component | Managed (Vercel + Atlas + Render) | Self-Hosted (Ubuntu VPS) |
|---|---|---|
| Frontend hosting | Vercel Pro: $20/user/mo + bandwidth | Included |
| API hosting | Render Starter: $7/mo (one service) | Included |
| MongoDB | Atlas M10: ~$57/mo | Included |
| Bandwidth at 1 TB/mo | ~$100+ in overages | Unmetered |
| Typical total at small-team scale | ~$180-250/mo | EUR 19.99/mo |
Architecture Overview
Before we install anything, it is worth understanding the runtime topology you will end up with. This is the single-server MERN layout we will build:
Internet (HTTPS :443)
│
┌───────▼───────┐
│ Nginx │ ← TLS termination, static serving, reverse proxy
└───┬───────┬───┘
│ │
/ (static) │ │ /api (proxy)
│ │
┌──────────▼─┐ ┌─▼──────────────┐
│ React SPA │ │ Express API │ ← Node.js 20, PM2 cluster mode
│ /dist/ │ │ :3000 (local) │
└────────────┘ └────────┬───────┘
│
mongodb://127.0.0.1:27017
│
┌──────▼──────┐
│ MongoDB 7 │ ← Bound to 127.0.0.1, auth enabled
└─────────────┘Key design decisions:
- Nginx serves the React build directly. The compiled
/distfolder is static HTML, CSS, and JS -- there is no reason to route it through Node.js. Nginx serves it faster, with better caching, and frees your API process from static-file duty. /apiis the only path that proxies to Express. Everything else falls through to the SPA'sindex.html, which React Router picks up on the client side.- Express listens only on
127.0.0.1:3000. No direct internet exposure. Nginx is the sole public entry point. - MongoDB binds to
127.0.0.1:27017. Only the API process on the same machine can reach it. - PM2 runs Express in cluster mode. On a 4 vCPU VPS, you get four worker processes sharing the same port, fully utilizing every core.
Prerequisites
Before you begin, make sure you have:
- A VPS running Ubuntu 24.04 LTS with root or sudo access
- SSH access to your server
- At least 4 GB of RAM (8 GB recommended for MongoDB's WiredTiger cache plus the Node.js API)
- At least 40 GB of disk space for the OS, MongoDB data, Node modules, and logs
- A domain name with an A record pointing to your VPS IP (required for TLS)
Recommended Plan: CloudCore Professional>
For comfortably running MongoDB, an Express API under PM2 cluster mode, and serving a React SPA, we recommend the CloudCore Professional plan:>
- 6 vCPU cores (ideal for PM2 cluster mode)
- 12 GB RAM (4 GB for MongoDB cache, 8 GB for Node/OS)
- 100 GB NVMe SSD
- Unmetered bandwidth
- EUR 19.99/month>
This gives you enough headroom to grow from MVP to several thousand monthly active users on a single server.
Connect to your server via SSH:
ssh root@your-server-ipStep 1: Update System Packages
Always start with a clean package index and the latest security patches.
sudo apt update && sudo apt upgrade -yInstall a few utilities we will use throughout the guide:
sudo apt install -y curl wget gnupg lsb-release ca-certificates ufw git build-essentialIf the upgrade installed a new kernel, reboot before continuing:
sudo rebootReconnect via SSH after a minute.
Step 2: Install MongoDB 7
We install MongoDB 7 directly from MongoDB's official APT repository, which ships faster updates and more recent versions than the Ubuntu defaults. For a deeper dive into MongoDB tuning, replication, and sharding, see our full MongoDB install guide.
Import the MongoDB 7 GPG key:
curl -fsSL https://www.mongodb.org/static/pgp/server-7.0.asc | \
sudo gpg -o /usr/share/keyrings/mongodb-server-7.0.gpg --dearmorAdd the APT source list:
echo "deb [ arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-7.0.gpg ] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/7.0 multiverse" | \
sudo tee /etc/apt/sources.list.d/mongodb-org-7.0.listNote: The MongoDB 7 APT repo does not yet publish anoble(Ubuntu 24.04) component at the time of writing. Thejammy(22.04) packages install and run cleanly on 24.04 because MongoDB is statically linked against its own OpenSSL.
Install MongoDB:
sudo apt update
sudo apt install -y mongodb-orgEnable and start the service:
sudo systemctl enable --now mongodVerify MongoDB is running:
sudo systemctl status mongodExpected output:
● mongod.service - MongoDB Database Server
Loaded: loaded (/lib/systemd/system/mongod.service; enabled; preset: enabled)
Active: active (running) since Wed 2026-04-16 10:00:00 UTC; 10s ago
Main PID: 1234 (mongod)
Memory: 180.4MConfirm you can connect with the Mongo shell:
mongosh --eval 'db.runCommand({ ping: 1 })'Expected output:
{ ok: 1 }MongoDB binds to 127.0.0.1:27017 by default on Ubuntu, which is exactly what we want -- no external exposure. We will enable authentication in Step 10.
Step 3: Install Node.js 20 LTS
Ubuntu's default repos ship an older Node.js. Install the current LTS (20.x) from NodeSource, the officially recommended APT repo maintained by the Node.js community. For the full background on Node.js versioning and alternative install paths (nvm, Volta, Docker), see our dedicated Node.js install guide.
Add the NodeSource repository:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -Install Node.js (includes npm):
sudo apt install -y nodejsVerify:
node --version
npm --versionExpected output:
v20.18.0
10.8.2Install PM2 globally -- we will use it to run the API as a managed cluster in Step 7:
sudo npm install -g pm2Verify PM2:
pm2 --versionStep 4: Create the Express API
We will organize the project with a clean separation between the API (/srv/mern-app/api) and the React client (/srv/mern-app/client). This lets Nginx serve each from its natural location.
Create the application directory:
sudo mkdir -p /srv/mern-app
sudo chown -R $USER:$USER /srv/mern-app
cd /srv/mern-appScaffold the API
mkdir api && cd api
npm init -yInstall dependencies:
npm install express mongoose dotenv cors helmet morgan bcryptjs jsonwebtoken express-rate-limit
npm install --save-dev nodemonEach package has a role:
- express -- HTTP server and routing
- mongoose -- MongoDB object modeling (schemas, validation, middleware)
- dotenv -- Load
.envfile intoprocess.env - cors -- Cross-origin resource sharing middleware (mostly unused in same-origin deploys -- see CORS section)
- helmet -- Sets security-related HTTP headers
- morgan -- HTTP request logger
- bcryptjs -- Password hashing
- jsonwebtoken -- JWT signing and verification
- express-rate-limit -- Basic brute-force protection
package.json and add useful scripts:{
"name": "api",
"version": "1.0.0",
"main": "server.js",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}
}Create the entry point
Create server.js:
import 'dotenv/config'; import express from 'express'; import mongoose from 'mongoose'; import helmet from 'helmet'; import morgan from 'morgan'; import rateLimit from 'express-rate-limit'; import authRoutes from './routes/auth.js'; import noteRoutes from './routes/notes.js';const app = express(); const PORT = process.env.PORT || 3000;
app.use(helmet()); app.use(express.json({ limit: '1mb' })); app.use(morgan('combined'));
app.use('/api/', rateLimit({ windowMs: 15 60 1000, max: 300 }));
app.get('/api/health', (req, res) => { res.json({ ok: true, uptime: process.uptime(), ts: Date.now() }); });
app.use('/api/auth', authRoutes); app.use('/api/notes', noteRoutes);
app.use((err, req, res, next) => { console.error(err); res.status(err.status || 500).json({ error: err.message || 'Server error' }); });
mongoose .connect(process.env.MONGODB_URI) .then(() => { app.listen(PORT, '127.0.0.1', () => { console.log(API listening on 127.0.0.1:${PORT}); }); }) .catch((err) => { console.error('Mongo connection failed:', err); process.exit(1); });
Key details:
app.listen(PORT, '127.0.0.1', ...)-- Explicitly bind to loopback. Nginx will be the only thing forwarding public traffic./apiprefix on every route. Nginx uses this prefix to decide what goes to Express vs. the React SPA.- Rate limit applied only to
/apito avoid false positives on the SPA. express.json({ limit: '1mb' })-- Reject oversized payloads before they reach business logic.
Create the .env file
cat > /srv/mern-app/api/.env <<'EOF'
PORT=3000
MONGODB_URI=mongodb://127.0.0.1:27017/mern_app
JWT_SECRET=replace-me-with-a-long-random-string
JWT_EXPIRES_IN=7d
NODE_ENV=production
EOFGenerate a real JWT secret:
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"Copy the output into JWT_SECRET. Tighten permissions on the file:
chmod 600 /srv/mern-app/api/.envStep 5: Add JWT Authentication and Mongoose Models
A real MERN app needs at least two Mongoose models (User and a domain entity), a few routes, and a JWT middleware. We will build a minimal "Notes" API as a runnable example.
Create the User model
mkdir -p /srv/mern-app/api/modelsmodels/User.js:
import mongoose from 'mongoose'; import bcrypt from 'bcryptjs';const userSchema = new mongoose.Schema( { email: { type: String, required: true, unique: true, lowercase: true, trim: true }, passwordHash: { type: String, required: true }, name: { type: String, default: '' }, }, { timestamps: true } );
userSchema.statics.register = async function (email, password, name) { const passwordHash = await bcrypt.hash(password, 12); return this.create({ email, passwordHash, name }); };
userSchema.methods.verifyPassword = function (password) { return bcrypt.compare(password, this.passwordHash); };
export default mongoose.model('User', userSchema);
Create the Note model
models/Note.js:
import mongoose from 'mongoose';const noteSchema = new mongoose.Schema( { userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, index: true }, title: { type: String, required: true, trim: true, maxlength: 200 }, body: { type: String, default: '', maxlength: 10000 }, }, { timestamps: true } );
export default mongoose.model('Note', noteSchema);
JWT middleware
middleware/auth.js:
import jwt from 'jsonwebtoken';
export function requireAuth(req, res, next) { const header = req.headers.authorization || ''; const token = header.startsWith('Bearer ') ? header.slice(7) : null; if (!token) return res.status(401).json({ error: 'Missing token' }); try { req.user = jwt.verify(token, process.env.JWT_SECRET); next(); } catch { res.status(401).json({ error: 'Invalid token' }); } }
Auth routes
routes/auth.js:
import { Router } from 'express'; import jwt from 'jsonwebtoken'; import User from '../models/User.js';const router = Router();
router.post('/register', async (req, res, next) => { try { const { email, password, name } = req.body; if (!email || !password) return res.status(400).json({ error: 'Email and password required' }); const user = await User.register(email, password, name); const token = jwt.sign({ sub: user._id, email }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN, }); res.status(201).json({ token, user: { id: user._id, email, name } }); } catch (err) { if (err.code === 11000) return res.status(409).json({ error: 'Email already registered' }); next(err); } });
router.post('/login', async (req, res, next) => { try { const { email, password } = req.body; const user = await User.findOne({ email }); if (!user || !(await user.verifyPassword(password))) { return res.status(401).json({ error: 'Invalid credentials' }); } const token = jwt.sign({ sub: user._id, email }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN, }); res.json({ token, user: { id: user._id, email, name: user.name } }); } catch (err) { next(err); } });
export default router;
Notes routes
routes/notes.js:
import { Router } from 'express'; import Note from '../models/Note.js'; import { requireAuth } from '../middleware/auth.js';const router = Router(); router.use(requireAuth);
router.get('/', async (req, res) => { const notes = await Note.find({ userId: req.user.sub }).sort({ updatedAt: -1 }); res.json(notes); });
router.post('/', async (req, res) => { const note = await Note.create({ userId: req.user.sub, title: req.body.title, body: req.body.body, }); res.status(201).json(note); });
router.delete('/:id', async (req, res) => { await Note.deleteOne({ _id: req.params.id, userId: req.user.sub }); res.status(204).end(); });
export default router;
Test the API locally:
cd /srv/mern-app/api
npm startIn another SSH session:
curl http://127.0.0.1:3000/api/healthExpected output:
{"ok":true,"uptime":3.12,"ts":1713276000000}Stop the process with Ctrl+C -- we will run it under PM2 in Step 7.
Step 6: Build the React SPA with Vite
React has two common scaffolding options: Create React App (CRA, now in maintenance mode) and Vite (current recommendation). Vite is strongly preferred -- it uses native ES modules in dev (instant startup), Rollup for production builds (smaller bundles), and has first-class TypeScript and JSX support out of the box.
Scaffold with Vite
From /srv/mern-app:
cd /srv/mern-app
npm create vite@latest client -- --template react
cd client
npm installConfigure the API base URL
The SPA needs to know where to call the API. In production, the API is same-origin at /api, so you can simply use relative URLs.
Edit client/src/api.js:
const API_BASE = '/api';
export async function apiFetch(path, options = {}) { const token = localStorage.getItem('token'); const headers = { 'Content-Type': 'application/json', ...(token ? { Authorization:Bearer ${token}} : {}), ...options.headers, }; const res = await fetch(${API_BASE}${path}, { ...options, headers }); if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || res.statusText); return res.status === 204 ? null : res.json(); }
Dev-mode proxy (optional)
When running Vite in dev mode (npm run dev), it serves on port 5173 and your API is on port 3000. Add a proxy in vite.config.js so /api calls hit Express without CORS headaches:
import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react';
export default defineConfig({ plugins: [react()], server: { proxy: { '/api': 'http://127.0.0.1:3000', }, }, });
Build for production
npm run buildVite outputs the production bundle to /srv/mern-app/client/dist. This is the folder Nginx will serve directly. Typical output:
vite v5.4.8 building for production...
✓ 34 modules transformed.
dist/index.html 0.45 kB │ gzip: 0.30 kB
dist/assets/index-abc123.css 1.23 kB │ gzip: 0.68 kB
dist/assets/index-def456.js 142.17 kB │ gzip: 45.63 kB
✓ built in 1.24sWhy not serve the React build from Express?
You technically can use express.static('client/dist'), but:
sendfile() syscalls and has mature cache control.Keep static serving in Nginx. Always.
Step 7: Run the API with PM2 in Cluster Mode
PM2 is a production-grade Node.js process manager that handles clustering, log rotation, zero-downtime reloads, and auto-restart on crashes.
Create an ecosystem file
/srv/mern-app/api/ecosystem.config.cjs:
module.exports = {
apps: [
{
name: 'mern-api',
script: './server.js',
cwd: '/srv/mern-app/api',
instances: 'max',
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
},
max_memory_restart: '500M',
error_file: '/var/log/mern-api/error.log',
out_file: '/var/log/mern-api/out.log',
merge_logs: true,
},
],
};Key settings:
instances: 'max'-- Spawn one worker per CPU core. On a 6 vCPU VPS, you get 6 Node processes sharing port 3000 via Node's built-in cluster module.exec_mode: 'cluster'-- Enables cluster mode. Without this, PM2 runs a single fork.max_memory_restart: '500M'-- Restart a worker if it exceeds 500 MB (catches slow memory leaks automatically).
sudo mkdir -p /var/log/mern-api
sudo chown $USER:$USER /var/log/mern-apiStart the cluster
cd /srv/mern-app/api
pm2 start ecosystem.config.cjs
pm2 saveCheck status:
pm2 statusExpected output:
┌────┬───────────┬─────────┬─────────┬─────────┬──────────┐
│ id │ name │ mode │ ↺ │ status │ memory │
├────┼───────────┼─────────┼─────────┼─────────┼──────────┤
│ 0 │ mern-api │ cluster │ 0 │ online │ 42.3 MB │
│ 1 │ mern-api │ cluster │ 0 │ online │ 42.1 MB │
│ 2 │ mern-api │ cluster │ 0 │ online │ 42.5 MB │
│ 3 │ mern-api │ cluster │ 0 │ online │ 42.2 MB │
└────┴───────────┴─────────┴─────────┴─────────┴──────────┘Persist PM2 across reboots with systemd + EnvironmentFile
PM2 can generate its own systemd unit, but for production we want fine-grained control over environment loading. Generate the base unit:
pm2 startup systemd -u $USER --hp $HOMERun the sudo command it prints. This creates /etc/systemd/system/pm2-<username>.service.
For maximum clarity, you may also run the API directly under systemd without PM2. Create /etc/systemd/system/mern-api.service:
[Unit] Description=MERN API After=network.target mongod.service Requires=mongod.service[Service] Type=simple User=www-data WorkingDirectory=/srv/mern-app/api EnvironmentFile=/srv/mern-app/api/.env ExecStart=/usr/bin/node server.js Restart=on-failure RestartSec=5
[Install] WantedBy=multi-user.target
EnvironmentFile= makes systemd parse your .env directly, so environment variables load consistently whether you run via PM2, systemd, or node server.js during debugging. Either approach works -- PM2 is more flexible for clustered Node apps, systemd is simpler for single-process setups.
Reload the PM2 config if you prefer the PM2 approach:
pm2 reload allStep 8: Configure Nginx as a Reverse Proxy
Nginx is the public-facing web server. It serves the React /dist folder, proxies /api to Express, terminates TLS, and applies gzip and caching. For a broader reference on Nginx tuning, virtual hosts, and security headers, see our full Nginx install and configuration guide.
Install Nginx
sudo apt install -y nginx
sudo systemctl enable --now nginxOpen the firewall
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw --force enable
sudo ufw statusCreate the site config
Replace yourdomain.com with your actual domain. Create /etc/nginx/sites-available/mern-app:
server { listen 80; server_name yourdomain.com www.yourdomain.com;root /srv/mern-app/client/dist; index index.html;
# Security headers add_header X-Content-Type-Options nosniff; add_header X-Frame-Options SAMEORIGIN; add_header Referrer-Policy strict-origin-when-cross-origin;
# Gzip gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml; gzip_min_length 1024;
# Client max body (adjust for your largest expected upload) client_max_body_size 10m;
# API -> Express on localhost:3000 location /api/ { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 60s; }
# Hashed asset files -- cache aggressively location /assets/ { expires 1y; add_header Cache-Control "public, immutable"; try_files $uri =404; }
# SPA fallback -- every non-API, non-file path returns index.html location / { try_files $uri $uri/ /index.html; } }
The critical pieces:
location /api/ { proxy_pass ... }-- Every request starting with/api/goes to Express.location / { try_files $uri $uri/ /index.html; }-- Everything else either hits a real file in/distor falls through toindex.html, which React Router handles on the client./assets/caching -- Vite outputs hashed filenames (index-abc123.js), so they are safe to cache for a year.
sudo ln -s /etc/nginx/sites-available/mern-app /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginxVisit http://yourdomain.com -- the React SPA should load. Visit http://yourdomain.com/api/health -- the JSON health response should come back.
A note on CORS
Because the SPA and the API share the same origin (yourdomain.com), you do not need any CORS configuration. The browser sees every fetch to /api as same-origin. The cors npm package stays installed but is only needed if you later split the frontend onto a different domain (e.g., app.yourdomain.com calling api.yourdomain.com).
Step 9: Add TLS with Certbot
Let's Encrypt issues free TLS certificates, and Certbot automates both issuance and renewal.
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.comCertbot prompts for an email, agreement, and HTTPS redirect preference. Choose option 2 (redirect HTTP to HTTPS) so all traffic upgrades to TLS automatically.
Certbot edits your Nginx config in place, adding:
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;Verify automatic renewal:
sudo systemctl status certbot.timer
sudo certbot renew --dry-runYour MERN stack is now live at https://yourdomain.com.
Step 10: Secure MongoDB with Authentication
MongoDB binds to localhost by default, but anyone with shell access to the VPS can currently connect without credentials. Enable authentication now.
Connect to the Mongo shell:
mongoshCreate an admin user and an application user:
use admin db.createUser({ user: "admin", pwd: "CHANGE-ME-long-admin-password", roles: [ { role: "userAdminAnyDatabase", db: "admin" }, "readWriteAnyDatabase" ] });use mern_app db.createUser({ user: "mern_app", pwd: "CHANGE-ME-long-app-password", roles: [ { role: "readWrite", db: "mern_app" } ] });
exit
Enable auth in /etc/mongod.conf:
security:
authorization: enabledRestart MongoDB:
sudo systemctl restart mongodUpdate your API's .env:
MONGODB_URI=mongodb://mern_app:[email protected]:27017/mern_app?authSource=mern_appReload the API:
pm2 reload mern-apiYour database is now protected even from local users who do not have the password.
Backups, Upgrades, and Maintenance
MongoDB backups with mongodump
Create a nightly backup script at /usr/local/bin/backup-mongo.sh:
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR=/var/backups/mongo
STAMP=$(date +%Y%m%d-%H%M%S)
mkdir -p "$BACKUP_DIR"
mongodump \
--uri="mongodb://mern_app:[email protected]:27017/mern_app?authSource=mern_app" \
--gzip \
--archive="$BACKUP_DIR/mern_app-$STAMP.gz"
find "$BACKUP_DIR" -type f -mtime +14 -deleteMake it executable and schedule it:
sudo chmod +x /usr/local/bin/backup-mongo.sh
sudo crontab -eAdd:
30 3 * /usr/local/bin/backup-mongo.sh >> /var/log/backup-mongo.log 2>&1Runs at 3:30 AM daily, keeps 14 days of rolling backups. To restore:
mongorestore --uri="mongodb://admin:[email protected]:27017/?authSource=admin" --gzip --archive=/var/backups/mongo/mern_app-20260416-033000.gzFor off-site safety, rsync or rclone the backup directory to S3, Backblaze B2, or another VPS nightly.
Node.js deployment workflow
When you ship new code:
cd /srv/mern-app/api git pull npm ci --omit=dev pm2 reload mern-apicd /srv/mern-app/client git pull npm ci npm run build
Nginx picks up the new /dist immediately -- no reload needed
pm2 reload performs a zero-downtime rolling restart across cluster workers.
Upgrading MongoDB
MongoDB minor upgrades are apt-based:
sudo apt update && sudo apt install --only-upgrade mongodb-org
sudo systemctl restart mongodMajor version upgrades (e.g., 7.0 → 8.0) require setting featureCompatibilityVersion before upgrading. Always back up first and read the official upgrade notes.
Upgrading Node.js
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
pm2 update
pm2 reload allTroubleshooting
| Problem | Cause | Solution |
|---|---|---|
502 Bad Gateway when hitting /api | Express not running on 127.0.0.1:3000 | pm2 status, then pm2 logs mern-api. Check that app.listen(PORT, '127.0.0.1') is binding. |
MongoNetworkError: connect ECONNREFUSED 127.0.0.1:27017 | MongoDB not running | sudo systemctl status mongod. Check /var/log/mongodb/mongod.log. |
MongoServerError: Authentication failed | Wrong credentials or missing authSource | Verify the URI format: mongodb://user:pass@host:27017/dbname?authSource=dbname. |
| CORS errors in browser console | SPA calling a different origin than Nginx | Confirm your fetches use relative /api/... paths, not hardcoded http://localhost:3000. |
vite build fails with ENOMEM | Not enough RAM on small VPS | Add swap: sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile. |
env not loading in production | Running node server.js from wrong cwd, or .env not read | Confirm dotenv/config is imported first. With systemd, use EnvironmentFile=. With PM2, ensure cwd is set in ecosystem file. |
Blank page after deploy, 200 OK on / | Stale browser cache of old index.html | Hard refresh (Cmd/Ctrl+Shift+R). Verify Nginx is serving the new /dist. |
| PM2 not restarting workers after crash | max_memory_restart too low | Raise the limit or investigate the memory leak with pm2 monit. |
| React Router deep links 404 on refresh | Nginx not falling back to index.html | Check try_files $uri $uri/ /index.html; is present in location /. |
| Certbot renewal fails | Port 80 blocked or Nginx down | sudo ufw status, sudo systemctl status nginx. Certbot needs port 80 open for HTTP-01 challenge. |
Viewing logs
# API logs
pm2 logs mern-apiNginx logs
sudo tail -f /var/log/nginx/access.log /var/log/nginx/error.logMongoDB logs
sudo tail -f /var/log/mongodb/mongod.logsystemd journal for the API (if using systemd unit)
sudo journalctl -u mern-api -fFAQ
Should I use Vite or Create React App for the MERN stack?
Use Vite. Create React App was officially deprecated in early 2025, and the React team now points users to Vite, Next.js, or Remix in its "Start a New React Project" documentation. Vite builds are 5-10x faster than CRA, dev startup is near-instant thanks to native ES modules, and the Rollup-based production output is typically 20-30% smaller. Migrating an existing CRA app to Vite usually takes a few hours.
Can I run MERN on a smaller VPS?
Yes. The minimum viable MERN VPS is 2 GB RAM with 2 vCPUs -- MongoDB's default WiredTiger cache will use about 1 GB, Node.js takes 100-200 MB per worker, and Ubuntu needs another 300-500 MB. That leaves you almost no headroom for traffic, so it only makes sense for personal projects. For anything production-facing, 4 GB is the practical floor and 8-12 GB is comfortable. Our CloudCore Professional plan at 12 GB RAM is sized for this exact use case.
Should I serve the React build from Express with express.static?
No. Let Nginx serve /dist directly. Nginx is roughly 3-5x faster than Express at static file serving, uses zero-copy sendfile() syscalls, applies gzip and caching with one-line configs, and handles HTTP/2 natively. Keeping your Node.js process focused on API work also lowers event-loop latency for your actual business logic. The one exception is if you are deploying to a Platform-as-a-Service that only runs a single process -- in that case, express.static is a fine workaround.
How do I handle file uploads in MERN?
For small files (under 5 MB), use multer as Express middleware and store files on the VPS disk (with size limits and MIME validation). For larger files or multi-server deployments, upload directly to S3-compatible object storage (AWS S3, Backblaze B2, Cloudflare R2) using presigned URLs generated by your API. Do not store large binaries in MongoDB -- GridFS works but is slower and harder to back up than object storage.
What is the difference between JWT and session auth in MERN?
JWT (JSON Web Token) is stateless: your API signs a token, the client stores it (typically in localStorage or an httpOnly cookie), and every request includes it in the Authorization header. No session state on the server. Session auth is stateful: the server stores a session record in MongoDB or Redis, and the client sends a session ID cookie. Sessions are easier to invalidate (just delete the record), safer against XSS when stored in httpOnly cookies, and simpler to reason about. JWT is ideal for stateless APIs, mobile clients, and microservices. For a typical single-server MERN SaaS app, either works -- pick JWT for simplicity or sessions (via express-session + connect-mongo) for easier revocation.
How do I add real-time features to MERN?
Add Socket.IO or native WebSockets to your Express server. Socket.IO shares the same HTTP server instance with Express and handles fallbacks automatically. In your Nginx config, the existing proxy_set_header Upgrade $http_upgrade; and Connection "upgrade"; directives in the /api/ block already support WebSocket upgrades -- or you can add a dedicated /socket.io/ location. For multi-worker PM2 deployments, use the Redis adapter (@socket.io/redis-adapter) so events broadcast across all cluster workers.
How does MERN compare to Next.js with MongoDB?
MERN separates the frontend (React SPA) and backend (Express API) cleanly, which is ideal when you plan to have multiple clients (web + mobile + third-party integrations) or want to deploy them independently. Next.js fuses React with an integrated backend via API routes and Server Components, which is faster to build for pure web apps but harder to split later. For SEO-heavy public sites, Next.js's server rendering wins. For data-heavy internal apps, dashboards, and mobile-backed APIs, MERN's separation is cleaner. Both are excellent choices on a single VPS.
Next Steps
With your MERN stack live, here are natural follow-ups to harden and extend it:
- Add monitoring -- Deploy Uptime Kuma or Netdata on the same VPS to track API uptime, response times, and MongoDB health. Alert via email, Telegram, or Discord when something goes down.
- Set up CI/CD with GitHub Actions -- Add a workflow that SSHes into your VPS, pulls the latest code, runs
npm ci, builds the frontend, and reloads PM2 on every push tomain. Zero-downtime deploys in under a minute.
- Add Redis for caching and sessions -- Install Redis (
sudo apt install redis-server) for session storage, rate-limit counters, and query caching. One-line Mongoose plugin installs (cachegoose) can cut database load dramatically.
- Containerize with Docker Compose -- Once the stack is stable, wrap MongoDB, Node API, and Nginx in a
docker-compose.ymlfor reproducible deployments across multiple VPS.
- Introduce TypeScript -- Migrate
server.jsto.ts, share types between API and client with ashared/package, and catch bugs at compile time. Vite supports TypeScript out of the box.
- Add full-text search -- MongoDB's built-in text indexes handle simple search; for advanced needs, deploy Meilisearch or Typesense alongside and sync changes via Mongoose middleware.
Skip the Manual Install -- Deploy on CloudCore Professional>
Launch a production-grade VPS sized exactly for the MERN stack -- 6 vCPU, 12 GB RAM, 100 GB NVMe, unmetered bandwidth -- and get up and running in minutes.>
- MongoDB 7 and Node.js 20 LTS run comfortably side by side
- PM2 cluster mode uses all 6 cores
- Unmetered bandwidth, no egress charges
- Full root access for custom setups
- EUR 19.99/month, scale vertically as you grow>
Deploy CloudCore Professional Now and ship your MERN app today.