9 min read

My first MERN stack app: from two dev servers to one NGINX host

2020–2021: dual yarn servers locally, one NGINX origin in production, MongoDB IP allowlists, and presigned S3 + Stripe flows I still recognize in every full-stack codebase.

  • MERN
  • Node.js
  • React
  • AWS
  • MongoDB
  • NGINX
  • Stripe

My first MERN stack app: from two dev servers to one NGINX host

#TL;DR

My first production MERN stack was a remote internship (2020–2021) on a digital memorial platform: families register, upload photos and video, and run registry fundraising. Stack:

LayerTechnology
ClientCreate React App, Yarn, port 3000
APIExpress, port 5000, server.compiled.js in prod
DataMongoDB Atlas (per-developer IP allowlist)
FilesAWS S3 presigned putObject / getObject
PaymentsStripe PaymentIntents + Connect accounts for memorial owners
ProductionEC2 (8 GB, us-east-1) + NGINX + PM2

Locally you ran two terminals. Production collapsed both behind one origin so cookies, relative URLs, and TLS termination behaved like a real product.


#Development topology

Text
┌─────────────────────┐     ┌─────────────────────┐
│  yarn start         │     │  yarn dev           │
│  client/ :3000      │     │  root   :5000       │
│  (CRA dev server)   │     │  (Express API)      │
└──────────┬──────────┘     └──────────┬──────────┘
           │                           │
           └───────────┬───────────────┘

              Browser hits two origins
              (CORS + JWT in headers)

The README’s onboarding was explicit: clone, yarn install at root and in client/, whitelist your IP in MongoDB Atlas and the EC2 security group, then run both servers. That was my first experience of “full stack” meaning two processes you are responsible for, not one magical npm run dev.


#Backend layout

Domain folders, not a single routes.js dump:

ModuleResponsibility
_helpers/jwt.jsBearer token verification on protected routes
users/Accounts and profile data
memorials/Digital memory records (core product noun)
payment/Stripe charges, contributions, payouts
aws/S3 presign + copy/delete helpers
email/Outbound notification plumbing

Secrets stayed out of git: config.json, payment/stripeConfig.js, SSH keys. Onboarding meant “get credentials from the team,” not “clone and go.”

#What I owned end-to-end

AreaScope
StripeRegistry contributions, checkout flows, Connect onboarding for memorial members tied to the platform org account
MemorialsCore product routes and templates (digital memory CRUD, public lookup links)
S3 mediaPresigned upload/download, client PUT pipeline, canonical-resource signing bugs
NGINX + EC2Reverse-proxy config, TLS, domain cutovers, git pull + PM2 deploy runbook

#JWT on the wire

Protected routes used express-jwt with HS256 and a server secret from config.json. Public paths (register, authenticate, presigned S3, some template and payment entrypoints) were listed in .unless({ path: [...] }) so unauthenticated flows still worked.

The mental model I internalized — same one jwt.io’s introduction uses:

PartRole
HeaderAlgorithm (HS256) and token type
PayloadClaims — e.g. sub for user id
SignatureProves the token was issued by our API

After decode, an isRevoked hook re-loaded the user from MongoDB; if the account was gone, the token was treated as invalid even if the signature verified. The React client stored the token and sent Authorization: Bearer <token> on protected axios calls — same pattern I still use on other stacks, just with different middleware names.

#Public routes on the JWT gate

Everything not in .unless({ path }) required a valid Bearer token. These paths stayed public so onboarding, media, and payments could work without a session first:

