Skip to content

Complete Guide to Deploying a MERN Stack Application with GitHub Actions CI/CD

Introduction

Deploying a MERN (MongoDB, Express, React, Node.js) stack application can be challenging, especially when you want to automate the entire process. This comprehensive guide will walk you through setting up a production-ready deployment pipeline using GitHub Actions, complete with SSL certificates, security configurations, and best practices.

By the end of this guide, you'll have a fully automated deployment system where pushing to your main branch automatically deploys your application to your server.

Table of Contents

Prerequisites

Before starting, ensure you have:

  • A Ubuntu/Debian server (20.04 LTS or later recommended)
  • A domain name pointed to your server's IP address
  • Root or sudo access to the server
  • A GitHub account with a repository containing your MERN application
  • Basic knowledge of Linux command line
  • Node.js application with separate frontend and backend directories

Architecture Overview

Our deployment architecture consists of:

  • Frontend: React application served by Nginx as static files
  • Backend: Node.js/Express API running as a PM2 process
  • Database: MongoDB running on the same server or external service
  • Reverse Proxy: Nginx routing requests to appropriate services
  • SSL: Let's Encrypt certificates for HTTPS
  • CI/CD: GitHub Actions for automated deployment
Internet → Nginx (443/80) → React Static Files (/)
                          → Node.js API (/api)
                          → MongoDB (localhost:27017)

Server Setup and Configuration

Initial Server Configuration

Connect to your server via SSH and update the system:

bash
ssh user@your-server-ip
sudo apt update && sudo apt upgrade -y

Install Node.js and npm

Install Node.js using NodeSource repository:

bash
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
node --version
npm --version

Install PM2 Process Manager

PM2 will keep your Node.js application running and automatically restart it if it crashes:

bash
sudo npm install -g pm2
pm2 startup systemd

Run the command that PM2 outputs to enable it to start on boot.

Install Nginx

bash
sudo apt install -y nginx
sudo systemctl start nginx
sudo systemctl enable nginx

Configure Firewall

bash
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

Create Application Directory

bash
sudo mkdir -p /var/www/your-app-name
sudo chown -R $USER:$USER /var/www/your-app-name

MongoDB Configuration

Option 1: Install MongoDB Locally

bash
wget -qO - https://www.mongodb.org/static/pgp/server-6.0.asc | sudo apt-key add -
echo "deb [ arch=amd64,arm64 ] https://repo.mongodb.org/apt/ubuntu focal/mongodb-org/6.0 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-6.0.list
sudo apt update
sudo apt install -y mongodb-org
sudo systemctl start mongod
sudo systemctl enable mongod

Secure MongoDB

bash
mongosh

Inside the MongoDB shell:

javascript
use admin
db.createUser({
  user: "adminUser",
  pwd: "StrongPasswordHere",
  roles: [ { role: "userAdminAnyDatabase", db: "admin" }, "readWriteAnyDatabase" ]
})

Exit the shell and enable authentication:

bash
sudo nano /etc/mongod.conf

Add or modify:

yaml
security:
  authorization: enabled

Restart MongoDB:

bash
sudo systemctl restart mongod

Option 2: Use MongoDB Atlas

If you prefer a managed solution, sign up for MongoDB Atlas and create a cluster. Save your connection string for later use.

Application Preparation

Project Structure

Your project should have this structure:

your-repo/
├── client/                 # React frontend
│   ├── src/
│   ├── public/
│   ├── package.json
│   └── vite.config.js (or other bundler config)
├── server/                 # Node.js backend
│   ├── src/
│   ├── package.json
│   └── server.js
├── .github/
│   └── workflows/
│       └── deploy.yml
└── README.md

Backend Configuration

Update your server/server.js to handle production settings:

javascript
const express = require('express');
const cors = require('cors');
const mongoose = require('mongoose');
require('dotenv').config();

const app = express();
const PORT = process.env.PORT || 5000;

// Middleware
app.use(cors({
  origin: process.env.CLIENT_URL || 'http://localhost:3000',
  credentials: true
}));
app.use(express.json());

