Skip to main contentSkip to navigation
[email protected]
Client AreaSupport
Hosting Mammoth
HostingMammothYour Data, Our Responsibility
Home
Solutions
Hosting Services
Store
Pricing
About
Blog
API
Contact

Stay Ahead of the Curve

Get the latest insights on cybersecurity, AI innovations, and enterprise data solutions delivered to your inbox.

Hosting Mammoth
HostingMammothEnterprise Solutions

Enterprise-grade data solutions. Hosting, recovery, cybersecurity, and AI-powered services for businesses worldwide.

[email protected]
Sun - Fri, 9:00am - 5:00pm

Services

  • Cloud Hosting
  • Data Recovery
  • Cybersecurity
  • Legal Support
  • MSP Services
  • Web Development
  • AI Services
  • Free Server Migration

Hosting

  • VPS Hosting (NVMe SSD)
  • VDS Hosting (NVMe)
  • Storage VPS (High SSD)
  • GPU Servers
  • Managed Services
  • Cloud Firewall
  • Load Balancer
  • One-Click Apps
  • n8n Hosting
  • Object Storage
  • FAQ

Company

  • Store
  • Pricing
  • About Us
  • Locations
  • Blog
  • Testimonials
  • Contact
  • Affiliate Program
  • White-Label
  • Terms of Service
  • Privacy Policy
  • Browser Cookies
  • SLA

Support

  • Client Area
  • Submit Ticket
  • Knowledge Base
  • Server Status
  • API Documentation

© 2026 Hosting Mammoth. All rights reserved.

Knowledge Base
Getting StartedAccount ManagementVPS HostingGPU ServersStorage VPSCloud FirewallLoad BalancerServer ManagementBilling & PaymentsSupport & TicketsAffiliate ProgramReseller ProgramMarketplace & Appsn8n HostingManaged ServicesServer MigrationAPI & DevelopersSecurityTroubleshootingGlossaryInstall Guides
  1. Home
  2. /
  3. Support
  4. /
  5. Install Guides
  6. /
  7. How To Install Mern Stack Ubuntu
GUIDEInstall Guides

How to Install a MERN Stack on Ubuntu 24.04 VPS: MongoDB + Express + React + Node.js

