Most DevOps writing about the MERN stack is generic DevOps writing with the words MongoDB, Express, React and Node sprinkled on top. The parts that are genuinely specific to this stack are the parts that break in production, and they are the ones nobody covers.
A MERN application is one repository holding two very different programs. The React front end is a build step that produces static files. The Node API is a long running process that holds database connections and in-flight requests. They fail differently, they deploy differently, and treating them as one deployable is where most small teams lose their afternoons.
This is written for a developer or a small team with a MERN app that needs a deployment setup that works, not a lecture on breaking down silos. We build on React and Node and we run our own products, so this is a pipeline we operate rather than one we recommend from a distance. It assumes a team of one to five people. If you have a platform engineer, you already know most of it.
One repository, two things that deploy on different schedules
The default MERN pipeline does this: any push to main triggers one job that installs everything, tests everything, builds the React app, and restarts the Node process. Change a button colour and your API restarts. Every in-flight request is at risk for a CSS tweak.
The fix is path filtering. In GitHub Actions this is either the paths key on the workflow trigger or a filter step that reports which top level directories changed. Structure the repo so those directories are unambiguous:
/client React app, builds to static files
/server Express API, runs as a process
/shared types, validation schemas, constants
Then wire three deploy conditions. A change under /client deploys the front end only. A change under /server deploys the API only. A change under /shared deploys both, because shared code is compiled into each. Get the third case wrong and you will ship a front end that validates against a schema the API no longer uses.
Splitting the deploys creates a second problem you have to answer deliberately: for a short window, an old front end talks to a new API, or the reverse. The rule that keeps this safe is ordering by direction of change. When you are adding something, deploy the API first, so the endpoint exists before anything calls it. When you are removing something, deploy the front end first, so nothing calls the endpoint before it disappears. Renames are an add and a remove across two releases, never one.
The same discipline applies to the response shape. An API that adds a field is safe. An API that renames a field breaks every browser tab that has not reloaded, and users keep tabs open for days.
Config and secrets across a build-time front end and a runtime API
This is the single most common security mistake in MERN deployments, and it is worth stating plainly.
Anything you put in a React environment variable is public. Whether the prefix is REACT_APP_, VITE_ or NEXT_PUBLIC_, the value is substituted into the JavaScript bundle at build time and shipped to the browser. It does not matter that you called it REACT_APP_SECRET_KEY. Anyone can open devtools and read it. There is no build configuration that changes this, because the bundle has to contain the value in order to use it.
So the front end gets publishable keys only: a Stripe publishable key, a Sentry DSN, an analytics ID, the API base URL. Every real secret lives on the Node side, injected at runtime by the hosting platform, read from process.env when the process starts, and never committed. Your Mongo connection string, JWT signing key, Stripe secret key and third party API keys are server side or they are compromised.
The second trap follows from the first. Because React config is baked in at build time, you cannot build one artifact and promote it from staging to production, which is otherwise the correct pattern. You have two honest options: build once per environment and accept that the staging artifact is not the production one, or keep the bundle environment agnostic and fetch runtime config from a small /config endpoint on first load, which costs one request before the app renders.
For a small team, building per environment is usually right. Just trigger both builds from the same commit, so the only difference between staging and production is configuration.
MongoDB is the part of the pipeline most teams skip
Teams put real effort into CI for their JavaScript and then treat the database as something that just exists. MongoDB being schemaless makes this worse, because there is no failing migration to remind you that the data changed.
Migrations without a schema
You still need migrations. They are just data scripts rather than DDL. Keep them in the repo, numbered, each one idempotent, and record which have run in a migrations collection so a rerun is a no-op. Run them as an explicit pipeline step before the API deploy, not from application startup code, because startup migrations race each other the moment you run two instances.
Structural changes go through expand and contract, never a single release:
- Deploy code that writes both the old and the new field, and reads the old one.
- Backfill existing documents in a migration.
- Deploy code that reads the new field.
- Deploy code that stops writing the old field.
- Drop the old field in a later migration, once you are sure.
That is four or five deploys to rename a field. It is also the only version that is safe to roll back at any point.
Indexes on a live collection
Mongoose will happily create indexes for you at boot. In production, turn that off by setting autoIndex: false and create indexes in migrations instead. Otherwise every instance that starts tries to build the same index, and an unexpected index build on a large collection during a deploy is a self-inflicted incident.
On a replica set, build indexes as a rolling operation so the primary keeps serving, and check the query plan afterwards. An index that does not match the sort order of the query it was built for does nothing except take up memory.
Connection pooling with autoscaled or serverless Node
Mongoose opens a pool per process. That is fine on two fixed servers. It is a problem when your platform autoscales to twenty containers, or when each serverless invocation creates its own client, because your connection count is instances multiplied by pool size and every cluster tier has a cap you can hit.
Set maxPoolSize deliberately rather than accepting the default, cache the connection outside the handler in serverless environments so warm invocations reuse it, and put a hard ceiling on instance count so a traffic spike cannot exhaust the cluster.
Backups, and the restore you have never tested
Managed MongoDB gives you snapshots and point-in-time recovery, and most teams tick that box and move on. A backup you have never restored is not a backup, it is a belief.
Put a restore drill on the calendar, quarterly at minimum. Restore the latest snapshot into a scratch cluster, point a local copy of the API at it, log in, and load a page that reads real data. Write down how long it took, because that number is your actual recovery time and it is usually several times what people assume.
Node in production: health checks, shutdown and zero-downtime deploys
A rolling deploy that drops requests is not a zero-downtime deploy. Three pieces have to be in place.
Two health endpoints, not one. Liveness answers "is this process alive" and should do nothing but return 200, because if it checks the database, a database blip restarts every one of your containers at once. Readiness answers "should this instance receive traffic" and checks what the instance actually needs: a live Mongo connection, migrations applied, caches warm.
A real SIGTERM handler. On shutdown, immediately fail readiness so the load balancer stops sending new requests, call server.close() to stop accepting connections while letting in-flight ones finish, close the Mongo connection, then exit. Add a hard timeout of twenty or thirty seconds so a stuck request cannot block the deploy forever. Without this, every deploy kills whatever was mid-flight.
Keep-alive timeouts that agree with each other. If Node's keepAliveTimeout is shorter than your load balancer's idle timeout, the balancer will occasionally send a request down a socket Node has just closed, and you get intermittent 502s that look like nothing. Set the Node value above the balancer value, not below.
One more thing: if you deploy in containers, do not run pm2 inside the container. One process per container, and let the orchestrator handle restarts. Two supervisors disagreeing about whether something is dead is a bad afternoon.
What runs at which stage, and how long it should take
The pipeline has one job that matters more than correctness: staying fast enough that nobody works around it. Once a pull request check takes fifteen minutes, people start batching changes, and large batches are exactly what a pipeline exists to prevent.
| Stage | What runs | Target time |
|---|---|---|
| Pre-commit hook | ESLint and Prettier on changed files only | Under 10 seconds |
| Pull request | Install from cache, TypeScript check, unit tests, build both apps | Under 4 minutes |
| Merge to main | Integration tests against a real MongoDB container, migration dry run | 3 to 6 minutes |
| Deploy to staging | Migrations, API deploy, front end build and upload, smoke test | 2 to 4 minutes |
| Deploy to production | Migrations, API first or front end first by change direction, smoke test | 3 to 5 minutes |
| Nightly | End to end suite, dependency audit, licence check | 10 to 30 minutes |
| Quarterly | Backup restore drill into a scratch cluster | Whatever it takes, measured |
Three things keep the pull request stage under four minutes. Cache node_modules keyed on the lockfile hash so a normal branch never reinstalls. Run the front end and back end test jobs in parallel rather than as one sequential script. And keep browser based end to end tests out of the pull request path entirely, because they are slow and flaky, and a flaky required check is worse than no check.
Integration tests deserve a real MongoDB, not a mock. A container in the CI job or an in-memory Mongo server both work, and both catch the bug where your query is valid JavaScript and wrong Mongo.
Observability a two-person team will actually maintain
The default advice is Prometheus, Grafana and an ELK stack: three systems to operate before you have learned anything about your application. Start with three much smaller things.
Structured logs. Use a JSON logger such as pino rather than console.log, attach a request ID to every line, and pass that ID through to the front end so a user can quote it in a support message. Log query shapes and status codes, never tokens, never full request bodies containing personal data.
Error tracking on both halves. One tool watching React and Node together, with releases tagged and source maps uploaded, so a production stack trace points at a line of your code instead of a minified blob. Without source maps the tool is decorative.
A very small number of alerts. Four or five, each of which means somebody does something now: error rate above threshold for five minutes, readiness failing on a majority of instances, database connections near the cap, deploy failed, event loop lag sustained above a threshold. That last one is the Node specific signal most teams miss, and it catches synchronous work blocking the loop before users report slowness.
Everything else belongs on a dashboard you look at deliberately, not in a channel that pages you. An alert nobody acts on trains the whole team to ignore alerts, including the one that mattered.
What a small team should not build
Being clear about the things to skip is more useful than another tool list.
Kubernetes, before you need it. A cluster brings its own upgrades, networking, RBAC, ingress controllers, certificate rotation and catalogue of outages. Adopting it for one React app and one Node API means taking all of that on in exchange for capabilities you are not using. A managed container platform will run a MERN application well past the point where it is making money. Revisit when someone's job description includes the cluster.
Microservices, before you have teams. The useful rule is roughly one service per team, so with one team you have one service. MERN teams that split early tend to split by layer rather than by domain, and end up with several services sharing one MongoDB: a distributed system with none of the benefits and all of the transaction problems. Module boundaries inside a single codebase give you the same separation, enforced by the compiler and reversible in an afternoon.
A bespoke pipeline where a managed platform would do. Custom shell scripts orchestrating deploys become a system only one person understands, and that person eventually leaves. A hosted CI service plus a platform deploy hook is usually under a hundred lines of YAML, with failure modes documented by someone else.
Infrastructure as code, before there is infrastructure. Terraform earns its keep with many resources or several environments that must match exactly. For one app, one database and one CDN it is a second source of truth that drifts. Add it when the manual steps become a checklist you are afraid of.
The position underneath all four is the one we take on client work: a well-structured monolith with a proper pipeline carries a product a very long way. Architecture that anticipates scale you do not have is cost paid early, and it is a common reason a web application build runs over without producing anything the user can see.
None of this is free either. The planning number we publish from operating our own dental practice platform is 15 to 20 percent of the original build cost per year to keep it current, before new features. A pipeline lowers the risk of that work rather than removing it. The web app cost guide covers where the rest of the budget goes.
If you have none of this, build it in this order
Each step is useful on its own, so you can stop at any point and still be better off than when you started.
- One command that runs the whole test suite locally. Everything else is automation of this. If it does not exist, nothing downstream is trustworthy.
- Pull request checks. Lint, type check, unit tests, a build of both apps. Required for merge.
- Automated deploy to staging on merge, which removes the manual step where mistakes live.
- Migrations as a pipeline step, with the runs recorded in the database.
- Health checks and graceful shutdown. The point at which deploys stop being scary and can happen during the working day.
- Error tracking with source maps, on the React app and the API.
- Path filtering so the front end and API deploy independently.
- A restore drill. Last on the list, and the one that matters most on the worst day you will have.
Steps one through five are a few days of work for someone who has done it before. If nobody on the team has, that is worth bringing in an experienced engineer for, because learning it during your first bad deploy costs a great deal more than the setup does.
Running a MERN application without a deploy process you trust? A Scoping Sprint ($2,300, two weeks) ends with a pipeline and deployment plan made for your codebase, a prototype, and a fixed quote. Or just start a conversation.