// MongoDB Connection
mongoose.connect(process.env.MONGODB_URI, {
  useNewUrlParser: true,
  useUnifiedTopology: true,
})
.then(() => console.log('MongoDB connected'))
.catch(err => console.error('MongoDB connection error:', err));

// Routes
app.use('/api', require('./routes'));

// Health check endpoint
app.get('/health', (req, res) => {
  res.status(200).json({ status: 'OK', timestamp: new Date().toISOString() });
});

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Frontend Configuration

Create a .env.production file in your client directory:

env
VITE_API_URL=https://yourdomain.com/api

Update your API calls to use this environment variable:

javascript
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000/api';

export const fetchData = async () => {
  const response = await fetch(`${API_URL}/endpoint`);
  return response.json();
};

Create PM2 Ecosystem File

Create ecosystem.config.js in the server directory:

javascript
module.exports = {
  apps: [{
    name: 'your-app-backend',
    script: './server.js',
    instances: 'max',
    exec_mode: 'cluster',
    env: {
      NODE_ENV: 'production',
      PORT: 5000
    },
    error_file: '/var/www/your-app-name/logs/err.log',
    out_file: '/var/www/your-app-name/logs/out.log',
    log_file: '/var/www/your-app-name/logs/combined.log',
    time: true
  }]
};

Nginx Configuration

Create Nginx Server Block

bash
sudo nano /etc/nginx/sites-available/your-app-name

Add the following configuration:

nginx
server {
    listen 80;
    listen [::]:80;
    server_name yourdomain.com www.yourdomain.com;

    # Redirect HTTP to HTTPS
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name yourdomain.com www.yourdomain.com;

    # SSL certificates (will be configured by Certbot)
    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "no-referrer-when-downgrade" always;
    add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;

    # Root directory for frontend build
    root /var/www/your-app-name/client/dist;
    index index.html;

    # Gzip compression
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;

    # API proxy
    location /api {
        proxy_pass http://localhost:5000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        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;
        proxy_cache_bypass $http_upgrade;
        
        # Timeouts
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
    }

    # Frontend - serve React app
    location / {
        try_files $uri $uri/ /index.html;
        
        # Cache static assets
        location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
            expires 1y;
            add_header Cache-Control "public, immutable";
        }
    }

    # Error pages
    error_page 404 /index.html;
    error_page 500 502 503 504 /50x.html;
    location = /50x.html {
        root /usr/share/nginx/html;
    }
}

Enable the site:

bash
sudo ln -s /etc/nginx/sites-available/your-app-name /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

SSL Certificate Setup

Install Certbot

bash
sudo apt install -y certbot python3-certbot-nginx

Obtain SSL Certificate

First, temporarily configure Nginx without SSL. Edit your Nginx config to only include the HTTP (port 80) server block:

bash
sudo nano /etc/nginx/sites-available/your-app-name

Simplify to:

nginx
server {
    listen 80;
    listen [::]:80;
    server_name yourdomain.com www.yourdomain.com;

    root /var/www/your-app-name/client/dist;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }
}

Reload Nginx:

bash
sudo nginx -t
sudo systemctl reload nginx

Now obtain the certificate:

bash
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

Follow the prompts. Certbot will automatically modify your Nginx configuration to include SSL settings.

Auto-Renewal Setup

Certbot automatically creates a systemd timer for renewal. Verify it:

bash
sudo systemctl status certbot.timer
sudo certbot renew --dry-run

GitHub Actions CI/CD Pipeline

Setup SSH Access for GitHub Actions

On your server, create a dedicated deployment user (optional but recommended):

bash
sudo adduser github-deploy
sudo usermod -aG sudo github-deploy

Generate SSH key pair on your local machine:

bash
ssh-keygen -t ed25519 -C "github-actions" -f github-deploy-key

Copy the public key to your server:

bash
ssh-copy-id -i github-deploy-key.pub user@your-server-ip

Or manually add it:

bash
# On server
mkdir -p ~/.ssh
chmod 700 ~/.ssh
nano ~/.ssh/authorized_keys
# Paste the public key, save and exit
chmod 600 ~/.ssh/authorized_keys

