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:
- Pulls the new code
- Builds new Docker images
- Stops old containers
- Starts new containers
- 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:
- Left sidebar โ Elastic IPs
- Click "Allocate Elastic IP address"
- Click "Allocate"
- Select the new IP โ Actions โ "Associate Elastic IP address"
- Attach to your
bloodlinker-serverinstance
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>
Common first-time errors:
-
"UNPROTECTED PRIVATE KEY FILE" โ Windows gives
.pemfiles too many permissions. Fix:
icacls "path\to\bloodlinker-key.pem" /inheritance:r
icacls "path\to\bloodlinker-key.pem" /grant:r "$($env:USERNAME):(R)"
- "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:~$
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
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
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
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"]
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;"]
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;
}
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
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
Key concepts:
-
${MONGO_URI}โ Read from.envat 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
โ ๏ธ 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
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
Dockerfiledidn't copy a file. CheckCOPY . . -
"MongoDB connection failed" โ Wrong
MONGO_URIor MongoDB Atlas IP whitelist missing0.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
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
Verify:
java -version
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
โ ๏ธ 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
Add:
[Service]
Environment="JAVA_OPTS=-Xms128m -Xmx400m -XX:MaxMetaspaceSize=192m"
Reload and restart:
sudo systemctl daemon-reload
sudo systemctl restart jenkins
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
Verify:
sudo -u jenkins docker ps
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
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'
}
}
}
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:
-
New Item โ Pipeline โ name it
bloodlinker-pipeline - Under Build Triggers, check GitHub hook trigger for GITScm polling (we'll configure the webhook later)
- Under Pipeline:
- Definition: Pipeline script from SCM
- SCM: Git
- Repository URL:
https://github.com/yourname/repo.git - Branch Specifier:
*/main - Script Path:
Jenkinsfile
- 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
Test:
http://<YOUR_ELASTIC_IP>
You'll see the Nginx welcome page. Delete it:
sudo rm /etc/nginx/sites-enabled/default
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
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;
}
}
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
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 psto 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
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
Certbot asks a series of questions:
- Email address: Your real email (for renewal reminders)
-
Agree to TOS:
A -
Share email with EFF:
N(orY, your choice) -
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
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
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
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:
- AWS Console โ EC2 โ Security Groups โ
bloodlinker-sg - Edit the Custom TCP 8080 rule
- 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
- GitHub repo โ Settings โ Webhooks โ Add webhook
-
Payload URL:
http://<YOUR_ELASTIC_IP>:8080/github-webhook/- โ ๏ธ The trailing slash is critical
-
Content type:
application/json -
Which events?
Just the push event - Active: โ
- 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
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
2. Jenkinsfile:
VITE_API_URL = 'https://yourname.is-a.dev/api'
3. backend/server.js CORS:
const allowedOrigins = [
'http://localhost:5173',
'https://yourname.is-a.dev',
'https://www.yourname.is-a.dev',
];
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
- Live demo: https://bloodlinker.is-a.dev
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)