Running MERN/MEAN on a Clean VPS (No Control Panel): A Complete, Battle‑Tested Playbook Print

  • 0

🚀 Production‑Ready MERN/MEAN on a Clean VPS (No Control Panel)

✍️ Short Summary
This guide shows how to deploy a MERN/MEAN app on a fresh Ubuntu VPS without a hosting control panel. You’ll run your frontend (port 3000) and API (port 4000) behind Nginx with free Let’s Encrypt SSL, keep apps alive with PM2, and (optionally) connect to MongoDB. It includes copy‑paste commands, health checks, and fixes for common pitfalls like “Cannot GET /api/”.

📎 Table of Contents

  1. What we’re building

  2. Why go control‑panel free for MERN/MEAN

  3. Prerequisites

  4. Create system user & folders

  5. Install Node & PM2 (per‑user)

  6. Optional: MongoDB quick start

  7. Minimal placeholder apps (health checks)

  8. Nginx reverse proxy + SSL

  9. PM2 auto‑start on reboot

  10. Verification checklist

  11. Troubleshooting cookbook

  12. Security & maintenance

  13. Appendix: Full configs

  14. Related articles


🧱 What we’re building

Architecture (example.com)

  • Nginx → public ports 80/443

  • Frontend → Node app on 127.0.0.1:3000

  • API → Express/Node on 127.0.0.1:4000

  • MongoDB (optional) → local or Docker, 127.0.0.1:27017

  • PM2 → keeps your Node apps alive and restarts on boot

URLs: https://example.com/ → frontend, https://example.com/api/... → API.
/api (exact) redirects to /api/health for an at‑a‑glance check.


💡 Why go control‑panel free for MERN/MEAN

  • Purpose‑built: Panels are tuned for LAMP (Apache/PHP/MySQL). MERN/MEAN prefers Node processes + Nginx proxy.

  • Fewer moving parts: Less bloat and fewer daemons (mail, FTP, bind) → lower attack surface and fewer conflicts on port 80/443.

  • Predictable routing: You control Nginx rules (e.g., the trailing‑slash detail in proxy_pass that breaks many APIs).

  • App‑centric ops: PM2 + .env + logs → easier CI/CD and debugging.

  • Future‑proof: Clean base is friendly to containers (Docker) or orchestration later.

Result: a lean, stable, and secure runtime tailored to Node apps.


🧰 Prerequisites

  • Ubuntu 22.04/24.04 VPS with sudo or root

  • A domain (use example.com here) pointing A → your server IP (e.g., 203.0.113.10)

  • Open ports 80/443 to the internet; 22 for SSH

Tip: If a panel or Apache is present, remove/stop it before proceeding to avoid port 80 conflicts.


👤 Create system user & folders

# Replace with your real user
APPUSER=appuser
sudo useradd -m -s /bin/bash "$APPUSER" || true
sudo -u "$APPUSER" mkdir -p /home/$APPUSER/apps/example/{api,web}

▶️ Install Node & PM2 (per‑user)

Use nvm so Node is local to the app user:

sudo -iu $APPUSER bash -lc '
  curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
  . "$HOME/.nvm/nvm.sh"
  nvm install 18
  npm i -g pm2
'

🗄️ Optional: MongoDB quick start

Option A — Docker (simple & isolated):

sudo apt-get update -y
sudo apt-get install -y docker.io
sudo docker run -d \
  --name mongo \
  -p 127.0.0.1:27017:27017 \
  -v /var/lib/mongo:/data/db \
  mongo:4.4

Option B — Native (apt):
Follow the official MongoDB repo instructions for your Ubuntu version, then:

sudo systemctl enable --now mongod

For auth: create a DB/user for your app and embed it in your MONGO_URI.


🧪 Minimal placeholder apps (health checks)

API (Express + Mongo ping is optional):

sudo -iu $APPUSER bash -lc '
  cd ~/apps/example/api
  cat > package.json <<EOF
  {"name":"example-api","version":"1.0.0","type":"module","main":"server.js","scripts":{"start":"node server.js"},"dependencies":{"dotenv":"^16.4.5","express":"^4.19.2","mongodb":"^4.16.1"}}
  EOF
  cat > .env <<EOF
  NODE_ENV=production
  PORT=4000
  # Adjust if you enabled auth
  MONGO_URI=mongodb://127.0.0.1:27017/test
  EOF
  cat > server.js <<'JS'
  import 'dotenv/config'
  import express from 'express'
  import { MongoClient } from 'mongodb'
  const app = express()
  const PORT = process.env.PORT || 4000
  const MONGO_URI = process.env.MONGO_URI
  app.get('/api/health', async (_req, res) => {
    try {
      if (MONGO_URI) {
        const c = await MongoClient.connect(MONGO_URI, { maxPoolSize: 1 })
        await c.db().command({ ping: 1 })
        await c.close()
      }
      res.json({ ok: 1 })
    } catch (e) {
      res.status(500).json({ ok: 0, error: e.message })
    }
  })
  app.listen(PORT, () => console.log(`API on ${PORT}`))
  JS
  npm install
  pm2 start server.js --name example-api --time
'

Web (tiny Node server):

