Your app runs on localhost. Deployment is the work of making three pieces — database, API, and client — run on the public internet, connected to each other, updated automatically, and safe. This lesson walks the standard MERN topology end to end, then adds the CI/CD pipeline that keeps it healthy.
The deployment map
┌──────────────────────────┐
Browser ─────▶│ Client (static files) │ Vercel / Netlify / Cloudflare Pages
│ React build output │ CDN, HTTPS, atomic deploys
└─────────────┬────────────┘
│ HTTPS (fetch to VITE_API_URL)
┌─────────────▼────────────┐
│ API (Node process) │ Render / Railway / Fly.io
│ Express + Socket.io │ env vars, health checks, logs
└─────────────┬────────────┘
│ mongodb+srv://
┌─────────────▼────────────┐
│ Database │ MongoDB Atlas
│ managed replica set │ backups, monitoring, network rules
└──────────────────────────┘Each piece deploys to the kind of host it needs:
- The client is static files — always deploy to a CDN host for free TLS and global edge caching.
- The API is a long-running Node process — needs a host that keeps it alive (and can hold WebSocket connections).
- The database should never run on your own server — use a managed service.
Match the host to the workload. Static build output belongs on a CDN; stateful Node processes belong on a platform that runs them continuously; a database belongs in managed hands where backups and failover are someone else's job.
MongoDB Atlas
- Create a free cluster at MongoDB Atlas.
- Create a database user (separate from your Atlas login) with a strong password — your app uses this, stored in the API's environment variables.
- Configure Network Access: allow the server host's IPs (or
0.0.0.0/0for hosts with dynamic IPs, accepting the trade-off while your auth is strong). - Copy the connection string:
mongodb+srv://app-user:<password>@cluster0.xxxxx.mongodb.net/<dbName>?retryWrites=true&w=majority.
Two production notes: URL-encode special characters in the password, and never point the same database at both development and production. Create a second database (app-dev, app-prod) — otherwise one bad deleteMany() in a debugging session wipes your live data.
# server/.env (never committed)
MONGODB_URI=mongodb+srv://app-user:password@cluster0.xxxxx.mongodb.net/app-prod?retryWrites=true&w=majorityDeploying the API to Render or Railway
Both platforms work identically in spirit: connect the repository, set a start command, inject environment variables.
Build command: npm ci --workspace server
Start command: node server/src/server.jsWhat to set:
- Root directory: repository root (so the workspace install works) or
server/if you deploy the API as a standalone repo. - Environment variables: everything from
.env, pasted into the platform's dashboard —NODE_ENV=production,MONGODB_URI,JWT_SECRET,CLIENT_URL(the deployed client URL, not localhost), plus service secrets like Cloudinary keys. - Health check path: add a trivial endpoint and point the platform at it:
// server/src/app.js (add near the top of the routes)
app.get('/api/v1/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() })
})The platform pings it and restarts unresponsive instances. It also gives you an uptime monitor URL.
Two platform realities to expect:
- Free tiers sleep. Render's free web services spin down after idle time; the next request takes ~30 seconds. Acceptable for portfolios; note it in your README so reviewers do not think the site is broken.
- Deploys are immutable. Configuration lives in the dashboard plus your repo; never edit files on the server by hand.
After the first deploy, verify from anywhere in the world:
curl -s https://your-api.onrender.com/api/v1/healthDeploying the client to Vercel or Netlify
The build output is a folder of static assets; the platforms handle CDN, TLS, and preview deployments (every pull request gets its own URL — a workflow worth learning to love).
Build command: npm run build --workspace client
Output directory: client/dist
Environment variable: VITE_API_URL=https://your-api.onrender.comVITE_API_URL is baked into the bundle at build time — that is why it uses the VITE_ prefix and why it must never hold a secret. Change the API URL, redeploy the client, and the new URL ships.
One required detail: SPA rewrites
React Router manages URLs in the browser — /projects/123 exists only client-side. When someone reloads that URL, the CDN host looks for that path as a file and returns 404. Tell the host to serve index.html for every path:
// client/vercel.json
{
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}# client/public/_redirects (Netlify)
/* /index.html 200Verify by deep-linking: open https://your-app.vercel.app/dashboard in a fresh tab after deploying — not by clicking through from the home page. The fresh load is what breaks without a rewrite.
CORS in production
Your dev proxy no longer exists, so the API and client are genuinely cross-origin. Set the deployed client URL (and preview URLs) in the API's CLIENT_URL and restart it:
// server/src/app.js
app.use(cors({ origin: env.CLIENT_URL, credentials: true }))If that env var still says http://localhost:5173, every browser request fails while curl keeps working — remember that the failing calls are server-configuration problems, and look at the API logs first.
CI with GitHub Actions
Deployment platforms can auto-deploy on push, but the pipeline that stops broken code from reaching them is yours: run lint and tests on every pull request.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [master]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
services:
mongo:
image: mongo:7
ports: ['27017:27017']
options: >-
--health-cmd "mongosh --eval 'db.runCommand({ping:1})'"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
NODE_ENV: test
MONGODB_URI: mongodb://localhost:27017/app-test
JWT_SECRET: ci-only-secret-not-used-in-production-1234
CLIENT_URL: http://localhost:5173
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run lint --workspaces --if-present
- run: npm test --workspaces --if-present
- run: npm run build --workspace clientWhat each piece buys you:
npm ciinstalls exactly the lockfile — reproducible builds.- The Mongo service container gives integration tests a real database on the runner; your
MONGODB_URIin CI points at it. - Tests with Vitest + Supertest run the same way locally and in CI — this is why the testing lesson's setup matters now.
- The client build catches import and type errors before deploy.
on: pull_requestmakes CI a required check;on: pushto master guards the deploy branch.
Add a deploy badge to your README:
Docker: portable environments
If Render or Railway deploys feel like magic, Docker makes the environment explicit. A container packages your app with the exact Node version and dependencies it needs — the same artifact runs on any host that speaks Docker.
# server/Dockerfile
FROM node:20-alpine
WORKDIR /app
# Install dependencies first — this layer caches across code changes
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
ENV NODE_ENV=production
EXPOSE 3000
USER node
CMD ["node", "src/server.js"]# server/.dockerignore
node_modules
.env
tests
*.mddocker build -t mern-api ./server
docker run --env-file server/.env -p 3000:3000 mern-api
# compose the whole stack for local parity
docker compose up -d mongoWhy containers matter even when the platform builds from source: dependency drift between your machine, CI, and production disappears (one image, everywhere), and you can run the whole stack — API plus a local Mongo — with one command. The USER node line matters too: running as root inside the container is an avoidable risk. The playground for this lesson walks through writing and running this Dockerfile.
The pre-launch checklist
Run these once before calling it deployed:
-
NODE_ENV=production,helmet(), and rate limiting enabled on the API - Every secret lives in platform env vars;
git logshows no committed.env - HTTPS on both domains (both hosts do this automatically)
- Clientside deep links reload correctly (SPA rewrite verified)
- Health endpoint returns
ok; the host is watching it -
CLIENT_URLand CORS match the deployed client; a real browser login works - A seeded demo account exists, or registration works end-to-end
- Error monitoring attached (Sentry free tier) so production errors email you
- Atlas backups enabled; dev and prod use different databases
- README has the live URLs, demo credentials, and a screenshot
Try it
Create .github/workflows/ci.yml in a repo with a Node project, push, and watch the Actions tab run. Copy the YAML above, trimming the workspace flags for a single-package repo.
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm testCheck your understanding
Your deployed app's deep link /projects/42 returns 404 on reload, but clicking through from the home page works. What is missing?
Why can VITE_API_URL be safely visible in the client bundle while JWT_SECRET cannot?
Deploy your movies app to production
Take the full stack app you have built through this curriculum and put it on the public internet with an automated pipeline.
- Database: create an Atlas cluster, an app database user, and a separate
app-proddatabase; connect from your local machine first with the sharedMONGODB_URIto confirm it works. - API: deploy
server/to Render or Railway with production env vars, add the/api/v1/healthendpoint, and confirmcurl https://<api>/api/v1/healthreturns ok from outside your network. - Client: deploy
client/to Vercel or Netlify withVITE_API_URLpointing at the API; add the SPA rewrite; verify a hard reload of a nested route works. - CORS: set
CLIENT_URLto the deployed client URL; verify login and a protected mutation work in a browser (not curl). - CI: add the GitHub Actions workflow with a Mongo service container; make tests run on pull requests; require the check before merging on master.
- CD: connect the platform's auto-deploy to master (or wire a deploy hook that fires only after CI passes). Push a trivial change and watch it ship without touching a dashboard.
- Monitoring: attach Sentry (or equivalent) to the API; trigger a deliberate error in a staging environment and confirm it appears with a stack trace.
- Seed and document: seed a demo account with sample data; update the README with live URLs, demo credentials, a screenshot, and a short architecture diagram.
- Hostile test: open the site in a private window on a different network (phone hotspot) and complete the core user journey end to end. Fix whatever breaks.
Summary
- The standard MERN topology: static client on a CDN host, Node API on a long-running host, database managed in Atlas.
- Atlas: separate app user, network rules, and separate dev/prod databases; URL-encode credentials.
- APIs deploy with a build command, start command, env vars from the dashboard, and a health endpoint; expect free tiers to sleep.
- Clients deploy as static builds with
VITE_-prefixed public config — and an SPA rewrite so deep links survive reloads. - Production CORS means setting the real client origin; curl working proves nothing about the browser.
- GitHub Actions runs lint, tests (against a real Mongo service container), and the client build on every PR; deploy hooks extend it to full CD.
- Docker makes the runtime environment explicit and reproducible; run as a non-root user.
- Launch with the checklist: security headers, secrets, HTTPS, monitoring, backups, seeded data, and docs.
Next: the capstone — one more complete MERN application, from empty repository to deployed product.