MERN on a Free Tier: One Container for Express and React, and Two Honest Choices for MongoDB
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_URIis 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.staticis 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 | |
|---|---|---|
| Price | Free, no expiry | $29/month, or $1 for a 12-hour DB Sprint Pack |
| Size | 512 MB of storage on a shared cluster | Dedicated instance: 1 GB RAM, 5 GB storage, 50 connections |
| Admin | Atlas web UI and Compass | mongo-express in the browser, one click from the add-on |
| Backups | Not on the free cluster | Daily |
| Fits | Portfolios, learning projects, anything under 512 MB with light traffic | A 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_SECRETor 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_*orREACT_APP_*) is baked in duringnpm run build, so it must exist at build time and is visible to every browser. Never put a secret there.
Deploying
- Push the repository with the Dockerfile and a
.dockerignorecontainingnode_modules,client/node_modules,client/distand.env. - Containers → Deploy from GitHub → pick the repository; port 3000. Attach a MongoDB add-on or paste the Atlas string.
- Deploy. The first build takes a few minutes (two
npm ciruns plus the React build). Open/health, then the site. - If
/healthreportsok: 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/healthA 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).