Running MERN/MEAN on a Clean VPS (No Control Panel): A Complete, BattleTested Playbook
In this article
- 1What we're building
- 2Why go controlpanel free for MERN/MEAN
- 3Prerequisites
- 4Create system user & folders
- 5Install Node & PM2 (peruser)
ProductionReady 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 copypaste commands, health checks, and fixes for common pitfalls like "Cannot GET /api/".
Table of Contents
-
What we're building
-
Why go controlpanel free for MERN/MEAN
-
Prerequisites
-
Create system user & folders
-
Install Node & PM2 (peruser)
-
Optional: MongoDB quick start
-
Minimal placeholder apps (health checks)
-
Nginx reverse proxy + SSL
-
PM2 autostart on reboot
-
Verification checklist
-
Troubleshooting cookbook
-
Security & maintenance
-
Appendix: Full configs
-
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/healthfor an ataglance check.
Why go controlpanel free for MERN/MEAN
-
Purposebuilt: 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 trailingslash detail in
proxy_passthat breaks many APIs). -
Appcentric ops: PM2 +
.env+ logs easier CI/CD and debugging. -
Futureproof: 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
sudoorroot -
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 (peruser)
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 [email protected] --agree-tos -n
Test through domain:
curl -IL https://example.com/ | head -n3 # 200 or 301200
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 autostart 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/healthreturns{ "ok": 1 } -
pm2 statusshows example-web and example-api online -
sudo systemctl status nginxis active (running) -
SSL padlock shows as valid (autorenew enabled)
Bonus: Visiting
https://example.com/api/(exact) should 301 to/api/health.
Troubleshooting cookbook
"Cannot GET /api/"
Likely trailingslash 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 Arecord 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 autorenew: 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:[email protected]:27017/appdbauthSource=appdb
# web/.env (frameworkspecific; example only)
NODE_ENV=production
PORT=3000
NEXT_PUBLIC_API_BASE=/api
Related articles
-
Dockerizing a MERN app for smooth rollbacks
Was this article helpful?
Your feedback helps us improve our documentation
Still need help? Submit a support ticket