Deploying a MERN app to AWS with a GitHub CI/CD pipeline
React on S3 and CloudFront, Express on EC2 behind Nginx and PM2, and a GitHub Actions workflow that ships both on every push to main.
Most MERN tutorials stop at npm start on localhost. Getting the same app onto real infrastructure, with HTTPS, a pipeline that deploys on every push, and no downtime while it does, is where the actual work is. This is the setup I keep coming back to for small and mid-sized projects: cheap, easy to reason about, and easy to grow out of later.
The architecture
- Frontend: the React build is static files, so it goes to an S3 bucket served through CloudFront. No server to patch, global caching, and HTTPS through ACM for free.
- API: Express runs on a single EC2 instance under PM2, with Nginx in front for TLS and as a reverse proxy.
- Database: MongoDB Atlas. Running Mongo yourself on EC2 is possible, but backups, upgrades and replica sets are a job of their own.
- Pipeline: one GitHub Actions workflow with two jobs, one per half of the app.
Preparing the API server
Start with an Ubuntu instance, install Node through nvm, then PM2 and Nginx. The security group only needs ports 22, 80 and 443 open. Port 5000, where Express listens, stays private because Nginx is the only thing talking to it.
PM2 keeps the process alive and, in cluster mode, lets you reload without dropping requests. An ecosystem file keeps the settings in the repo instead of in your shell history:
module.exports = {
apps: [
{
name: "api",
script: "src/index.js",
instances: 2,
exec_mode: "cluster",
env_production: { NODE_ENV: "production", PORT: 5000 },
},
],
};Nginx forwards everything to that port and passes along the headers Express needs to know the real client IP and protocol:
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Run sudo certbot --nginx -d api.example.com and Certbot rewrites that block for HTTPS and sets up renewal. On the Express side, add app.set("trust proxy", 1) so rate limiters and secure cookies see the client instead of Nginx.
Hosting the frontend on S3 + CloudFront
Keep the bucket private and let CloudFront read it through Origin Access Control. Set the default root object to index.html, and map 403 and 404 errors to /index.html with a 200 status so client-side routes survive a refresh.
Caching is the part people get wrong. Vite and CRA put content hashes in asset filenames, so those files can be cached for a year. index.html keeps the same name on every deploy, so it must never be cached, or visitors keep loading last week's bundle.
The GitHub Actions workflow
Rather than storing long-lived AWS keys as secrets, the workflow uses GitHub's OIDC provider to assume an IAM role that can only write to that one bucket and create invalidations. The API job connects to the instance over SSH and reloads PM2.
name: deploy
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: client
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: client/package-lock.json
- run: npm ci
- run: npm run build
env:
VITE_API_URL: https://api.example.com
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE }}
aws-region: ap-south-1
- name: Upload hashed assets (cached for a year)
run: aws s3 sync dist s3://${{ secrets.S3_BUCKET }} --delete --exclude index.html --cache-control "public,max-age=31536000,immutable"
- name: Upload index.html (never cached)
run: aws s3 cp dist/index.html s3://${{ secrets.S3_BUCKET }}/index.html --cache-control "no-cache"
- run: aws cloudfront create-invalidation --distribution-id ${{ secrets.CF_DISTRIBUTION_ID }} --paths "/index.html"
api:
runs-on: ubuntu-latest
steps:
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.EC2_HOST }}
username: ubuntu
key: ${{ secrets.EC2_SSH_KEY }}
script: |
cd ~/app/server
git pull --ff-only
npm ci --omit=dev
pm2 reload ecosystem.config.js --env productionWhat I'd tell myself the first time
- Add a
/healthroute that checks the database connection, and call it at the end of the API job so a broken deploy fails the workflow instead of failing silently. - Keep production secrets in a
.envon the instance (or SSM Parameter Store), never in the repo or the build logs. - Run
pm2 startupandpm2 saveonce, so the API comes back by itself after the instance reboots. - Put CloudWatch alarms on CPU and status checks. A t3.small is fine until the day it isn't, and you want to hear about it before your users do.
None of this is exotic, and that's the point. Every piece can be swapped later, containers instead of PM2 or a load balancer in front of more instances, without redoing the rest.