DEV Community

Cover image for Deploy a MERN App on AWS with Docker & Jenkins (Beginner Guide)
Dishan Sanjula
Dishan Sanjula

Posted on

Deploy a MERN App on AWS with Docker & Jenkins (Beginner Guide)

A step-by-step guide to deploying your first full-stack application with a real CI/CD pipeline, custom domain, and HTTPS,DevOps, Docker, AWS, Jenkins
By Dishan Sanjula ยทhttps://dswijesooriya.is-a.dev/

Introduction
A few weeks ago, I finished building my first full-stack MERN application. It ran perfectly on localhost:5173, and I was genuinely proud of it.Then I asked myself the question every beginner eventually asks:

"Now what?"
I could have deployed it to Vercel or Netlify and called it done. And honestly โ€” that's a perfectly valid choice. Those platforms are amazing, they're free, and they "just work."

But I wanted something different. I wanted to understand what actually happens under the hood. I wanted to know how production apps get deployed in real companies. I wanted to learn DevOps โ€” that mysterious word everyone throws around but nobody explains properly to beginners.

So I decided to do something uncomfortable: I'd deploy my app on AWS EC2 with Docker, build a Jenkins CI/CD pipeline, set up Nginx as a reverse proxy, register a custom domain, and enable HTTPS with Let's Encrypt.

Total time: About 3 weeks of evenings.
Total cost: $0 (everything on free tiers).
Total sanity lost: Some. Worth it.

This article is the story of that journey โ€” including all the mistakes, errors, and "why is this not working?!" moments. Because those are the parts tutorials skip.

Let's begin.

What I Deployed: A Brief Intro to the App
Before we dive into DevOps, let me quickly introduce the app

BloodLinker is a blood donation management system with three user roles:

  • ๐Ÿฉธ Donors โ€” Register, receive notifications about matching blood requests in their district, respond to requests, track donation history with a 90-day medical eligibility countdown.
  • ๐Ÿฅ Hospitals โ€” Register (pending admin approval), post blood requests, view donor responses, mark donations as completed.
  • ๐Ÿ›ก๏ธ Admins โ€” Approve hospital registrations, monitor platform activity, manage users.

The stack:

  • Frontend: React 18 + Vite + Tailwind CSS
  • Backend: Node.js + Express + JWT authentication
  • Database: MongoDB Atlas (cloud-hosted, free tier)
  • DevOps: Docker, Docker Compose, Nginx, Jenkins, Let's Encrypt
  • Cloud: AWS EC2 (Ubuntu 24.04, t3.micro)

Live demo: https://bloodlinker.is-a.dev

important - The DevOps principles are 100% universal. If you have any Node.js + React app (or even just a Node.js API), you can apply everything here.

The DevOps Stack โ€” Every Tool Explained for Beginners
Before we touch any configuration, let me explain what each tool does and why it matters. Bookmark this section

AWS EC2

Instead of hosting on your laptop (which turns off when you close the lid), you rent a computer that runs 24/7 in an Amazon data center somewhere in the world.

You pay monthly, live there with EC2 โ€” you rent compute power by the hour.

Cost: Free tier gives you 750 hours/month of t3.micro for 12 months. That's enough for one instance running 24/7 for free.

Docker

A tool that packages your app with everything it needs to run (code, runtime, dependencies, system libraries, config) into a single unit called a "container."

Why we use it: Solves the "it works on my machine" problem. The container runs the exact same way on your laptop, my server, or any cloud.

Analogy:

  • Without Docker: You give someone a recipe + raw ingredients + your oven temperature. They might get a different result.
  • With Docker: You give them a fully cooked meal in a sealed box. Everyone gets the same result.

Jenkins โ€” The CI/CD Server

What it is: An automation server that runs your deployment pipeline.

Why we use it: Every time you git push, Jenkins automatically:

  1. Pulls the new code
  2. Builds new Docker images
  3. Stops old containers
  4. Starts new containers
  5. Verifies the deployment worked

Nginx โ€” The Reverse Proxy

