Tutorial

MERN on a Free Tier: One Container for Express and React, and Two Honest Choices for MongoDB

SnapDeploy Team 2026-11-23 Updated 2026-11-23 8 min read
mernmongodbexpressreactfree-tier

MERN, meaning MongoDB, Express, React and Node, is still the stack most bootcamps teach and most first portfolios ship, and it fits a free container tier well: one Node process serves the React build and the API, and MongoDB lives elsewhere. This guide gets a MERN app to a live URL with the two honest choices for the database, a Dockerfile that produces one small image for front end and API together, and the settings that stop a free container from looking broken.

One container or two?

One. Build the React app into static files and let Express serve them alongside /api. Same origin, so no CORS; one container, so one set of hours and one wake instead of two. The alternative, React on a Pages host and Express in the container, is right when the front end needs a CDN or the API is shared with a mobile app; it is covered in our Vercel and Netlify guide. For a portfolio or a small product, one container is simpler and cheaper.

// server/index.js
const path = require("path");
const express = require("express");
const mongoose = require("mongoose");

const app = express();
app.use(express.json());

app.get("/health", (req, res) => res.json({ ok: mongoose.connection.readyState === 1 }));
app.use("/api/items", require("./routes/items"));

app.use(express.static(path.join(__dirname, "../client/dist")));
app.get("*", (req, res) => res.sendFile(path.join(__dirname, "../client/dist/index.html")));

mongoose.connect(process.env.MONGODB_URI, { maxPoolSize: 5, serverSelectionTimeoutMS: 5000 })
  .then(() => app.listen(process.env.PORT || 3000, "0.0.0.0", () => console.log("up")))
  .catch((err) => { console.error("mongo connect failed:", err.message); process.exit(1); });
  • MONGODB_URI is the variable a SnapDeploy MongoDB add-on injects, and the name most tutorials use; Atlas gives you a string to paste under the same name.
  • maxPoolSize: 5: a MongoDB Mini allows 50 connections and Atlas's free cluster 500, but one small server needs a handful. Mongoose's default of 100 is a mistake waiting for a second container.
  • The catch-all route after express.static is what makes React Router refreshes work.
  • Exit with a non-zero code if the database is unreachable at start. The deployment then fails with the real reason in the log instead of a server that answers 500 to every request.

The database: two honest options

MongoDB Atlas M0 SnapDeploy MongoDB Mini
PriceFree, no expiry$29/month, or $1 for a 12-hour DB Sprint Pack
Size512 MB of storage on a shared clusterDedicated instance: 1 GB RAM, 5 GB storage, 50 connections
AdminAtlas web UI and Compassmongo-express in the browser, one click from the add-on
BackupsNot on the free clusterDaily
FitsPortfolios, learning projects, anything under 512 MB with light trafficA product with users, or data you would be sorry to lose

For a first deploy, Atlas M0 is the right call and we would say so even if we did not sell the other thing. Move to a Mini when you want backups, a dedicated instance, or the database in the same dashboard as the app. Either way the app only ever sees MONGODB_URI.

The Dockerfile: build React, run Express

FROM node:22-alpine AS client
WORKDIR /app/client
COPY client/package*.json ./
RUN npm ci
COPY client/ .
RUN npm run build

FROM node:22-alpine
WORKDIR /app
COPY server/package*.json ./server/
RUN cd server && npm ci --omit=dev
COPY server/ ./server/
COPY --from=client /app/client/dist ./client/dist
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "server/index.js"]

Two stages so the final image has the server's production dependencies and the built front end, not the React toolchain. Expect an image around 150 MB and a container that idles near 60 to 90 MB, comfortable in 512 MB. If you push without a Dockerfile, SnapDeploy generates a Node.js one from package.json, but it will not know to build the client first, so for a MERN repository keep this file.

Environment variables

  • MONGODB_URI: injected by the add-on, or pasted from Atlas (include the database name in the path).
  • JWT_SECRET or whatever your auth uses: a long random string, never the one in the repository.
  • PORT: set by the platform; the code above honours it.
  • Anything the React build needs (VITE_* or REACT_APP_*) is baked in during npm run build, so it must exist at build time and is visible to every browser. Never put a secret there.

Deploying

  1. Push the repository with the Dockerfile and a .dockerignore containing node_modules, client/node_modules, client/dist and .env.
  2. Containers → Deploy from GitHub → pick the repository; port 3000. Attach a MongoDB add-on or paste the Atlas string.
  3. Deploy. The first build takes a few minutes (two npm ci runs plus the React build). Open /health, then the site.
  4. If /health reports ok: false, the app is up but the database is not: check the URI, and on Atlas check that network access allows connections from anywhere (a container's address is not fixed).

Test the image locally first

docker build -t mern .
docker run --rm -p 3000:3000 -e MONGODB_URI="mongodb+srv://..." -e JWT_SECRET=dev mern
curl -s http://localhost:3000/health

A React build that fails on a missing environment variable, or a server that cannot reach the database, shows up here in seconds rather than after a platform build.

Where free stops

  • The container sleeps after 15 minutes without traffic and wakes in about a minute; a visitor to a sleeping portfolio sees a wake page, then the site. Always-On ($12/month) removes that and adds a custom domain.
  • 100 container-hours a month across four containers. A portfolio uses a fraction.
  • File uploads go to object storage, not the container's disk; the disk is reset on every deploy and wake.
  • Socket.io works while the container is awake, but socket traffic does not keep it awake; only HTTP requests do.

Seeding and schema changes

MongoDB has no migrations to run at start, which is one less thing to break, but a fresh database is empty. Keep a seed.js that inserts the reference data your app needs and run it once against the new MONGODB_URI from your machine; do not run it from the container's start command, or every wake reseeds. Schema changes are code changes in Mongoose models; for data reshaping, a one-off script run the same way is the honest tool.

Numbers used: MongoDB Mini $29/month and the $1 DB Sprint Pack from the add-on pricing page; Atlas M0 limits as published by MongoDB in September 2026; free tier from the free tier docs (4 containers, 512 MB, 0.25 vCPU, 100 container-hours a month, no card).

Ready to Deploy?

Deploy free. 10 deploys a day, 100 hours a month, no credit card.

Run this yourself: Get a dedicated NVIDIA T4 (16 GB) for a flat $499/mo or A10G (24 GB) for $999/mo — never shared, never spot, no hourly metering. See dedicated GPU hosting →

One-click Ollama, vLLM, ComfyUI, Whisper, PyTorch & more — or deploy your own GitHub repo or Docker image. Compare plans.

FIRST OF ITS KIND

Sprint Pack — ₹99 for 24 hours

The first managed container PaaS to offer a one-time 24-hour Always-On pass under $5. Unlimited deploys + Always-On for one Small (512 MB) container — including WebSockets & real-time apps. No subscription, no auto-renewal. Stack two for 48 hours.

Need a managed database too? DB Sprint Pack — ₹99 / 12h for Postgres, MySQL, MariaDB, Mongo, Redis, or RabbitMQ.

Tip: Need 24 hours of Always-On for a demo, weekend, or quick test — or WebSockets & real-time apps? Sprint Pack is ₹99 one-time — no subscription, no auto-renewal.

Need a managed Postgres / MySQL / Mongo / Redis instead? DB Sprint Pack — ₹99 / 12h.

Take SnapDeploy with you

Deploy, monitor and wake your containers from your iPhone.

Download on the App Store

Get DevOps Tips & Updates

Container deployment guides, platform updates, and DevOps best practices. No spam.

Unsubscribe anytime. We respect your privacy.

More Articles