Skip to content
savemores

VPS & deployment

Host a MERN App for the Price of One Small VPS (2026)

Host a MERN stack app cheaply in 2026: compare Atlas M0, Mongo in Docker and PaaS costs, then lock down MongoDB, run Express under PM2 and back it up nightly.

By · 20+ years in tech

Published · 11 min read

The short answer

  • 01The cheapest MERN setup with room to grow is one VPS running the React build, the Express API and MongoDB in Docker, so the bill stays flat as you add projects.
  • 02MongoDB Atlas M0 is free but caps at 512 MB of storage and around 100 operations per second, which suits a portfolio or MVP, not a real product.
  • 03Publish MongoDB's port as 127.0.0.1:27017:27017; Docker writes its own iptables rules and a plain -p 27017:27017 can be reachable from the internet even with UFW enabled.
  • 04Serve React and proxy /api/ from one Nginx server block to remove CORS issues, and use try_files $uri $uri/ /index.html so client-side routes survive a refresh.
  • 05Self-hosting Mongo means owning backups: a nightly mongodump, two weeks of retention, and at least one test restore.

Stop thinking of it as one app

MERN is three deployables wearing one name. The React side compiles to static files. Express is a process that must stay alive. MongoDB is a database that needs persistent disk. Once you price them separately, the decisions get easy.

Static files cost almost nothing anywhere. The API needs a host that keeps a process running. The database is where both the money and the risk sit, and it is the part people most often get wrong.

Three realistic setups, priced

Figures checked in September 2026; they will move. The trade-offs column is the part that ages well.

SetupApprox. monthly costWhat you trade
One VPS + MongoDB Atlas M0The VPS only; the database is freeM0 is free with no end date, but stops at 512 MB of storage and roughly 100 ops/sec. Fine for an MVP or portfolio; it will not carry a product.
One VPS running everything, Mongo in DockerThe VPS onlyNo database ceiling and no second invoice. Backups, upgrades and security are all on you. Cheapest path that still leaves room to grow.
Managed PaaS + Atlas Flex~$7–37 for the app, up to $30 for the databaseLeast operational work. Highest bill, and the least predictable, because usage-based pricing can surprise you.

My pick: everything on one 4 GB box

If I were starting a side project today, I would put all three pieces on a single entry-tier VPS with 4 GB of RAM, such as KVM 1. Express, the React build and MongoDB run together without strain, and the next project you add costs nothing.

Install Node 24 LTS (the Active LTS line as of September 2026) and Docker. MongoDB goes in a container so you are not chasing apt repository changes every time Mongo ships a major version. If you have not hardened the server yet, do the security checklist first.

Fresh Ubuntu 24.04 or 26.04, as a sudo user:
bash
# Node 24 LTS
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt-get install -y nodejs nginx git

# Docker, for MongoDB
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
# log out and back in so the group takes effect

MongoDB, kept off the public internet

This is the step that matters most. Exposed, unauthenticated MongoDB instances are behind a long list of data breaches, and the Docker command in most tutorials publishes the port to everyone.

Two settings fix it. Publish the port on 127.0.0.1 so only local processes can connect, and set root credentials on first start. Be aware that Docker manages its own iptables rules and can bypass UFW entirely: a bare -p 27017:27017 is reachable from outside even when you believe the firewall is shut.

The 127.0.0.1 prefix on the port mapping is the whole security story:
bash
docker volume create mongo-data

docker run -d \
  --name mongo \
  --restart unless-stopped \
  -p 127.0.0.1:27017:27017 \
  -v mongo-data:/data/db \
  -e MONGO_INITDB_ROOT_USERNAME=appuser \
  -e MONGO_INITDB_ROOT_PASSWORD='use-a-long-random-password' \
  mongo:8

Express under PM2, listening on loopback

Your connection string stays on loopback too: mongodb://appuser:password@127.0.0.1:27017/myapp?authSource=admin. Keep it in an env file that never goes into Git.

PM2 restarts the API on a crash and after a reboot. Bind Express to 127.0.0.1 as well; Nginx should be the only thing that can reach it.

bash
sudo npm install -g pm2

cd /var/www/api
npm ci --omit=dev

pm2 start server.js --name api
pm2 save
pm2 startup systemd
# run the sudo command PM2 prints, then 'pm2 save' again
server.js: listen on loopback only
js
const PORT = process.env.PORT || 5000;

app.listen(PORT, "127.0.0.1", () => {
  console.log("API listening on 127.0.0.1:" + PORT);
});

One domain for the React build and the API

Serve the front end and the API from the same origin and CORS stops being a problem, because the browser only sees one domain. Nginx serves the build from disk and forwards anything under /api/ to Express.

The try_files line is what keeps client-side routing alive. Without it, refreshing /dashboard gives a 404, because no such file exists on disk. Point the domain at the server first (DNS guide) so Certbot can issue the certificate.

