🚀 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
-
What we’re building
-
Why go control‑panel free for MERN/MEAN
-
Prerequisites
-
Create system user & folders
-
Install Node & PM2 (per‑user)
-
Optional: MongoDB quick start
-
Minimal placeholder apps (health checks)
-
Nginx reverse proxy + SSL
-
PM2 auto‑start 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/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
orroot
-
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
-
Dockerizing a MERN app for smooth rollbacks