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 Mean Stack Ubuntu
GUIDEInstall Guides

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

26 min read

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

Deploying a full MEAN stack on your own VPS gives you end-to-end control over a modern JavaScript application: a document database, a Node.js API, a single-page Angular front end, and a hardened reverse proxy — all on infrastructure you own. This guide walks through a production-grade MEAN deployment on Ubuntu 24.04 LTS, from MongoDB 7 and Node.js 20 installation through Angular 17 builds, PM2 clustering, Nginx TLS termination, JWT authentication, and backup strategy.

Want to skip the server bootstrap? Spin up a CloudCore Professional VPS pre-configured with Ubuntu 24.04 and SSH key auth, and follow this guide from Step 1.

Table of Contents

  • What is the MEAN Stack?
  • Why Self-Host a MEAN Stack on Your VPS?
  • Prerequisites
  • Step 1: Update the System and Harden SSH
  • Step 2: Install MongoDB 7
  • Step 3: Install Node.js 20 LTS
  • Step 4: Install Angular CLI and Create the Workspace
  • Step 5: Build the Express API
  • Step 6: Wire Angular to the API with HttpClient and Environments
  • Step 7: Add JWT Authentication and Interceptors
  • Step 8: Build Angular for Production
  • Step 9: Run Express Under PM2 in Cluster Mode
  • Step 10: Configure Nginx as Reverse Proxy with TLS
  • Step 11: Enable MongoDB Authentication
  • Step 12: Environment Variables via systemd EnvironmentFile
  • Optional: Angular SSR with Angular Universal
  • Backups and Upgrades
  • Troubleshooting
  • FAQ
  • Next Steps
  • What is the MEAN Stack?

    The MEAN stack is an end-to-end JavaScript/TypeScript web development stack made up of four components that share a common language and data format (JSON). The name is an acronym:

    • M — MongoDB. A NoSQL document database that stores data as flexible, JSON-like BSON documents. It excels at unstructured or rapidly evolving schemas, horizontal scaling via sharding, and high-write workloads.
    • E — Express. A minimal, unopinionated Node.js web framework that handles routing, middleware, request parsing, and HTTP response generation. It is the de-facto standard for REST APIs on Node.
    • A — Angular. A batteries-included front-end framework from Google, now at version 17+, written in TypeScript. It ships with dependency injection, a router, RxJS-based HTTP, reactive forms, and a powerful CLI.
    • N — Node.js. The JavaScript runtime (built on V8) that powers both Express on the back end and the Angular build tooling during development.
    Together, these components form a cohesive stack where a single team can write the front end, API, and data layer in one language. Data flows as JSON from MongoDB through Express to Angular and back, with no object-relational mapping layer in between.

    The MEAN architecture typically looks like this on a single VPS:

    text
    Browser ──HTTPS──▶ Nginx (:443)
                         │
                         ├── / ──────▶ Angular bundle (static files in /dist/app/browser)
                         │
                         └── /api/* ─▶ Express (:3000, PM2 cluster)
                                           │
                                           └──▶ MongoDB (:27017, localhost)

    Nginx terminates TLS, serves the compiled Angular bundle as static files, and proxies /api/* to the Express cluster. Express talks to MongoDB over the local loopback. The entire stack runs on one VPS until you need to scale out.

    Why Self-Host a MEAN Stack on Your VPS?

    Managed platforms (Heroku, Vercel, MongoDB Atlas, Render) each solve a slice of the MEAN stack, but self-hosting on a single VPS has real advantages:

    • Flat, predictable cost. A VPS runs Mongo, Express, and Nginx for one monthly price regardless of traffic spikes or storage growth. Atlas alone costs more than an entire CloudCore Professional plan once you pass the free tier.
    • No cold starts. Serverless platforms spin down idle functions; your PM2 cluster stays warm 24/7, keeping API latency in the single-digit millisecond range.
    • Full data control. Your MongoDB is on your disk, encrypted with your keys, backed up on your schedule. No vendor can read it or lock you out of it.
    • Arbitrary scaling knobs. Change the PM2 instance count, tune MongoDB wiredTigerCacheSizeGB, add swap, enable HTTP/2 — all without tier upgrades or forum requests.
    • Simpler networking. Mongo, Express, and your front-end bundle all live on localhost. No VPC peering, no egress charges, no cross-region latency.
    • One deploy target. A single git pull && npm ci && pm2 reload ecosystem.config.js updates the entire back end. Angular is a rsync or git pull away.
    For small SaaS apps, internal tools, portfolios, and agency client sites, a MEAN stack on a single CloudCore Professional VPS comfortably supports thousands of daily active users.

    Prerequisites

    Before you begin, you need:

    • A VPS running Ubuntu 24.04 LTS with root or sudo access.
    • SSH access to your server.
    • A domain name pointed at your server's IPv4 address (for TLS). For the examples below we use app.example.com.
    • At least 4 GB RAM (8 GB recommended so MongoDB has a healthy WiredTiger cache alongside Node).
    • At least 20 GB of disk (40 GB+ recommended for application data and Mongo indexes).
    • Basic command-line comfort — editing files with nano or vim, running sudo, reading logs.
    Recommended Plan: CloudCore Professional
    >
    For a MEAN stack running MongoDB 7, a PM2 cluster of 2–4 Express workers, and an Angular static bundle behind Nginx, we recommend the CloudCore Professional plan:
    >
    - 6 vCPU cores
    - 12 GB RAM
    - 100 GB NVMe SSD
    - Unmetered bandwidth
    >
    This leaves plenty of headroom for the WiredTiger cache, multiple Node workers, and any auxiliary services (Redis for sessions, a log shipper, etc.).

    Connect to your server via SSH:

    bash
    ssh root@your-server-ip

    Step 1: Update the System and Harden SSH

    Start by refreshing the package index and upgrading installed packages so dependency resolution and security patches are current.

    bash
    sudo apt update && sudo apt upgrade -y

    Install a handful of utilities you'll rely on throughout the guide:

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

    Enable the firewall with sane defaults — allow SSH, HTTP, and HTTPS; deny everything else:

    bash
    sudo ufw default deny incoming
    sudo ufw default allow outgoing
    sudo ufw allow OpenSSH
    sudo ufw allow 80/tcp
    sudo ufw allow 443/tcp
    sudo ufw enable

    If the kernel was updated in the apt upgrade step, reboot before continuing:

    bash
    sudo reboot

    Reconnect via SSH after a minute.

    Step 2: Install MongoDB 7

    MongoDB is not in the default Ubuntu repositories. You install it from the official MongoDB APT repository, which provides 7.0 LTS for Ubuntu 24.04 (Noble).

    Add the MongoDB GPG Key and Repository

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

    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: MongoDB publishes Ubuntu 22.04 (jammy) packages that work on Ubuntu 24.04. As of this writing, there is no dedicated noble repository yet — the jammy packages are the officially documented path on Ubuntu 24.04.

    Install and Start MongoDB

    bash
    sudo apt update
    sudo apt install -y mongodb-org
    sudo systemctl enable --now mongod

    Verify the service 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.0M

    Connect with the shell to confirm the server responds:

    bash
    mongosh

    Inside the shell:

    text
    test> db.runCommand({ ping: 1 })
    { ok: 1 }
    test> exit

    For a deeper walkthrough of MongoDB installation options, replica sets, and tuning, see our MongoDB install guide.

    Step 3: Install Node.js 20 LTS

    Ubuntu's default Node.js package lags behind upstream. Install Node.js 20 LTS directly from NodeSource so you get the current LTS line plus npm, npx, and corepack.

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

    Verify the versions:

    bash
    node --version
    npm --version

    Expected output:

    text
    v20.12.2
    10.5.0

    Enable Corepack (ships with Node 20) so you can use pnpm or yarn without a separate install:

    bash
    sudo corepack enable

    See the Node.js install guide for details on managing multiple Node versions with nvm or fnm, which is useful if your team pins different versions per project.

    Step 4: Install Angular CLI and Create the Workspace

    The Angular CLI scaffolds projects, runs dev servers, and produces production builds. Install it globally:

    bash
    sudo npm i -g @angular/cli

    Verify:

    bash
    ng version

    Expected output (abbreviated):

    text
    Angular CLI: 17.3.0
    Node: 20.12.2
    Package Manager: npm 10.5.0

    Create a dedicated directory for the project and scaffold the Angular workspace. We'll keep the API and front end in sibling directories:

    bash
    sudo mkdir -p /var/www/myapp
    sudo chown -R $USER:$USER /var/www/myapp
    cd /var/www/myapp

    ng new web --routing --style=scss --ssr=false --skip-git

    The CLI will prompt for analytics — decline if you prefer. You'll end up with:

    text
    /var/www/myapp/
    └── web/
        ├── src/
        ├── angular.json
        ├── package.json
        └── tsconfig.json

    Test the dev server briefly to confirm everything scaffolded correctly:

    bash
    cd /var/www/myapp/web
    ng serve --host 127.0.0.1 --port 4200

    Press Ctrl+C to stop. We won't use ng serve in production — it's a development-only server.

    For details on Angular itself, the CLI reference, and component architecture, see angular.dev.

    Step 5: Build the Express API

    Now create a sibling api directory for the Express server.

    bash
    mkdir -p /var/www/myapp/api
    cd /var/www/myapp/api
    npm init -y
    npm install express mongoose cors helmet morgan dotenv jsonwebtoken bcryptjs
    npm install -D nodemon

    Project Layout

    text
    /var/www/myapp/api/
    ├── src/
    │   ├── index.js
    │   ├── db.js
    │   ├── middleware/
    │   │   └── auth.js
    │   └── routes/
    │       ├── auth.js
    │       └── items.js
    ├── .env
    └── package.json

    src/db.js

    bash
    mkdir -p /var/www/myapp/api/src/{routes,middleware}
    javascript
    // /var/www/myapp/api/src/db.js
    const mongoose = require('mongoose');

    async function connectDB() { const uri = process.env.MONGODB_URI; if (!uri) throw new Error('MONGODB_URI is not set'); mongoose.set('strictQuery', true); await mongoose.connect(uri); console.log('MongoDB connected'); }

    module.exports = { connectDB };

    src/index.js

    javascript
    // /var/www/myapp/api/src/index.js
    require('dotenv').config();
    const express = require('express');
    const cors = require('cors');
    const helmet = require('helmet');
    const morgan = require('morgan');
    const { connectDB } = require('./db');

    const app = express();

    app.use(helmet()); app.use(cors({ origin: process.env.CORS_ORIGIN || 'https://app.example.com' })); app.use(express.json({ limit: '1mb' })); app.use(morgan('combined'));

    app.get('/api/health', (req, res) => res.json({ ok: true, ts: Date.now() }));

    app.use('/api/auth', require('./routes/auth')); app.use('/api/items', require('./routes/items'));

    app.use((err, req, res, next) => { console.error(err); res.status(err.status || 500).json({ error: err.message || 'Server error' }); });

    const PORT = process.env.PORT || 3000; connectDB() .then(() => app.listen(PORT, '127.0.0.1', () => console.log(API on :${PORT}))) .catch((e) => { console.error(e); process.exit(1); });

    src/routes/auth.js (simplified)

    javascript
    // /var/www/myapp/api/src/routes/auth.js
    const router = require('express').Router();
    const bcrypt = require('bcryptjs');
    const jwt = require('jsonwebtoken');
    const mongoose = require('mongoose');

    const User = mongoose.model('User', new mongoose.Schema({ email: { type: String, unique: true, required: true, index: true }, passwordHash: { type: String, required: true }, }, { timestamps: true }));

    router.post('/register', async (req, res) => { const { email, password } = req.body; if (!email || !password) return res.status(400).json({ error: 'email+password required' }); const passwordHash = await bcrypt.hash(password, 12); const user = await User.create({ email, passwordHash }); res.status(201).json({ id: user.id }); });

    router.post('/login', async (req, res) => { const { email, password } = req.body; const user = await User.findOne({ email }); if (!user || !(await bcrypt.compare(password, user.passwordHash))) { return res.status(401).json({ error: 'Invalid credentials' }); } const token = jwt.sign({ sub: user.id }, process.env.JWT_SECRET, { expiresIn: '1h' }); res.json({ token }); });

    module.exports = router;

    src/middleware/auth.js

    javascript
    // /var/www/myapp/api/src/middleware/auth.js
    const jwt = require('jsonwebtoken');

    module.exports = 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' }); } };

    .env

    bash
    # /var/www/myapp/api/.env
    NODE_ENV=production
    PORT=3000
    MONGODB_URI=mongodb://apiuser:[email protected]:27017/myapp?authSource=myapp
    JWT_SECRET=change-this-to-a-long-random-string
    CORS_ORIGIN=https://app.example.com

    Generate a strong JWT secret:

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

    Step 6: Wire Angular to the API with HttpClient and Environments

    Angular's HttpClient handles JSON requests against your Express API. Use environment files to switch the API base URL between development and production.

    Environment Files

    In /var/www/myapp/web/src/environments/:

    typescript
    // environment.ts (development)
    export const environment = {
      production: false,
      apiUrl: 'http://localhost:3000/api',
    };
    typescript
    // environment.prod.ts (production)
    export const environment = {
      production: true,
      apiUrl: '/api',
    };

    In production, Angular is served from the same origin as the API (via Nginx), so a relative /api path avoids CORS entirely.

    Register HttpClient

    In /var/www/myapp/web/src/app/app.config.ts (standalone API used by default in Angular 17):

    typescript
    import { ApplicationConfig } from '@angular/core';
    import { provideRouter } from '@angular/router';
    import { provideHttpClient, withInterceptors } from '@angular/common/http';
    import { routes } from './app.routes';
    import { authInterceptor } from './auth.interceptor';

    export const appConfig: ApplicationConfig = { providers: [ provideRouter(routes), provideHttpClient(withInterceptors([authInterceptor])), ], };

    A Simple Service

    typescript
    // src/app/items.service.ts
    import { Injectable, inject } from '@angular/core';
    import { HttpClient } from '@angular/common/http';
    import { environment } from '../environments/environment';

    @Injectable({ providedIn: 'root' }) export class ItemsService { private http = inject(HttpClient); list() { return this.http.get<Item[]>(${environment.apiUrl}/items); } create(body: Partial<Item>) { return this.http.post<Item>(${environment.apiUrl}/items, body); } }

    export interface Item { _id: string; title: string; createdAt: string; }

    Step 7: Add JWT Authentication and Interceptors

    The Express /api/auth/login endpoint returns a JWT. On the Angular side, store the token, attach it to outgoing requests, and handle 401 responses by redirecting to login.

    Token Storage

    typescript
    // src/app/auth.service.ts
    import { Injectable, inject, signal } from '@angular/core';
    import { HttpClient } from '@angular/common/http';
    import { environment } from '../environments/environment';

    @Injectable({ providedIn: 'root' }) export class AuthService { private http = inject(HttpClient); token = signal<string | null>(localStorage.getItem('token'));

    login(email: string, password: string) { return this.http .post<{ token: string }>(${environment.apiUrl}/auth/login, { email, password }); }

    setToken(t: string) { localStorage.setItem('token', t); this.token.set(t); }

    logout() { localStorage.removeItem('token'); this.token.set(null); } }

    Functional Interceptor (Angular 17 style)

    typescript
    // src/app/auth.interceptor.ts
    import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
    import { inject } from '@angular/core';
    import { Router } from '@angular/router';
    import { catchError, throwError } from 'rxjs';
    import { AuthService } from './auth.service';

    export const authInterceptor: HttpInterceptorFn = (req, next) => { const auth = inject(AuthService); const router = inject(Router); const token = auth.token();

    const authed = token ? req.clone({ setHeaders: { Authorization: Bearer ${token} } }) : req;

    return next(authed).pipe( catchError((err: HttpErrorResponse) => { if (err.status === 401) { auth.logout(); router.navigateByUrl('/login'); } return throwError(() => err); }) ); };

    This interceptor automatically attaches the JWT to every outbound request and logs the user out on 401, giving you a clean auth flow without duplicating logic in every component.

    Step 8: Build Angular for Production

    Run the production build. Angular 17 emits a browser/ subdirectory containing the static assets.

    bash
    cd /var/www/myapp/web
    ng build --configuration production

    Expected output (abbreviated):

    text
    Application bundle generation complete.

    Initial chunk files | Names | Raw size | Estimated transfer size main-XXXXX.js | main | 220.00 kB | 65.00 kB polyfills-XXXXX.js | polyfills | 33.00 kB | 11.00 kB styles-XXXXX.css | styles | 5.00 kB | 1.50 kB

    Output location: /var/www/myapp/web/dist/web/browser

    The bundle lives at /var/www/myapp/web/dist/web/browser and contains only static files — Nginx will serve them directly. There is no Node.js process involved in serving the front end unless you enable SSR (see Optional: Angular SSR).

    To rebuild on each deploy, wire this into your CI or run it manually after git pull:

    bash
    cd /var/www/myapp/web
    npm ci
    ng build --configuration production

    Step 9: Run Express Under PM2 in Cluster Mode

    PM2 is a process manager for Node.js that handles clustering, restarts on crash, log rotation, and startup integration with systemd. Install it globally:

    bash
    sudo npm i -g pm2

    Create an ecosystem file at /var/www/myapp/api/ecosystem.config.js:

    javascript
    module.exports = {
      apps: [
        {
          name: 'myapp-api',
          script: 'src/index.js',
          cwd: '/var/www/myapp/api',
          instances: 'max',         // one worker per CPU core
          exec_mode: 'cluster',
          max_memory_restart: '512M',
          env: { NODE_ENV: 'production' },
        },
      ],
    };

    Start the cluster:

    bash
    cd /var/www/myapp/api
    pm2 start ecosystem.config.js
    pm2 save
    pm2 startup systemd -u $USER --hp /home/$USER

    The last command prints a sudo command that wires PM2 into systemd. Run it exactly as shown.

    Check the cluster:

    bash
    pm2 list

    Expected output:

    text
    ┌────┬───────────────┬─────────┬─────────┬──────────┬────────┬──────┬──────────┐
    │ id │ name          │ mode    │ status  │ cpu      │ mem    │ ↺    │ user     │
    ├────┼───────────────┼─────────┼─────────┼──────────┼────────┼──────┼──────────┤
    │ 0  │ myapp-api     │ cluster │ online  │ 0%       │ 52mb   │ 0    │ deploy   │
    │ 1  │ myapp-api     │ cluster │ online  │ 0%       │ 51mb   │ 0    │ deploy   │
    │ 2  │ myapp-api     │ cluster │ online  │ 0%       │ 51mb   │ 0    │ deploy   │
    │ 3  │ myapp-api     │ cluster │ online  │ 0%       │ 53mb   │ 0    │ deploy   │
    └────┴───────────────┴─────────┴─────────┴──────────┴────────┴──────┴──────────┘

    PM2 load-balances incoming connections across workers using the Node cluster module. A crashed worker is restarted in under a second without dropping the other workers' in-flight requests.

    To apply a code update with zero downtime:

    bash
    pm2 reload myapp-api

    Step 10: Configure Nginx as Reverse Proxy with TLS

    Nginx serves the Angular bundle from / and proxies /api/* to Express. See our dedicated Nginx install and configuration guide for the full reference.

    Install Nginx and Certbot

    bash
    sudo apt install -y nginx certbot python3-certbot-nginx

    Server Block

    Create /etc/nginx/sites-available/myapp:

    nginx
    server {
        listen 80;
        server_name app.example.com;
        return 301 https://$host$request_uri;
    }

    server { listen 443 ssl http2; server_name app.example.com;

    # Filled in by certbot --nginx ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;

    root /var/www/myapp/web/dist/web/browser; index index.html;

    # Security headers add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options DENY always; add_header Referrer-Policy strict-origin-when-cross-origin always;

    # Long-cache hashed bundles location ~* \.(js|css|woff2?|svg|png|jpg|jpeg|gif|ico)$ { expires 1y; add_header Cache-Control "public, immutable"; try_files $uri =404; }

    # Angular client-side routing fallback location / { try_files $uri $uri/ /index.html; }

    # API proxy 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_read_timeout 60s; proxy_send_timeout 60s; }

    client_max_body_size 10m; }

    Enable the site and issue a certificate:

    bash
    sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
    sudo rm -f /etc/nginx/sites-enabled/default
    sudo nginx -t
    sudo certbot --nginx -d app.example.com
    sudo systemctl reload nginx

    Certbot rewrites the server block to point at the new certificate paths and installs a renewal timer. Confirm:

    bash
    sudo systemctl list-timers | grep certbot

    Visit https://app.example.com — you should see your Angular app. Hit https://app.example.com/api/health to confirm the API is reachable through the proxy.

    Step 11: Enable MongoDB Authentication

    By default, MongoDB allows unauthenticated local connections. For production, create an admin user, enable auth, and create a least-privilege user for your application.

    Create the Admin and App Users

    bash
    mongosh

    In the shell:

    javascript
    use admin
    db.createUser({
      user: 'root',
      pwd: 'STRONG-ADMIN-PASSWORD',
      roles: [{ role: 'root', db: 'admin' }]
    })

    use myapp db.createUser({ user: 'apiuser', pwd: 'STRONG-APP-PASSWORD', roles: [{ role: 'readWrite', db: 'myapp' }] })

    exit

    Enable authorization

    Edit /etc/mongod.conf:

    yaml
    security:
      authorization: enabled

    net: bindIp: 127.0.0.1 port: 27017

    Restart Mongo:

    bash
    sudo systemctl restart mongod

    Update MONGODB_URI in /var/www/myapp/api/.env to the authenticated connection string:

    text
    MONGODB_URI=mongodb://apiuser:[email protected]:27017/myapp?authSource=myapp

    Reload PM2 to pick up the new env:

    bash
    pm2 reload myapp-api --update-env

    Confirm with mongosh "mongodb://apiuser:[email protected]:27017/myapp?authSource=myapp". For the deep dive on MongoDB roles, replica sets, and backup strategy, see mongodb.com/docs.

    Step 12: Environment Variables via systemd EnvironmentFile

    PM2 reads .env via dotenv during require('dotenv').config(). For stricter setups — or if you run Express directly under systemd instead of PM2 — use a systemd unit with an EnvironmentFile.

    Create an env file

    bash
    sudo install -d -m 750 -o root -g $USER /etc/myapp
    sudo tee /etc/myapp/api.env > /dev/null <<'EOF'
    NODE_ENV=production
    PORT=3000
    MONGODB_URI=mongodb://apiuser:[email protected]:27017/myapp?authSource=myapp
    JWT_SECRET=your-long-random-secret
    CORS_ORIGIN=https://app.example.com
    EOF
    sudo chmod 640 /etc/myapp/api.env

    Systemd unit (alternative to PM2)

    If you prefer systemd over PM2, create /etc/systemd/system/myapp-api.service:

    ini
    [Unit]
    Description=MyApp Express API
    After=network.target mongod.service
    Requires=mongod.service

    [Service] Type=simple User=deploy WorkingDirectory=/var/www/myapp/api EnvironmentFile=/etc/myapp/api.env ExecStart=/usr/bin/node src/index.js Restart=on-failure RestartSec=5 LimitNOFILE=65535

    [Install] WantedBy=multi-user.target

    bash
    sudo systemctl daemon-reload
    sudo systemctl enable --now myapp-api

    With this approach you get built-in journald logging (journalctl -u myapp-api -f) and no PM2 dependency. You lose cluster mode unless you spawn workers from inside index.js using the Node cluster module or run multiple unit instances behind an Nginx upstream.

    Optional: Angular SSR with Angular Universal

    Client-rendered Angular apps load fast for returning visitors but show a blank page until the JS bundle parses — bad for SEO and slow on first paint. Angular Universal (now integrated into the CLI as @angular/ssr) solves this by rendering the first HTML on the server.

    Add SSR to an existing project:

    bash
    cd /var/www/myapp/web
    ng add @angular/ssr

    This:

  • Creates server.ts that boots an Express server rendering Angular on each request.
  • Adds a serve-ssr and build target that outputs both browser/ and server/ bundles.
  • Pre-renders static routes at build time and SSRs dynamic ones at runtime.
  • Rebuild:

    bash
    ng build

    Run the SSR server (typically on a different port from your API, e.g. :4000):

    bash
    PORT=4000 node dist/web/server/server.mjs

    Then update Nginx so / proxies to the SSR server instead of serving static files directly:

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

    Add a second PM2 app in ecosystem.config.js for the SSR process. SSR roughly doubles the memory footprint of your front end — budget 200–400 MB extra RAM per SSR worker on top of your API workers.

    For most MEAN apps (dashboards, internal tools, SaaS apps behind auth), SSR is not worth the complexity. Enable it only if your public pages need first-paint SEO.

    Backups and Upgrades

    MongoDB Backups

    A simple, robust strategy is a nightly mongodump to a local directory, with offsite rotation via rsync or S3.

    bash
    sudo install -d -m 750 -o mongodb -g mongodb /var/backups/mongo

    Create /usr/local/bin/mongo-backup.sh:

    bash
    #!/usr/bin/env bash
    set -euo pipefail
    STAMP=$(date +%Y%m%d-%H%M%S)
    DEST=/var/backups/mongo/$STAMP
    mkdir -p "$DEST"
    mongodump --uri="mongodb://root:[email protected]:27017/?authSource=admin" \
      --out "$DEST"
    tar -czf "$DEST.tar.gz" -C /var/backups/mongo "$STAMP"
    rm -rf "$DEST"
    

    Keep last 14 days

    find /var/backups/mongo -name '*.tar.gz' -mtime +14 -delete
    bash
    sudo chmod 700 /usr/local/bin/mongo-backup.sh
    sudo tee /etc/cron.d/mongo-backup > /dev/null <<'EOF'
    30 3   * root /usr/local/bin/mongo-backup.sh >> /var/log/mongo-backup.log 2>&1
    EOF

    For offsite storage, pipe the tarball to S3-compatible storage (aws s3 cp, rclone, or restic) inside the same script.

    Application Deploys

    For zero-downtime deploys of both the API and the front end:

    bash
    cd /var/www/myapp/api && git pull && npm ci --omit=dev && pm2 reload myapp-api
    cd /var/www/myapp/web && git pull && npm ci && ng build --configuration production

    Nginx picks up the new Angular bundle on the next request — no reload required as long as the output directory path is unchanged.

    Upgrading Node.js

    Major Node versions come out yearly (October LTS). To upgrade from 20 to 22:

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

    Test your API's full test suite before upgrading in production — Node majors occasionally change default TLS ciphers or deprecate APIs.

    Upgrading MongoDB

    Always upgrade one minor version at a time (7.0 → 7.0.latest → 8.0 when it reaches GA). Run mongodump before each upgrade. Swap the apt repo line, then:

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

    Troubleshooting

    ProblemCauseSolution
    ng build fails with JavaScript heap out of memoryNode default heap too small for large Angular buildsNODE_OPTIONS=--max-old-space-size=4096 ng build --configuration production
    Angular app loads but API calls return 404Nginx location /api/ missing or order wrongConfirm /api/ block is above the catch-all /. Run sudo nginx -t and reload.
    Refreshing a deep Angular route returns 404Missing SPA fallbackEnsure try_files $uri $uri/ /index.html; in the location / block.
    MongoNetworkError: connect ECONNREFUSED 127.0.0.1:27017MongoDB not running or bound to the wrong IPsudo systemctl status mongod. Check bindIp in /etc/mongod.conf.
    Authentication failed from Mongo after enabling authWrong authSource or passwordConnection string needs ?authSource=myapp (the DB the user was created in).
    PM2 cluster workers thrash / restart loopWorker crashing on startup — check logspm2 logs myapp-api --lines 200. Common cause: missing env var.
    502 Bad Gateway from NginxExpress not listening or listening on wrong portss -tlnp \</td><td>grep 3000<code>. Check </code>PORT<code> in </code>.env.
    CORS errors in dev but not prodDev hits :4200 and :3000 directlySet CORS_ORIGIN=http://localhost:4200 in dev, or use Angular proxy.conf.json to proxy /api through ng serve.
    Certbot renewal failsPort 80 blocked or different web server in the waysudo ufw allow 80/tcp. Stop any service on :80 during renewal or use --webroot mode.

    Useful Log Commands

    bash
    # Express (PM2)
    pm2 logs myapp-api --lines 200

    Express (systemd)

    sudo journalctl -u myapp-api -f

    MongoDB

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

    Nginx

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

    FAQ

    What are the minimum specs for a MEAN stack VPS?

    A usable MEAN stack — MongoDB 7, a small PM2 cluster of 2 Express workers, Nginx, and an Angular bundle — runs on 2 vCPU / 4 GB RAM / 20 GB SSD for light traffic. MongoDB is the memory-hungry component; its WiredTiger cache defaults to half of (RAM - 1 GB). On 4 GB servers, explicitly cap it with storage.wiredTiger.engineConfig.cacheSizeGB: 1 in /etc/mongod.conf so Node has room to breathe. For any real production workload, we recommend 8+ GB RAM — the CloudCore Professional plan at 12 GB is well-sized for thousands of daily users.

    Should I use MongoDB Atlas instead of self-hosting Mongo?

    Atlas is excellent for teams that don't want to operate a database — it handles backups, point-in-time recovery, automated upgrades, and multi-region replicas. But once you outgrow the free tier, the smallest paid dedicated cluster costs more per month than an entire CloudCore Professional VPS running the full MEAN stack. Self-host when: cost matters, data residency matters, latency matters (same-VPS Mongo is sub-millisecond), or you already run other services on the same server. Use Atlas when: you don't want on-call duties for the DB, you need multi-region automatic failover, or your team is deeply in the AWS/GCP ecosystem already.

    Do I need Angular SSR for SEO?

    Only for public, SEO-critical pages. Google crawls client-rendered JavaScript well in 2025 — crawlability is rarely the problem. First paint / Largest Contentful Paint (LCP) and social link previews (OpenGraph/Twitter cards) are the usual motivators. If your MEAN app is a dashboard behind login, SSR adds complexity with zero SEO benefit. If you're building a marketing site or blog, SSR or SSG (ng build with prerendered routes) is worthwhile.

    How do I scale beyond a single VPS?

    The single-VPS architecture in this guide scales vertically (bigger plan) to handle tens of thousands of daily users. To scale horizontally: (1) Move MongoDB to its own server or to a replica set. (2) Move Nginx to a load balancer and run multiple Express VPS behind it — keep sessions stateless (JWT, not server-side sessions) so any node can handle any request. (3) Put the Angular bundle on a CDN (Cloudflare, Bunny, CloudFront) and let Nginx handle only /api/*. The same code runs unchanged across all three stages — you're just changing where the bits live.

    Can I use pnpm or yarn instead of npm?

    Yes. Corepack (bundled with Node 20+) manages pnpm and yarn versions from package.json. pnpm is particularly nice on a VPS because its content-addressed store deduplicates dependencies across multiple projects, saving both disk and install time. Just replace npm i with pnpm i and npm ci with pnpm i --frozen-lockfile. Angular CLI fully supports both.

    How do I add Redis for sessions or caching?

    sudo apt install -y redis-server and you're off. For a pure JWT auth flow like the one in this guide, you don't need Redis — tokens are stateless. Add Redis when you need rate limiting (express-rate-limit with a Redis store), server-side caching of expensive Mongo queries, or a job queue (bullmq). Redis on the same VPS is ~5 MB RAM at idle and adds negligible operational load.

    Next Steps

    With the MEAN stack running end-to-end, here are recommended next steps:

    • Add structured logging — Replace morgan + console.log with pino or winston writing JSON to stdout. Pipe logs into Loki/Grafana, Elasticsearch, or Datadog for searchable, structured log exploration.
    • Wire up application monitoring — Deploy Uptime Kuma for endpoint health and add prom-client to Express for Prometheus metrics. Graph request latency, error rate, and Mongo query times in Grafana.
    • Enable HTTP/2 and Brotli in Nginx — HTTP/2 is already on from listen 443 ssl http2;. Add brotli on; via libnginx-mod-brotli for ~15-20% better compression than gzip on your Angular bundle.
    • Set up GitHub Actions for CI/CD — Run npm ci, ng test, and ng build on every push. Deploy via SSH + pm2 reload on merges to main. Keep build artifacts cached to speed up subsequent runs.
    • Add rate limiting and brute-force protection — express-rate-limit on /api/auth/login (say, 10 requests/minute per IP) blocks credential-stuffing attacks. fail2ban (installed in Step 1) watches Nginx access logs for 4xx floods.
    • Harden MongoDB further — Enable TLS on port 27017 even for localhost, turn on audit logging, and set up a replica set on a second VPS for automatic failover. See mongodb.com/docs/manual/security for the full hardening checklist.
    • Explore Angular signals and resource APIs — Angular 17+ ships signals for fine-grained reactivity, replacing many RxJS patterns. The new resource() and rxResource() APIs make async data in components dramatically simpler. See angular.dev for the latest patterns.

    Skip the Setup — Launch a Production-Ready VPS in 60 Seconds
    >
    Our CloudCore Professional plan is ideal for a full MEAN stack on a single server:
    >
    - 6 vCPU / 12 GB RAM / 100 GB NVMe
    - Ubuntu 24.04 LTS pre-installed
    - SSH key auth, root access, full control
    - Unmetered bandwidth
    - Deploy Mongo, Node, Express, Angular, and Nginx in one afternoon
    >
    Launch CloudCore Professional and get your MEAN stack live today.

    Was this article helpful?

    ← Back to Install GuidesBrowse all categories →

    Still have questions?

    Contact Support →Submit a Ticket