sudo -iu $APPUSER bash -lc '
  cd ~/apps/example/web
  cat > package.json <<EOF
  {"name":"example-web","version":"1.0.0","type":"module","main":"server.js","scripts":{"start":"node server.js"},"dependencies":{"express":"^4.19.2"}}
  EOF
  cat > server.js <<'JS'
  import express from "express"
  const app = express()
  const PORT = 3000
  app.get('/', (_req, res) => res.send('<h1>Web OK</h1>'))
  app.listen(PORT, () => console.log(`Web on ${PORT}`))
  JS
  npm install
  pm2 start server.js --name example-web --time
'

Quick checks:

curl -s http://127.0.0.1:4000/api/health   # {"ok":1}
curl -sI http://127.0.0.1:3000 | head -n1  # HTTP/1.1 200 OK

🌐 Nginx reverse proxy + SSL

Remove Apache/other daemons using port 80 (if present):

sudo systemctl stop apache2 httpd 2>/dev/null || true
sudo apt -y purge 'apache2*' 2>/dev/null || true

Install Nginx + Certbot:

sudo apt-get update -y
sudo apt-get install -y nginx certbot python3-certbot-nginx
sudo ufw allow 'Nginx Full' 2>/dev/null || true

Create vhost for example.com:

DOMAIN=example.com
sudo tee /etc/nginx/sites-available/$DOMAIN > /dev/null <<'NGX'
# HTTP → HTTPS
server {
  listen 80;
  server_name example.com www.example.com;
  return 301 https://$host$request_uri;
}

# HTTPS
server {
  listen 443 ssl http2;
  server_name example.com www.example.com;

  ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
  include /etc/letsencrypt/options-ssl-nginx.conf;
  ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

  # Redirect the bare /api → /api/health for a quick OK
  rewrite ^/api/?$ /api/health permanent;

  # Frontend → 3000  (trailing slash OK)
  location / {
    proxy_pass http://127.0.0.1:3000/;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
  }

  # API → 4000  (NO trailing slash)
  location /api/ {
    proxy_pass http://127.0.0.1:4000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
  }
}
NGX

sudo ln -sf /etc/nginx/sites-available/$DOMAIN /etc/nginx/sites-enabled/$DOMAIN
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx

Issue SSL (Let’s Encrypt) and enable redirect automatically:

# This will also write the TLS bits if not present yet
sudo certbot --nginx -d example.com -d www.example.com --redirect -m admin@example.com --agree-tos -n

Test through domain:

curl -IL https://example.com/ | head -n3     # 200 or 301→200
curl -s  https://example.com/api/health      # {"ok":1}

If DNS is still propagating, force SNI to your IP:

curl -s --resolve example.com:443:203.0.113.10 https://example.com/api/health

♻️ PM2 auto‑start on reboot

sudo -iu $APPUSER bash -lc '
  pm2 save
  pm2 startup systemd -u $USER --hp $HOME | sed -n "s/^.*sudo //p" | sudo -E bash
'

✅ Verification checklist

  • https://example.com/ serves your frontend

  • https://example.com/api/health returns { "ok": 1 }

  • pm2 status shows example-web and example-api online

  • sudo systemctl status nginx is active (running)

  • SSL padlock shows as valid (auto‑renew enabled)

Bonus: Visiting https://example.com/api/ (exact) should 301 to /api/health.


🩺 Troubleshooting cookbook

“Cannot GET /api/”
Likely trailing‑slash mismatch. In Nginx:

  • location /api/ { proxy_pass http://127.0.0.1:4000; }no trailing slash on upstream

  • location / { proxy_pass http://127.0.0.1:3000/; }with trailing slash for web

Port 80 already in use / Nginx won’t start
Stop Apache/panel web server:

sudo ss -lntp | grep ':80' || true
sudo systemctl stop apache2 httpd 2>/dev/null || true
sudo pkill -9 httpd 2>/dev/null || true
sudo fuser -k 80/tcp 2>/dev/null || true
sudo systemctl start nginx

Mongo auth failed
Check DB user, database, and authSource in your MONGO_URI.

DNS not resolving yet
Verify registrar A‑record and use --resolve to test early.

Default page still shows
Ensure only your site is enabled and default vhost removed:

ls -l /etc/nginx/sites-enabled
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx

🔐 Security & maintenance

  • Firewall: allow only 22, 80, 443. (e.g., ufw allow OpenSSH && ufw allow 'Nginx Full')

  • PM2 logs: pm2 logs, pm2 flush

  • SSL auto‑renew: Certbot sets a daily timer (check with systemctl list-timers | grep certbot).

  • Backups: Git for code; DB dumps via mongodump (or volume snapshots if Docker).

  • Updates: sudo apt update && sudo apt upgrade -y (monthly cadence).


📎 Appendix: Full configs

/etc/nginx/sites-available/example.com (final)

# HTTP → HTTPS
server {
  listen 80;
  server_name example.com www.example.com;
  return 301 https://$host$request_uri;
}

# HTTPS
server {
  listen 443 ssl http2;
  server_name example.com www.example.com;

  ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
  include /etc/letsencrypt/options-ssl-nginx.conf;
  ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

  rewrite ^/api/?$ /api/health permanent;

  location / {
    proxy_pass http://127.0.0.1:3000/;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
  }

  location /api/ {
    proxy_pass http://127.0.0.1:4000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
  }
}

Example .env usage

# api/.env
NODE_ENV=production
PORT=4000
MONGO_URI=mongodb://dbuser:dbpass@127.0.0.1:27017/appdb?authSource=appdb

# web/.env (framework‑specific; example only)
NODE_ENV=production
PORT=3000
NEXT_PUBLIC_API_BASE=/api

🔗 Related articles


Was this answer helpful?

« Back