Next note →
DevOps3 min read

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:

server/ecosystem.config.jsjs
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:

/etc/nginx/sites-available/apinginx
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.

.github/workflows/deploy.ymlyaml
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 production

What I'd tell myself the first time

  • Add a /health route 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 .env on the instance (or SSM Parameter Store), never in the repo or the build logs.
  • Run pm2 startup and pm2 save once, 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.

Next noteCleaning up a compromised WordPress site, step by step →