Path patternWhy public
/users/authenticate, /users/registerLogin + signup
/aws/signS3_get, /aws/signS3_uploadPresign before/without session on some flows
/payment/contribute, /payment/tokenRegistry checkout entrypoints
/memory/search, /memory/lookup, /memory/registry*Public memorial lookup
/templates/*Template CRUD used during memorial setup

isRevoked called userService.getById(payload.sub) — if MongoDB had no user for sub, the token was revoked even when the HMAC was valid. That is a simple account-deleted story without maintaining a token blocklist.


#NGINX and production routing

Production did not expose ports 3000 and 5000 to the internet. On the EC2 box, NGINX terminated TLS and proxied all traffic to the compiled Express process on localhost:5000 — including the React production bundle (Express served client/build in production, not a separate static-only location).

Config lived on the instance under /etc/nginx/sites-available/ (site-specific filename on the box). The production pattern — HTTP→HTTPS, then proxy everything to Node:

nginx
server {
    listen 80;
    listen [::]:80;
    server_name www.example.com example.com;
    return 301 https://$host$request_uri;
}
 
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name www.example.com example.com;
 
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
 
    location / {
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header Host $http_host;
        proxy_pass http://localhost:5000;
    }
}

We also maintained legacy hostname redirects (old marketing domain → canonical HTTPS) in separate server blocks — a real lesson in registrar A / * records, Elastic IP moves, and Certbot with the NGINX plugin for Let’s Encrypt.

LayerRole
NGINXTLS, HTTP→HTTPS, legacy domain redirects
PM2 + server.compiledNode listens on 5000; serves API + SPA
REMOTE_HOST in client/src/constants.jsMust match the public HTTPS origin after domain changes

Production bugs I debugged (worth naming because they teach):

  • axios.get returning index.html instead of JSON — SPA catch-all and HTTP verb mismatch between client GET and Express routes; fixing memorial routes to POST where appropriate stopped the frontend from “successfully” parsing HTML as data.
  • 502 after domain cutover — DNS at the registrar (* and apex A records to the Elastic IP) had to move before SSL hostnames matched.
  • NGINX down after deleting /var/log/nginx/*.log — without log files, nginx -t fails; recreate logs or rotate properly (NGINX logging).

References: NGINX reverse proxy, Medium — MERN on EC2 (the tutorial our team used).


#AWS EC2 deploy runbook

Documented flow from the project README:

  1. Merge to master, cd client && yarn build, commit the production bundle.
  2. SSH into the instance (ubuntu@… with .pem key).
  3. cd /var/www/...git pull.
  4. NODE_ENV=production pm2 restart server.compiled --update-env
StepWhy it sticks
Build locally/CI, commit client/buildEra before ubiquitous S3-only static hosting for CRA
PM2 on compiled serverProcess supervision without container orchestration
Security group IP allowlistSSH and sometimes admin ports locked to office/home IPs
Compiled server.compiled.jsBabel/transpile step separated dev server.js from prod artifact

This was real ops: not vercel deploy, but repeatable enough that a intern could ship after code review.


#S3: presigned uploads and private media

The aws service used AWS SDK v2 with keys from config.json, region us-east-1. Two patterns the React app called constantly:

EndpointPurpose
POST /aws/signS3_uploadPresigned PUT for user media (memorial assets, registry images)
POST /aws/signS3_getPresigned GET for displaying private bucket objects

Upload signing (from aws/aws.service.js):

JavaScript
const userInfo = await getUser.getById(req.body.id);
const s3Params = {
  Bucket: S3_BUCKET,
  Key: userInfo._id + "/" + fileLocation + "/" + fileName,
  Expires: 500,
  ContentType: getMimeType(fileType),
};
const signedRequest = s3.getSignedUrl("putObject", s3Params);

The signer loads the user from req.body.id (JWT sub on protected routes) so object keys stay under {mongoUserId}/… — two users cannot collide in the same folder even with identical filenames.

getMimeType normalizes extensions (jpeg/JPGimage/jpeg, movvideo/quicktime). A mismatch between signed Content-Type and the browser PUT header still yields 403 from S3 — the bug class I hit when the canonical resource string drifted.

Client flow I implemented (memory template uploads):

  1. User picks a file → handleUpload reads fileName / fileType.
  2. axios.post to /aws/signS3_upload with those fields.
  3. API returns { signedRequest, url }.
  4. Browser axios.put(signedRequest, file, { headers: { "Content-Type": filetype } }) — must match what was signed or S3 returns 403 (I once broke the canonical resource string in the signer; AWS REST authentication docs and this walkthrough were the fix).
  5. Objects stayed private; display used signS3_get presigned reads.

Object keys were per-user prefixes (userId/path/fileName). The browser uploaded directly to S3; Express never saw the bytes — only metadata. Without presigning, you would stream every image through EC2 (bandwidth, RAM, and credential exposure on the app server).

References: AWS presigned URLs, S3 PUT object.


#Stripe: PaymentIntents and registry money flow

payment.service.js used Stripe PaymentIntents (not a hand-rolled card form posting PANs to Express):

JavaScript
const customer = await stripe.stripeConfig.customers.create({
  email: email,
  payment_method: id,
});
const charge = await stripe.stripeConfig.paymentIntents.create({
  amount,
  currency: "usd",
  customer: customer.id,
  payment_method: id,
  confirm: true,
  receipt_email: email,
});

Separate paths for checkout (memorial purchase), contribute (registry), and withdraw (payout transfers). Memorial owners onboarded as Stripe Connect accounts linked to the platform organization account — so registry payouts and dashboards were per-owner, not a single merchant of record.

What I learned without naming it “PCI compliance” in 2020:

  • Card data stays in Stripe.js / Elements on the client.
  • Express sees payment method IDs, not raw card numbers.
  • Webhooks and receipts matter for disputes — we treated email receipts as product requirements.

References: Stripe PaymentIntents, Accept a payment.


#MongoDB and performance notes

MongoDB Atlas (2020-era cluster) required each developer IP in Network Access — friction that teaches you why VPC peering and private endpoints exist. The app ran on an 8 GB EC2 instance in us-east-1. On the API, bulk write operations trimmed latency on heavy paths (~15% in my notes). The product eventually supported 250+ registered users with registry Stripe flows — real money and real media, not a todo app. The company and its production domain are no longer live; this post stays technical, not a brand memorial.


#Lessons that stuck

  1. Draw the dev vs prod diagram first — two ports locally, one hostname in prod.
  2. Presigned URLs — API signs, browser uploads, S3 stores.
  3. Payment primitives — intents + customers, not “store card in MongoDB.”
  4. Deploy runbooks — SSH, git pull, PM2 restart; boring and reliable.
  5. Security groups and Atlas IP lists — production data has a perimeter, not just passwords.

#How this connects to later work

Later projectEcho
Team MERN exam capstone (2022)Same monorepo muscle memory — client + server terminals
Public API course dashboardExpress BFF discipline, better TypeScript, clearer README
Mobile events platformChose Firebase later partly after carrying Mongo + Express ops cost

#Closing thought

Two terminal tabs in development and one reverse proxy in production is still the whole mental model for self-hosted MERN. Containers and edge platforms change the packaging — not the fact that someone must serve static files, something must proxy /api, and secrets never belong in git.


TopicLink
NGINX reverse proxyNGINX — reverse proxy
PM2 process managementPM2 documentation
MERN on EC2 (tutorial era)Medium — MERN on AWS EC2
JWT introductionjwt.io introduction