What it is: A web server that receives requests on ports 80/443 and routes them to the correct backend container based on the URL path.

Why we use it: Users shouldn't have to remember port numbers. https://bloodlinker.is-a.dev is way better than IP Address.

Let's Encrypt + Certbot โ€” Free SSL Certificates

What it is: Let's Encrypt is a non-profit certificate authority that gives out free SSL certificates. Certbot is a tool that automates getting and renewing them.

Why we use it: HTTPS is non-negotiable in 2026. Without it:

  • Browsers show "Not Secure" warnings
  • Google penalizes your SEO
  • Users don't trust the site

GitHub Webhooks โ€” The Automation Trigger

What it is: GitHub sends a small HTTP request to your server whenever you push code.

Why we use it: So Jenkins knows when to run the pipeline automatically. Without it, you'd have to click "Build Now" every time.

*Let's start the deployment part
*

Phase 1: Setting Up AWS EC2

The first thing we need is a server. A real one, running in the cloud, with a public IP address that anyone on the internet can reach.

Let's rent one.

Step 1.1: Launch the Instance

Sign in to the AWS Console (https://console.aws.amazon.com) and navigate to EC2.

Click "Launch instance" and fill in:

Field Value
Name your_app_name-server
AMI Ubuntu Server 24.04 LTS (Free tier eligible)
Instance type t3.micro (Free tier eligible)
Key pair Create new โ†’ app_name-key โ†’ .pem format
Storage 20 GB gp3

โš ๏ธ Important note about the key pair: When you click "Create key pair," a .pem file downloads. Move it to a safe folder immediately and never share it. You cannot download it again. If you lose it, you'll have to create a new key pair and reattach it โ€” which is a pain.

Step 1.2: Configure Security Groups

This is where most beginners get stuck. A security group is a firewall that decides what traffic is allowed in.

Create a new security group named your_project_name-sg with these inbound rules:

Type Port Source Purpose
SSH 22 My IP Remote access
HTTP 80 Anywhere (0.0.0.0/0) Web traffic
HTTPS 443 Anywhere (0.0.0.0/0) Secure web traffic
Custom TCP 8080 My IP Jenkins UI

Why "My IP" for SSH? Only your computer can access the server via SSH. This is a security best practice. Every time your home IP changes (which happens with most ISPs), you'll need to update this rule. Trust me โ€” you'll forget and get "Connection timed out" errors for 20 minutes before realizing.

Why "Anywhere" for HTTP/HTTPS? Because your users will access the site from anywhere. That's the point.

Step 1.3: Launch and Get Your Elastic IP

Click "Launch instance".

Wait 2 minutes, then allocate an Elastic IP:

  1. Left sidebar โ†’ Elastic IPs
  2. Click "Allocate Elastic IP address"
  3. Click "Allocate"
  4. Select the new IP โ†’ Actions โ†’ "Associate Elastic IP address"
  5. Attach to your bloodlinker-server instance

Why Elastic IP? EC2's default public IP changes every time you stop and start the instance. An Elastic IP is permanent (and free while attached to a running instance).

Save your Elastic IP somewhere safe. You'll use it dozens of times.

Step 1.4: SSH In for the First Time

Open PowerShell (Windows) or Terminal (Mac/Linux) and connect:

ssh -i "C:\Users\YourName\path\to\bloodlinker-key.pem" ubuntu@<YOUR_ELASTIC_IP>
Enter fullscreen mode Exit fullscreen mode

Common first-time errors:

  • "UNPROTECTED PRIVATE KEY FILE" โ€” Windows gives .pem files too many permissions. Fix:
  icacls "path\to\bloodlinker-key.pem" /inheritance:r
  icacls "path\to\bloodlinker-key.pem" /grant:r "$($env:USERNAME):(R)"
Enter fullscreen mode Exit fullscreen mode
  • "Connection timed out" โ€” Your security group doesn't allow SSH from your current IP. Update the rule to "My IP."
  • "Permission denied (publickey)" โ€” Wrong key path, or the key isn't attached to this instance.

If it works, you'll see:

Welcome to Ubuntu 24.04.x LTS ...

ubuntu@ip-172-31-xx-xxx:~$
Enter fullscreen mode Exit fullscreen mode

You're inside a real cloud server. ๐ŸŽ‰

Step 1.5: Basic Server Setup

Once connected, run:

# Update packages
sudo apt update && sudo apt upgrade -y

# Install essentials
sudo apt install -y curl git unzip wget nano ufw

# Set timezone (optional โ€” replace with yours)
sudo timedatectl set-timezone Asia/Colombo
Enter fullscreen mode Exit fullscreen mode

Add 2 GB of swap space (critical for t3.micro with only 1 GB RAM):

sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
Enter fullscreen mode Exit fullscreen mode

Why swap? Your server has 1 GB of RAM. Docker + Jenkins + your app will exceed that. Swap = using disk space as overflow RAM. Slower, but prevents crashes.

Verify:

free -h
Enter fullscreen mode Exit fullscreen mode

You should see Swap: 2.0Gi.

Phase 2: Dockerizing the App

Now for the fun part. We're going to package the app into containers.

Step 2.1: Create the Backend Dockerfile

In your project's backend/ folder, create a file called Dockerfile (no extension):

# ============================================================
# Backend Dockerfile โ€” Node.js + Express
# ============================================================

FROM node:20-alpine

# Set working directory
WORKDIR /app

# Copy package files first (better caching)
COPY package*.json ./

# Install production dependencies only
RUN npm install --omit=dev

# Copy the rest of the code
COPY . .

# Expose port
EXPOSE 5000

# Start the server
CMD ["npm", "start"]
Enter fullscreen mode Exit fullscreen mode

Why node:20-alpine? Alpine Linux is a tiny distro (~5 MB vs ~900 MB for full Node images). Smaller image = faster deploy.

Why copy package*.json separately? Docker caching. If only your source code changes (not dependencies), Docker skips the npm install step. This saves 30-60 seconds per build.

Step 2.2: Create the Frontend Dockerfile

For the frontend, we use a multi-stage build. This is a professional technique that keeps the final image tiny.

Create frontend/Dockerfile:

# ============================================================
# Frontend Dockerfile โ€” Multi-stage build
# ============================================================

# ---------- STAGE 1: BUILD ----------
FROM node:20-alpine AS builder

WORKDIR /app

# Install dependencies
COPY package*.json ./
RUN npm install

# Copy source code
COPY . .

# Accept the API URL as a build argument
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL

# Build the React app
RUN npm run build

# ---------- STAGE 2: SERVE ----------
FROM nginx:alpine

# Remove default Nginx welcome page
RUN rm -rf /usr/share/nginx/html/*

# Copy the built React app
COPY --from=builder /app/dist /usr/share/nginx/html

# Copy custom Nginx config for SPA routing
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]
Enter fullscreen mode Exit fullscreen mode

Why multi-stage?

  • Stage 1 has Node.js + build tools (~300 MB of temporary stuff)
  • Stage 2 only copies the built dist/ folder (~25 MB final image)

Result: A production image that's 10x smaller than a naive single-stage build.

โš ๏ธ The critical bit: VITE_API_URL is passed as a build argument. Vite bakes it into the JS bundle at build time. This means if the URL is wrong, you must rebuild โ€” restarting won't help. Remember this. It will save you hours later. (Spoiler: it doesn't save me hours later.)

Step 2.3: Create Frontend's Nginx Config

Create frontend/nginx.conf:

# ============================================================
# Nginx config INSIDE the frontend container
# ============================================================

server {
    listen 80;
    root /usr/share/nginx/html;
    index index.html;

    # SPA routing โ€” send everything to index.html
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Cache static assets
    location ~* \.(js|css|png|jpg|svg|woff2?)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    gzip on;
    gzip_types text/css application/javascript application/json;
}
Enter fullscreen mode Exit fullscreen mode

Why this matters: Without try_files $uri $uri/ /index.html, refreshing /dashboard gives you a 404 โ€” because there's no file at that path. This config sends all unknown routes to index.html, letting React Router handle navigation.

Step 2.4: Create .dockerignore Files

Never copy node_modules or .env into Docker images. Create backend/.dockerignore and frontend/.dockerignore:

node_modules
.env
.env.local
.git
.gitignore
*.md
.vscode
npm-debug.log
Enter fullscreen mode Exit fullscreen mode

Why: node_modules is huge and gets reinstalled inside the container. .env contains secrets that should never be baked into images.

Step 2.5: Create docker-compose.yml

This single file orchestrates both containers. Create it at the project root:

services:
  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: bloodlinker-backend
    restart: unless-stopped
    environment:
      - MONGO_URI=${MONGO_URI}
      - JWT_SECRET=${JWT_SECRET}
      - JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-7d}
      - NODE_ENV=production
      - PORT=5000
    ports:
      - "5000:5000"
    networks:
      - bloodlinker-network
    healthcheck:
      test: ["CMD", "wget", "--spider", "http://localhost:5000/api/health"]
      interval: 30s
      timeout: 5s
      retries: 3

  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
      args:
        VITE_API_URL: ${VITE_API_URL}
    container_name: bloodlinker-frontend
    restart: unless-stopped
    ports:
      - "3000:80"
    networks:
      - bloodlinker-network
    depends_on:
      - backend

networks:
  bloodlinker-network:
    driver: bridge
Enter fullscreen mode Exit fullscreen mode

Key concepts:

  • ${MONGO_URI} โ€” Read from .env at the project root (git-ignored)
  • restart: unless-stopped โ€” Containers auto-start on server reboot
  • healthcheck โ€” Docker monitors the backend's health
  • networks โ€” Containers can reach each other by name

Step 2.6: Create .env at Project Root

Create .env at the project root (and add it to .gitignore):

MONGO_URI=mongodb+srv://user:pass@cluster.mongodb.net/bloodlife?retryWrites=true&w=majority
JWT_SECRET=your_super_secret_key_here
JWT_EXPIRES_IN=7d
VITE_API_URL=https://bloodlinker.is-a.dev/api
NODE_ENV=production
Enter fullscreen mode Exit fullscreen mode

โš ๏ธ Add .env to .gitignore right now. If you accidentally commit this, your MongoDB password leaks to GitHub forever. Rotate the password if that happens.

Step 2.7: Test Locally

Before deploying to AWS, test locally:

docker compose up -d --build
Enter fullscreen mode Exit fullscreen mode

Then open http://localhost:3000 in your browser. If it loads, you're ready to move to the server.

Common first-time errors:

  • "Cannot find module" โ€” Your Dockerfile didn't copy a file. Check COPY . .
  • "MongoDB connection failed" โ€” Wrong MONGO_URI or MongoDB Atlas IP whitelist missing 0.0.0.0/0
  • "Port already in use" โ€” Something else is using port 5000 or 3000. Kill it: docker stop $(docker ps -aq)

Once it works locally, stop the containers:

docker compose down
Enter fullscreen mode Exit fullscreen mode

Phase 3: Installing Jenkins on the Server

Now let's install the automation server. SSH into your EC2 instance.

Step 3.1: Install Java

Jenkins requires Java 17 or higher:

sudo apt install -y openjdk-21-jre
Enter fullscreen mode Exit fullscreen mode

Verify:

java -version
Enter fullscreen mode Exit fullscreen mode

Step 3.2: Install Jenkins

Add the Jenkins repository:

# Add GPG key
sudo wget -O /etc/apt/keyrings/jenkins-keyring.asc \
  https://pkg.jenkins.io/debian-stable/jenkins.io-2026.key

# Add repository
echo "deb [signed-by=/etc/apt/keyrings/jenkins-keyring.asc]" \
  https://pkg.jenkins.io/debian-stable binary/ | sudo tee \
  /etc/apt/sources.list.d/jenkins.list > /dev/null

# Install
sudo apt update
sudo apt install -y jenkins
Enter fullscreen mode Exit fullscreen mode

โš ๏ธ Pro tip: If you see NO_PUBKEY errors, your GPG key is outdated. The correct URL ends in jenkins.io-2026.key, not 2023.key. This tripped me up for 30 minutes.

Step 3.3: Configure Jenkins Memory

Critical for t3.micro with 1 GB RAM. By default, Jenkins eats all your memory. Cap it:

sudo systemctl edit jenkins
Enter fullscreen mode Exit fullscreen mode

Add:

[Service]
Environment="JAVA_OPTS=-Xms128m -Xmx400m -XX:MaxMetaspaceSize=192m"
Enter fullscreen mode Exit fullscreen mode

Reload and restart:

sudo systemctl daemon-reload
sudo systemctl restart jenkins
Enter fullscreen mode Exit fullscreen mode

Why -Xmx400m? This caps Jenkins' heap at 400 MB. Without this, Jenkins + Docker + your app will exceed 1 GB RAM and the server will start swapping hard.

Step 3.4: Give Jenkins Access to Docker

Jenkins needs to run docker commands. Add the jenkins user to the docker group:

sudo usermod -aG docker jenkins
sudo systemctl restart jenkins
Enter fullscreen mode Exit fullscreen mode

Verify:

sudo -u jenkins docker ps
Enter fullscreen mode Exit fullscreen mode

Should return an empty list, not "permission denied."

Step 3.5: Unlock Jenkins

Open http://<YOUR_ELASTIC_IP>:8080 in your browser.

Get the initial admin password:

sudo cat /var/lib/jenkins/secrets/initialAdminPassword
Enter fullscreen mode Exit fullscreen mode

Paste it, click Continue, install suggested plugins, create an admin user, and finish.

Step 3.6: Install Required Plugins

Go to Manage Jenkins โ†’ Plugins โ†’ Available plugins. Install:

  • GitHub Integration Plugin โ€” receives webhooks
  • Docker Pipeline โ€” runs Docker commands in pipelines
  • Docker โ€” Docker integration for Jenkins

Restart Jenkins after installing.

Step 3.7: Store Your Secrets

We need to give Jenkins access to the MongoDB URI and JWT secret โ€” without ever putting them in Git.

Go to Manage Jenkins โ†’ Credentials โ†’ System โ†’ Global credentials โ†’ Add Credentials:

Credential 1:

  • Kind: Secret text
  • Secret: <your actual MongoDB URI>
  • ID: bloodlinker-mongo-uri (must match exactly)

Credential 2:

  • Kind: Secret text
  • Secret: <your actual JWT secret>
  • ID: bloodlinker-jwt-secret

Why "Secret text"? It's the simplest credential type. Jenkins encrypts it and never shows it in logs.

โš ๏ธ The ID must be exact. If your Jenkinsfile references bloodlinker-mongo-uri, the credential must have that exact same ID (case-sensitive, no spaces).


Phase 4: The Jenkinsfile โ€” Pipeline as Code

This is the heart of the CI/CD setup. The Jenkinsfile is a Groovy script that tells Jenkins what to do.

Create a file called Jenkinsfile at your project root (no extension):

// ============================================================
// BloodLinker CI/CD Pipeline
// ============================================================

pipeline {
    agent any

    // Environment variables โ€” secrets come from Jenkins credentials
    environment {
        MONGO_URI       = credentials('bloodlinker-mongo-uri')
        JWT_SECRET      = credentials('bloodlinker-jwt-secret')
        JWT_EXPIRES_IN  = '7d'
        NODE_ENV        = 'production'
        VITE_API_URL    = 'https://bloodlinker.is-a.dev/api'
    }

    stages {
        stage('Checkout') {
            steps {
                echo '๐Ÿ“ฅ Code checked out from GitHub'
                sh 'pwd && ls -la'
            }
        }

        stage('Prepare Environment') {
            steps {
                echo '๐Ÿ” Writing .env file from credentials'
                sh '''
                    cat > .env <<EOF
MONGO_URI=${MONGO_URI}
JWT_SECRET=${JWT_SECRET}
JWT_EXPIRES_IN=${JWT_EXPIRES_IN}
NODE_ENV=${NODE_ENV}
VITE_API_URL=${VITE_API_URL}
EOF
                    echo 'โœ… .env created'
                '''
            }
        }

        stage('Build Backend') {
            steps {
                echo '๐Ÿ”จ Building backend Docker image...'
                sh 'docker compose build backend'
            }
        }

        stage('Build Frontend') {
            steps {
                echo '๐Ÿ”จ Building frontend Docker image (no cache)...'
                sh 'docker compose build --no-cache frontend'
            }
        }

        stage('Deploy') {
            steps {
                echo '๐Ÿš€ Deploying new containers...'
                sh 'docker compose down --remove-orphans || true'
                sh 'docker compose up -d --force-recreate'
                sh 'docker compose ps'
            }
        }

        stage('Health Check') {
            steps {
                echo '๐Ÿฅ Checking backend health...'
                sh '''
                    sleep 15
                    curl -f http://localhost:5000/api/health || exit 1
                '''
            }
        }
    }

    post {
        success {
            echo '๐ŸŽ‰ Deployment successful!'
        }
        failure {
            echo 'โŒ Deployment failed. Check logs above.'
        }
        always {
            sh 'rm -f .env || true'
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Walkthrough of Each Stage

Checkout โ€” Jenkins automatically clones your repo before the pipeline runs. This stage just verifies the files are there.

Prepare Environment โ€” Writes a .env file from Jenkins credentials. This is why we never commit .env to Git โ€” it's generated fresh on every build.

Build Backend โ€” Runs docker compose build backend. Docker builds the image.

Build Frontend โ€” Uses --no-cache. Why? This is critical. Because VITE_API_URL is a build-time argument, Docker caches might reuse an old image with a stale URL. --no-cache forces a fresh build.

Deploy โ€” Stops old containers, starts new ones. --force-recreate ensures containers restart with the new images.

Health Check โ€” Waits 15 seconds, then hits /api/health. If it fails, the pipeline fails โ€” so we don't deploy broken builds.

Post actions โ€” Cleans up .env from the workspace, reports success or failure.

Create the Jenkins Job

In the Jenkins UI:

  1. New Item โ†’ Pipeline โ†’ name it bloodlinker-pipeline
  2. Under Build Triggers, check GitHub hook trigger for GITScm polling (we'll configure the webhook later)
  3. Under Pipeline:
    • Definition: Pipeline script from SCM
    • SCM: Git
    • Repository URL: https://github.com/yourname/repo.git
    • Branch Specifier: */main
    • Script Path: Jenkinsfile
  4. Save

Trigger the First Build

Click "Build Now". Watch the Console Output.

If it succeeds โ†’ you've deployed your app via Jenkins! ๐ŸŽ‰

**If it fails โ†’ this is where the learning begins.

Phase 5: Nginx Reverse Proxy + Custom Domain

Right now, your app runs on http://<YOUR_IP>:3000 (frontend) and http://<YOUR_IP>:5000/api (backend). That's ugly, and it exposes internal ports.

Let's fix that with Nginx and a real domain.

Step 5.1: Register a Free Custom Domain

For a portfolio project, you don't want to pay $15/year for a domain. Instead, use is-a.dev โ€” a free subdomain service for developers.

Step 5.2: Install Nginx on the Server

SSH back into your EC2:

sudo apt install -y nginx
sudo systemctl start nginx
sudo systemctl enable nginx
Enter fullscreen mode Exit fullscreen mode

Test:

http://<YOUR_ELASTIC_IP>
Enter fullscreen mode Exit fullscreen mode

You'll see the Nginx welcome page. Delete it:

sudo rm /etc/nginx/sites-enabled/default
Enter fullscreen mode Exit fullscreen mode

Step 5.3: Configure Nginx as a Reverse Proxy

In SSH Create file - /etc/nginx/sites-available/bloodlinker:

sudo nano /etc/nginx/sites-available/bloodlinker
Enter fullscreen mode Exit fullscreen mode

the content of above file based on my project :

server {
    listen 80;
    server_name yourname.is-a.dev www.yourname.is-a.dev;

    client_max_body_size 20M;

    # API routes โ†’ backend container
    location /api/ {
        proxy_pass http://localhost: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;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

    # Everything else โ†’ frontend container
    location / {
        proxy_pass http://localhost:3000;
        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;
    }
}
Enter fullscreen mode Exit fullscreen mode

Enable it:
In your SSH

sudo ln -s /etc/nginx/sites-available/bloodlinker /etc/nginx/sites-enabled/
sudo nginx -t    # ALWAYS test before reloading!
sudo systemctl reload nginx
Enter fullscreen mode Exit fullscreen mode

Step 5.4: Test

Open http://yourname.is-a.dev. You should see your app! ๐ŸŽ‰

Common issues:

  • "Connection refused" โ€” Nginx isn't running, or the config isn't loaded. Check sudo systemctl status nginx.
  • "502 Bad Gateway" โ€” Nginx is running, but your containers aren't. Run docker ps to check.

Phase 6: HTTPS with Let's Encrypt

HTTP is nice, but HTTPS is table stakes in 2026. Browsers flag HTTP sites as "Not Secure," Google penalizes them in rankings, and users don't trust them.

Let's add free HTTPS.

Step 6.1: Install Certbot

In your SSH

sudo apt update
sudo apt install -y certbot python3-certbot-nginx
Enter fullscreen mode Exit fullscreen mode

Certbot is the official tool for Let's Encrypt. The python3-certbot-nginx plugin automatically modifies your Nginx config.

Step 6.2: Get the Certificate

In your SSH

sudo certbot --nginx -d yourname.is-a.dev -d www.yourname.is-a.dev
Enter fullscreen mode Exit fullscreen mode

Certbot asks a series of questions:

  1. Email address: Your real email (for renewal reminders)
  2. Agree to TOS: A
  3. Share email with EFF: N (or Y, your choice)
  4. Redirect HTTP to HTTPS: 2 (choose this!)

What happens next:

  • Certbot verifies you own the domain (via HTTP challenge)
  • Downloads a certificate from Let's Encrypt (valid 90 days)
  • Automatically updates your Nginx config to add HTTPS
  • Sets up HTTP โ†’ HTTPS redirect

Expected output:

Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/yourname.is-a.dev/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/yourname.is-a.dev/privkey.pem
This certificate expires on 2027-XX-XX.
...
Congratulations! You have successfully enabled HTTPS
Enter fullscreen mode Exit fullscreen mode

Step 6.3: Verify

Open https://yourname.is-a.dev. You should see:

  • ๐Ÿ”’ Green padlock in the address bar
  • โœ… Click the lock โ†’ "Connection is secure"

Also test:

http://yourname.is-a.dev
Enter fullscreen mode Exit fullscreen mode

Should auto-redirect to HTTPS.

Step 6.4: Auto-Renewal

Certificates expire every 90 days. Certbot handles this automatically via a systemd timer. Test it:

In your SSH

sudo certbot renew --dry-run
Enter fullscreen mode Exit fullscreen mode

Should say: "Congratulations, all simulated renewals succeeded."

You never have to think about SSL certificates again. Certbot checks twice a day and renews when needed.


Phase 7: GitHub Webhooks โ€” True Automation

Right now, you have to click "Build Now" in Jenkins every time you want to deploy. That's not CI/CD โ€” that's manual.

GitHub webhooks fix this. Every git push triggers the pipeline automatically.

Step 7.1: Prepare the Security Group

GitHub's servers need to reach Jenkins on port 8080. But GitHub's IPs are dynamic, so we can't whitelist them individually.

Update your security group:

  1. AWS Console โ†’ EC2 โ†’ Security Groups โ†’ bloodlinker-sg
  2. Edit the Custom TCP 8080 rule
  3. Change source from "My IP" to "Anywhere (0.0.0.0/0)"

โš ๏ธ Security trade-off: This exposes Jenkins publicly. Mitigate by:

  • Jenkins still requires authentication
  • Use strong credentials
  • (Later) Put Jenkins behind a subdomain with Nginx + SSL

Step 7.2: Enable Webhook Trigger in Jenkins

Jenkins โ†’ Your job โ†’ Configure โ†’ Build Triggers โ†’ check:

โ˜‘ GitHub hook trigger for GITScm polling

Save.

Step 7.3: Create the Webhook in GitHub

  1. GitHub repo โ†’ Settings โ†’ Webhooks โ†’ Add webhook
  2. Payload URL: http://<YOUR_ELASTIC_IP>:8080/github-webhook/
    • โš ๏ธ The trailing slash is critical
  3. Content type: application/json
  4. Which events? Just the push event
  5. Active: โ˜‘
  6. Click Add webhook

GitHub sends a ping test event immediately. You should see a green โœ… next to your webhook.

Step 7.4: Test It

Make a tiny change to your app and push:

git commit --allow-empty -m "test: trigger auto-deploy"
git push
Enter fullscreen mode Exit fullscreen mode

Watch what happens:

  • Within 5-15 seconds: GitHub sends the webhook
  • Within 30 seconds: Jenkins starts a new build automatically
  • Within 2-3 minutes: Your app is updated live

Welcome to real CI/CD. ๐ŸŽ‰

Step 7.5: Update App URLs to Use the Domain

Critical step. Update these three files:

1. Root .env:

VITE_API_URL=https://yourname.is-a.dev/api
Enter fullscreen mode Exit fullscreen mode

2. Jenkinsfile:

VITE_API_URL = 'https://yourname.is-a.dev/api'
Enter fullscreen mode Exit fullscreen mode

3. backend/server.js CORS:

const allowedOrigins = [
  'http://localhost:5173',
  'https://yourname.is-a.dev',
  'https://www.yourname.is-a.dev',
];
Enter fullscreen mode Exit fullscreen mode

Commit and push. The webhook triggers Jenkins. Jenkins rebuilds with the new URL.

โš ๏ธ Why this matters: Vite bakes VITE_API_URL into the JS bundle at build time. Change the URL โ†’ rebuild required. This was the source of one of my longest debugging sessions (see Section 8).

Was It Worth It?

Absolutely. Here's what I can now do that I couldn't before:

  • Deploy any Node.js app to a real cloud server
  • Containerize apps with Docker and Compose
  • Build a CI/CD pipeline from scratch
  • Configure Nginx as a reverse proxy
  • Set up a custom domain with HTTPS
  • Debug production issues calmly instead of panicking
  • Explain any of these concepts in a job interview

The $0 cost makes this the best ROI learning project I've ever done.

Conclusion

When I started this project, I was terrified of DevOps. Terms like "container orchestration" and "CI/CD pipeline" sounded like they were for senior engineers only.

They're not.

They're just systems talking to each other. Your code pushes to GitHub. GitHub pings Jenkins. Jenkins runs Docker. Docker runs your app. Nginx serves it to users. That's it.

The hard part isn't the tools. The hard part is having the patience to debug when something breaks โ€” which happens constantly for the first two weeks.

But every error teaches you something. Every fix makes the next issue easier. After three weeks, I can look at a 502 Bad Gateway error and immediately know it's either a container that didn't start or a proxy misconfiguration.

That intuition comes from doing the work.

If you're reading this as a beginner, here's my advice: pick one real project, deploy it the hard way, and don't give up when it breaks. Because it will break. And when it finally works, you'll understand it in a way no tutorial can teach.

You've got this. ๐Ÿš€


Try It Yourself

If this article helped you, please:

  • ๐Ÿ’ฌ Leave a comment with your own deployment story
  • ๐Ÿ”— Share it with someone who's stuck where I was

Follow me for more DevOps, MERN, and beginner-friendly tech writing.

Thanks for reading. ๐Ÿ’ช


Top comments (0)