Keep the private key (github-deploy-key) for GitHub Secrets.

Create GitHub Secrets

Go to your GitHub repository → Settings → Secrets and variables → Actions → New repository secret

Add these secrets:

  • SSH_PRIVATE_KEY: Content of your private key file
  • SERVER_HOST: Your server IP or domain
  • SERVER_USER: Your SSH user (e.g., github-deploy or your username)
  • MONGODB_URI: Your MongoDB connection string
  • SERVER_PATH: /var/www/your-app-name

Create GitHub Actions Workflow

Create .github/workflows/deploy.yml:

yaml
name: Deploy MERN Application

on:
  push:
    branches: [ main, production ]
  workflow_dispatch:

jobs:
  deploy:
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: |
            client/package-lock.json
            server/package-lock.json

      - name: Install frontend dependencies
        working-directory: ./client
        run: npm ci

      - name: Build frontend
        working-directory: ./client
        env:
          VITE_API_URL: https://yourdomain.com/api
        run: npm run build

      - name: Install backend dependencies
        working-directory: ./server
        run: npm ci --production

      - name: Setup SSH
        uses: webfactory/ssh-agent@v0.8.0
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

      - name: Add server to known hosts
        run: |
          mkdir -p ~/.ssh
          ssh-keyscan -H ${{ secrets.SERVER_HOST }} >> ~/.ssh/known_hosts

      - name: Create logs directory on server
        run: |
          ssh ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }} "mkdir -p ${{ secrets.SERVER_PATH }}/logs"

      - name: Deploy frontend
        run: |
          rsync -avz --delete \
            -e "ssh -o StrictHostKeyChecking=no" \
            ./client/dist/ \
            ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}:${{ secrets.SERVER_PATH }}/client/dist/

      - name: Deploy backend
        run: |
          rsync -avz --delete \
            --exclude 'node_modules' \
            --exclude '.env' \
            --exclude 'logs' \
            -e "ssh -o StrictHostKeyChecking=no" \
            ./server/ \
            ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}:${{ secrets.SERVER_PATH }}/server/

      - name: Install production dependencies on server
        run: |
          ssh ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }} << 'EOF'
            cd ${{ secrets.SERVER_PATH }}/server
            npm ci --production
          EOF

      - name: Create/Update .env file
        run: |
          ssh ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }} << 'EOF'
            cat > ${{ secrets.SERVER_PATH }}/server/.env << 'ENVEOF'
          NODE_ENV=production
          PORT=5000
          MONGODB_URI=${{ secrets.MONGODB_URI }}
          CLIENT_URL=https://yourdomain.com
          ENVEOF
          EOF

      - name: Restart backend with PM2
        run: |
          ssh ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }} << 'EOF'
            cd ${{ secrets.SERVER_PATH }}/server
            pm2 delete your-app-backend || true
            pm2 start ecosystem.config.js
            pm2 save
          EOF

      - name: Reload Nginx
        run: |
          ssh ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }} "sudo systemctl reload nginx"

      - name: Health check
        run: |
          sleep 5
          curl -f https://yourdomain.com/health || exit 1

      - name: Deployment summary
        if: success()
        run: |
          echo "✅ Deployment successful!"
          echo "🌐 Frontend: https://yourdomain.com"
          echo "🔌 Backend: https://yourdomain.com/api"
          echo "⏰ Deployed at: $(date)"

Allow Passwordless Sudo for Nginx Reload

On your server, configure sudo to allow nginx reload without password:

bash
sudo visudo

Add this line at the end:

github-deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload nginx

Replace github-deploy with your deployment user.

Environment Variables and Secrets

Server-Side Environment Variables