Build the client where Nginx expects it:
bash
cd /var/www/client
npm ci
npm run build
# Vite outputs to dist/, Create React App to build/
/etc/nginx/sites-available/mern
nginx
server {
    listen 80;
    server_name example.com;

    root /var/www/client/dist;
    index index.html;

    location /api/ {
        proxy_pass http://127.0.0.1:5000;
        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;
    }

    # Client-side routing: unknown paths fall back to index.html
    location / {
        try_files $uri $uri/ /index.html;
    }
}
Enable it and add HTTPS:
bash
sudo ln -s /etc/nginx/sites-available/mern /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx

sudo apt-get install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com

Nightly backups, because no one else is taking them

This is the price of running Mongo yourself, and it is small. A nightly mongodump into a dated folder, pruned after two weeks, covers the failures that actually happen: a bad migration, a dropped collection, a corrupted write.

Your host's weekly server backups protect against losing the whole machine. They do nothing for the collection you deleted on a Tuesday afternoon. You need both layers.

/home/deploy/backup-mongo.sh
bash
#!/usr/bin/env bash
set -euo pipefail

STAMP=$(date +%F)
OUT=/home/deploy/backups/$STAMP
mkdir -p "$OUT"

docker exec mongo mongodump \
  --username appuser \
  --password "$MONGO_PASSWORD" \
  --authenticationDatabase admin \
  --archive > "$OUT/myapp.archive"

# Keep two weeks
find /home/deploy/backups -maxdepth 1 -type d -mtime +14 -exec rm -rf {} +
Schedule it with crontab -e:
bash
0 3 * * * MONGO_PASSWORD='...' /home/deploy/backup-mongo.sh >> /home/deploy/backup.log 2>&1

Verify the stack, layer by layer

Run these after the first deploy and after any change to Docker or Nginx.

  • 502 on `/api/` routes: Express is down or not on port 5000. Check pm2 logs api.
  • Authentication failed from the API: the connection string is missing ?authSource=admin.
  • Slow requests on Atlas M0 with no errors: you are likely at the ~100 ops/sec ceiling and requests are queueing.
  1. 1Confirm MongoDB is bound to loopback only. 127.0.0.1:27017 is correct; 0.0.0.0:27017 means it is exposed.
  2. 2Confirm the API answers locally on port 5000 before blaming Nginx.
  3. 3Load a nested client route over HTTPS and refresh it. A 200 means try_files is doing its job.
  4. 4Test a restore from last night's archive into a scratch container.
bash
sudo ss -tlnp | grep 27017
# Want:  127.0.0.1:27017
# Bad:   0.0.0.0:27017  <- reachable from the internet

docker ps --filter name=mongo        # STATUS should be 'Up'
pm2 status                           # api should be 'online'
curl -I https://example.com/dashboard  # 200, not 404
Restore test into a disposable container:
bash
docker run -d --name mongo-restore-test \
  -e MONGO_INITDB_ROOT_USERNAME=appuser \
  -e MONGO_INITDB_ROOT_PASSWORD='test-password' \
  mongo:8

docker exec -i mongo-restore-test mongorestore \
  --username appuser --password 'test-password' \
  --authenticationDatabase admin \
  --archive < /home/deploy/backups/$(date +%F)/myapp.archive

docker rm -f mongo-restore-test

When the database should move to managed hosting

Self-hosted Mongo is the right call while the data fits comfortably on one disk and a restore-length outage is survivable. Switch when any of these become true. Notice that none of them is about size; they are all about consequences.

  • A few hours of downtime during a restore would genuinely hurt the business.
  • You need point-in-time recovery, not 'yesterday at 3 a.m.'.
  • You want replica-set failover, and building it yourself has stopped being an interesting afternoon.

The short version

For a side project or MVP, React, Express and Mongo on one VPS is the cheapest arrangement that does not trap you, and the price holds as you add projects. If you would rather not own a database yet, start on Atlas M0 and move Mongo onto the box when 512 MB runs out.

For the server, the links here apply coupon WEBHOST2026 automatically. The Hostinger VPS overview lists the plan tiers.

Questions people ask

What is the cheapest way to host a MERN app?

One VPS running the React build, the Express API and MongoDB in Docker. You pay for the server only, there is no database ceiling, and additional projects on the same box cost nothing extra.

Is MongoDB Atlas M0 good enough for production?

Not for a real product. It is free, but it is limited to 512 MB of storage and about 100 operations per second, which suits a portfolio or an MVP.

Does UFW protect a MongoDB container?

Not reliably. Docker writes its own iptables rules, so a port published as 27017:27017 can be reachable from the internet despite UFW. Publish it as 127.0.0.1:27017:27017 instead.

How do I stop 404 errors when refreshing a React route?

Add try_files $uri $uri/ /index.html to the Nginx location block so unknown paths fall back to index.html and the client-side router handles them.

Independent site, not Hostinger. Buy through my links and Hostinger pays me a referral fee; your price is the same or lower. How that works.