28 min read

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?
  • Why Self-Host MERN on a VPS?
  • Architecture Overview
  • Prerequisites
  • Step 1: Update System Packages
  • Step 2: Install MongoDB 7
  • Step 3: Install Node.js 20 LTS
  • Step 4: Create the Express API
  • Step 5: Add JWT Authentication and Mongoose Models
  • Step 6: Build the React SPA with Vite
  • Step 7: Run the API with PM2 in Cluster Mode
  • Step 8: Configure Nginx as a Reverse Proxy
  • Step 9: Add TLS with Certbot
  • Step 10: Secure MongoDB with Authentication
  • Backups, Upgrades, and Maintenance
  • Troubleshooting
  • FAQ
  • Next Steps
  • 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.
    The power of MERN is not the individual components but their cohesion. JSON flows from MongoDB through Express to your React components without translation. You write TypeScript or JavaScript once and share validation logic, type definitions, and utility functions between the API and the SPA. Deployment is uniform: one language, one runtime, one package manager.

    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

    ComponentManaged (Vercel + Atlas + Render)Self-Hosted (Ubuntu VPS)
    Frontend hostingVercel Pro: $20/user/mo + bandwidthIncluded
    API hostingRender Starter: $7/mo (one service)Included
    MongoDBAtlas M10: ~$57/moIncluded
    Bandwidth at 1 TB/mo~$100+ in overagesUnmetered
    Typical total at small-team scale~$180-250/moEUR 19.99/mo
    For most indie apps and small SaaS products, a single well-configured VPS handles thousands of active users without breaking a sweat.

    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:

    text
    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 /dist folder 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.
    • /api is the only path that proxies to Express. Everything else falls through to the SPA's index.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.
    With that mental model in place, let's build it.

    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:

    bash
    ssh root@your-server-ip

    Step 1: Update System Packages

    Always start with a clean package index and the latest security patches.

    bash
    sudo apt update && sudo apt upgrade -y

    Install a few utilities we will use throughout the guide:

    bash
    sudo apt install -y curl wget gnupg lsb-release ca-certificates ufw git build-essential

    If the upgrade installed a new kernel, reboot before continuing:

    bash
    sudo reboot

    Reconnect 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:

    bash
    curl -fsSL https://www.mongodb.org/static/pgp/server-7.0.asc | \
       sudo gpg -o /usr/share/keyrings/mongodb-server-7.0.gpg --dearmor

    Add the APT source list:

    bash
    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.list
    Note: The MongoDB 7 APT repo does not yet publish a noble (Ubuntu 24.04) component at the time of writing. The jammy (22.04) packages install and run cleanly on 24.04 because MongoDB is statically linked against its own OpenSSL.

    Install MongoDB:

    bash
    sudo apt update
    sudo apt install -y mongodb-org

    Enable and start the service:

    bash
    sudo systemctl enable --now mongod

    Verify MongoDB is running:

    bash
    sudo systemctl status mongod

    Expected output:

    text
    ● 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.4M

    Confirm you can connect with the Mongo shell:

    bash
    mongosh --eval 'db.runCommand({ ping: 1 })'

    Expected output:

    text
    { 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:

    bash
    curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -

    Install Node.js (includes npm):

    bash
    sudo apt install -y nodejs

    Verify:

    bash
    node --version
    npm --version

    Expected output:

    text
    v20.18.0
    10.8.2

    Install PM2 globally -- we will use it to run the API as a managed cluster in Step 7:

    bash
    sudo npm install -g pm2

    Verify PM2:

    bash
    pm2 --version

    Step 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:

    bash
    sudo mkdir -p /srv/mern-app
    sudo chown -R $USER:$USER /srv/mern-app
    cd /srv/mern-app

    Scaffold the API

    bash
    mkdir api && cd api
    npm init -y

    Install dependencies:

    bash
    npm install express mongoose dotenv cors helmet morgan bcryptjs jsonwebtoken express-rate-limit
    npm install --save-dev nodemon

    Each package has a role:

    • express -- HTTP server and routing
    • mongoose -- MongoDB object modeling (schemas, validation, middleware)
    • dotenv -- Load .env file into process.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
    Edit package.json and add useful scripts:

    json
    {
      "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:

    javascript
    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.
    • /api prefix on every route. Nginx uses this prefix to decide what goes to Express vs. the React SPA.
    • Rate limit applied only to /api to avoid false positives on the SPA.
    • express.json({ limit: '1mb' }) -- Reject oversized payloads before they reach business logic.

    Create the .env file

    bash
    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
    EOF

    Generate a real JWT secret:

    bash
    node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"

    Copy the output into JWT_SECRET. Tighten permissions on the file:

    bash
    chmod 600 /srv/mern-app/api/.env

    Step 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

    bash
    mkdir -p /srv/mern-app/api/models

    models/User.js:

    javascript
    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:

    javascript
    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:

    javascript
    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:

    javascript
    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:

    javascript
    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:

    bash
    cd /srv/mern-app/api
    npm start

    In another SSH session:

    bash
    curl http://127.0.0.1:3000/api/health

    Expected output:

    json
    {"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:

    bash
    cd /srv/mern-app
    npm create vite@latest client -- --template react
    cd client
    npm install

    Configure 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:

    javascript
    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:

    javascript
    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

    bash
    npm run build

    Vite outputs the production bundle to /srv/mern-app/client/dist. This is the folder Nginx will serve directly. Typical output:

    text
    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.24s

    Why not serve the React build from Express?

    You technically can use express.static('client/dist'), but:

  • Nginx is faster at static files. It uses sendfile() syscalls and has mature cache control.
  • Your Node process stays dedicated to API work, lowering event-loop latency.
  • Caching, gzip, and HTTP/2 are one-line Nginx configs vs. extra middleware in Express.
  • 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:

    javascript
    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).
    Create the log directory:

    bash
    sudo mkdir -p /var/log/mern-api
    sudo chown $USER:$USER /var/log/mern-api

    Start the cluster

    bash
    cd /srv/mern-app/api
    pm2 start ecosystem.config.cjs
    pm2 save

    Check status:

    bash
    pm2 status

    Expected output:

    text
    ┌────┬───────────┬─────────┬─────────┬─────────┬──────────┐
    │ 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:

    bash
    pm2 startup systemd -u $USER --hp $HOME

    Run 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:

    ini
    [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:

    bash
    pm2 reload all

    Step 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

    bash
    sudo apt install -y nginx
    sudo systemctl enable --now nginx

    Open the firewall

    bash
    sudo ufw allow OpenSSH
    sudo ufw allow 'Nginx Full'
    sudo ufw --force enable
    sudo ufw status

    Create the site config

    Replace yourdomain.com with your actual domain. Create /etc/nginx/sites-available/mern-app:

    nginx
    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 /dist or falls through to index.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.
    Enable the site:

    bash
    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 nginx

    Visit 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.

    bash
    sudo apt install -y certbot python3-certbot-nginx
    sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

    Certbot 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:

    nginx
    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:

    bash
    sudo systemctl status certbot.timer
    sudo certbot renew --dry-run

    Your 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:

    bash
    mongosh

    Create an admin user and an application user:

    javascript
    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:

    yaml
    security:
      authorization: enabled

    Restart MongoDB:

    bash
    sudo systemctl restart mongod

    Update your API's .env:

    bash
    MONGODB_URI=mongodb://mern_app:[email protected]:27017/mern_app?authSource=mern_app

    Reload the API:

    bash
    pm2 reload mern-api

    Your 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:

    bash
    #!/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 -delete

    Make it executable and schedule it:

    bash
    sudo chmod +x /usr/local/bin/backup-mongo.sh
    sudo crontab -e

    Add:

    text
    30 3   * /usr/local/bin/backup-mongo.sh >> /var/log/backup-mongo.log 2>&1

    Runs at 3:30 AM daily, keeps 14 days of rolling backups. To restore:

    bash
    mongorestore --uri="mongodb://admin:[email protected]:27017/?authSource=admin" --gzip --archive=/var/backups/mongo/mern_app-20260416-033000.gz

    For 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:

    bash
    cd /srv/mern-app/api
    git pull
    npm ci --omit=dev
    pm2 reload mern-api

    cd /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:

    bash
    sudo apt update && sudo apt install --only-upgrade mongodb-org
    sudo systemctl restart mongod

    Major 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

    bash
    curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
    sudo apt install -y nodejs
    pm2 update
    pm2 reload all

    Troubleshooting

    ProblemCauseSolution
    502 Bad Gateway when hitting /apiExpress not running on 127.0.0.1:3000pm2 status, then pm2 logs mern-api. Check that app.listen(PORT, '127.0.0.1') is binding.
    MongoNetworkError: connect ECONNREFUSED 127.0.0.1:27017MongoDB not runningsudo systemctl status mongod. Check /var/log/mongodb/mongod.log.
    MongoServerError: Authentication failedWrong credentials or missing authSourceVerify the URI format: mongodb://user:pass@host:27017/dbname?authSource=dbname.
    CORS errors in browser consoleSPA calling a different origin than NginxConfirm your fetches use relative /api/... paths, not hardcoded http://localhost:3000.
    vite build fails with ENOMEMNot enough RAM on small VPSAdd swap: sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile.
    env not loading in productionRunning node server.js from wrong cwd, or .env not readConfirm 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.htmlHard refresh (Cmd/Ctrl+Shift+R). Verify Nginx is serving the new /dist.
    PM2 not restarting workers after crashmax_memory_restart too lowRaise the limit or investigate the memory leak with pm2 monit.
    React Router deep links 404 on refreshNginx not falling back to index.htmlCheck try_files $uri $uri/ /index.html; is present in location /.
    Certbot renewal failsPort 80 blocked or Nginx downsudo ufw status, sudo systemctl status nginx. Certbot needs port 80 open for HTTP-01 challenge.

    Viewing logs

    bash
    # API logs
    pm2 logs mern-api

    Nginx logs

    sudo tail -f /var/log/nginx/access.log /var/log/nginx/error.log

    MongoDB logs

    sudo tail -f /var/log/mongodb/mongod.log

    systemd journal for the API (if using systemd unit)

    sudo journalctl -u mern-api -f

    FAQ

    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 to main. 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.yml for reproducible deployments across multiple VPS.
    • Introduce TypeScript -- Migrate server.js to .ts, share types between API and client with a shared/ 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.

    Was this article helpful?

    ← Back to Install GuidesBrowse all categories →

    Still have questions?

    Contact Support →Submit a Ticket