Create .env file on the server (this won't be in Git):

bash
nano /var/www/your-app-name/server/.env
env
NODE_ENV=production
PORT=5000
MONGODB_URI=mongodb://adminUser:password@localhost:27017/yourdb?authSource=admin
CLIENT_URL=https://yourdomain.com
JWT_SECRET=your-super-secret-jwt-key
SESSION_SECRET=your-session-secret

Important: Never commit .env files to Git. Add to .gitignore:

# .gitignore
.env
.env.local
.env.production
node_modules/
dist/
build/
logs/
*.log

Managing Secrets in GitHub

For additional secrets needed by your application:

bash
# Add to GitHub Secrets
JWT_SECRET
SESSION_SECRET
DATABASE_PASSWORD
API_KEYS

Update the workflow to pass these to the server during deployment.

Deployment Process

Manual First Deployment

Before relying on GitHub Actions, do a manual deployment to verify everything works:

bash
# On your server
cd /var/www/your-app-name

# Clone your repository
git clone https://github.com/yourusername/your-repo.git .

# Build frontend
cd client
npm install
npm run build

# Setup backend
cd ../server
npm install --production
pm2 start ecosystem.config.js
pm2 save

Automated Deployment

Once GitHub Actions is configured:

  1. Make changes to your code
  2. Commit and push to the main branch
  3. GitHub Actions automatically:
    • Builds the frontend
    • Installs backend dependencies
    • Deploys to your server
    • Restarts the backend
    • Reloads Nginx

Monitor the deployment in the Actions tab of your GitHub repository.

Monitoring and Logging

PM2 Monitoring

bash
# View logs
pm2 logs your-app-backend

# Monitor resources
pm2 monit

# View detailed info
pm2 info your-app-backend

# View all processes
pm2 list

Nginx Logs

bash
# Access logs
sudo tail -f /var/log/nginx/access.log

# Error logs
sudo tail -f /var/log/nginx/error.log

Application Logs

Your application logs are stored in /var/www/your-app-name/logs/:

bash
tail -f /var/www/your-app-name/logs/combined.log

Setup Log Rotation

Create log rotation configuration:

bash
sudo nano /etc/logrotate.d/your-app
/var/www/your-app-name/logs/*.log {
    daily
    rotate 14
    compress
    delaycompress
    notifempty
    create 0640 www-data www-data
    sharedscripts
}

Troubleshooting

Common Issues and Solutions

1. Deployment Fails - SSH Connection

Problem: GitHub Actions can't connect to server

Solution:

  • Verify SSH key is correctly added to GitHub Secrets
  • Ensure server allows SSH connections
  • Check firewall rules: sudo ufw status
  • Verify known_hosts step in workflow

2. Backend Not Starting

Problem: PM2 process crashes or won't start

Solution:

bash
# Check PM2 logs
pm2 logs your-app-backend

# Common issues:
# - Missing environment variables
# - MongoDB connection failure
# - Port already in use

# Check if port 5000 is available
sudo lsof -i :5000

# Restart with verbose output
cd /var/www/your-app-name/server
node server.js

3. 502 Bad Gateway

Problem: Nginx can't connect to backend

Solution:

  • Verify backend is running: pm2 list
  • Check backend port matches Nginx config
  • Review Nginx error logs: sudo tail -f /var/log/nginx/error.log
  • Test backend directly: curl http://localhost:5000/health

4. SSL Certificate Issues

Problem: HTTPS not working or certificate errors

Solution:

bash
# Verify certificate
sudo certbot certificates

# Renew manually
sudo certbot renew

# Check Nginx SSL config
sudo nginx -t

# Verify certificate files exist
ls -la /etc/letsencrypt/live/yourdomain.com/

5. MongoDB Connection Errors

Problem: Backend can't connect to MongoDB

Solution:

bash
# Check if MongoDB is running
sudo systemctl status mongod

# Test connection
mongosh "mongodb://adminUser:password@localhost:27017/yourdb?authSource=admin"

# Check MongoDB logs
sudo tail -f /var/log/mongodb/mongod.log

# Verify credentials in .env file
cat /var/www/your-app-name/server/.env

6. Frontend Not Loading

Problem: React app shows blank page or errors

Solution:

  • Check browser console for errors
  • Verify API URL in frontend build
  • Check Nginx serves correct directory: /var/www/your-app-name/client/dist
  • Rebuild frontend: npm run build
  • Verify Nginx try_files directive handles React routing

7. CORS Errors

Problem: Frontend can't communicate with backend

Solution: Update backend CORS configuration:

javascript
app.use(cors({
  origin: ['https://yourdomain.com', 'https://www.yourdomain.com'],
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization']
}));

Debugging Checklist

When deployment fails, check in this order:

  1. ✅ GitHub Actions log for error messages
  2. ✅ PM2 logs: pm2 logs
  3. ✅ Nginx error logs: sudo tail -f /var/log/nginx/error.log
  4. ✅ Application logs: tail -f /var/www/your-app-name/logs/combined.log
  5. ✅ MongoDB logs: sudo tail -f /var/log/mongodb/mongod.log
  6. ✅ System resources: htop or free -h
  7. ✅ Disk space: df -h
  8. ✅ Port availability: sudo lsof -i :5000

Best Practices

Security

  1. Keep Software Updated
bash
sudo apt update && sudo apt upgrade -y
  1. Use Strong Passwords
  • Generate strong passwords for MongoDB users
  • Use password managers
  1. Implement Rate Limiting

Install express-rate-limit:

javascript
const rateLimit = require('express-rate-limit');

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100 // limit each IP to 100 requests per windowMs
});

app.use('/api', limiter);
  1. Environment Variables
  • Never commit .env files
  • Rotate secrets regularly
  • Use different credentials for development and production
  1. Database Backups

Setup automated MongoDB backups:

bash
# Create backup script
nano ~/backup-mongo.sh
bash
#!/bin/bash
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/var/backups/mongodb"
mkdir -p $BACKUP_DIR

mongodump --uri="mongodb://adminUser:password@localhost:27017/yourdb?authSource=admin" --out="$BACKUP_DIR/backup_$DATE"

# Keep only last 7 days
find $BACKUP_DIR -type d -mtime +7 -exec rm -rf {} +
bash
chmod +x ~/backup-mongo.sh

# Add to crontab
crontab -e
# Add: 0 2 * * * /home/user/backup-mongo.sh

Performance

  1. Enable Caching
  • Use Redis for session storage
  • Implement API response caching
  • Configure proper cache headers
  1. Optimize Database Queries
  • Create proper indexes
  • Use aggregation pipelines
  • Limit query results
  1. Monitor Resources
bash
# Install monitoring tools
sudo apt install -y htop iotop nethogs

Development Workflow

  1. Branching Strategy
  • main - production deployments
  • develop - staging/testing
  • feature/* - feature development
  1. Testing Before Deployment

Add tests to your workflow:

yaml
- name: Run tests
  working-directory: ./server
  run: npm test

- name: Run frontend tests
  working-directory: ./client
  run: npm test
  1. Rollback Strategy

Create rollback workflow:

yaml
name: Rollback Deployment

on:
  workflow_dispatch:
    inputs:
      version:
        description: 'Commit hash to rollback to'
        required: true

jobs:
  rollback:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout specific commit
        uses: actions/checkout@v3
        with:
          ref: ${{ github.event.inputs.version }}
      
      # Rest of deployment steps...

Conclusion

You now have a fully automated MERN stack deployment pipeline with:

  • ✅ Automated CI/CD with GitHub Actions
  • ✅ SSL/HTTPS encryption
  • ✅ Production-ready Nginx configuration
  • ✅ Process management with PM2
  • ✅ Secure MongoDB setup
  • ✅ Comprehensive logging
  • ✅ Error handling and troubleshooting guides

Next Steps

  1. Setup Monitoring: Consider tools like Uptime Robot or New Relic
  2. Implement Analytics: Add Google Analytics or similar
  3. Setup Error Tracking: Use Sentry or similar services
  4. Configure CDN: Use Cloudflare for improved performance
  5. Implement CI Tests: Add unit and integration tests
  6. Setup Staging Environment: Create a separate staging server
  7. Document API: Use Swagger or similar for API documentation

Additional Resources


Need help? If you encounter issues not covered in this guide, check the troubleshooting section or review logs systematically. Most deployment issues can be resolved by carefully reading error messages and logs.

Happy deploying! 🚀

javascript book

If this interested you, check out my book Javascript Book

Enjoy every byte.