<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Dishan Sanjula</title>
    <description>The latest articles on DEV Community by Dishan Sanjula (@dishan_sanjula_e92a52f862).</description>
    <link>https://dev.to/dishan_sanjula_e92a52f862</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4184383%2F25d41232-9be6-4ba4-a809-94beb694f852.png</url>
      <title>DEV Community: Dishan Sanjula</title>
      <link>https://dev.to/dishan_sanjula_e92a52f862</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dishan_sanjula_e92a52f862"/>
    <language>en</language>
    <item>
      <title>Deploy a MERN App on AWS with Docker &amp; Jenkins (Beginner Guide)</title>
      <dc:creator>Dishan Sanjula</dc:creator>
      <pubDate>Wed, 14 Oct 2026 18:24:33 +0000</pubDate>
      <link>https://dev.to/dishan_sanjula_e92a52f862/deploying-a-production-ready-mern-app-with-docker-jenkins-and-aws-a-beginners-journey-2p50</link>
      <guid>https://dev.to/dishan_sanjula_e92a52f862/deploying-a-production-ready-mern-app-with-docker-jenkins-and-aws-a-beginners-journey-2p50</guid>
      <description>&lt;p&gt;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&lt;br&gt;
&lt;strong&gt;By Dishan Sanjula&lt;/strong&gt; ·&lt;a href="//dswijesooriya.is-a.dev"&gt;https://dswijesooriya.is-a.dev/&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;"Now what?"&lt;/strong&gt;&lt;br&gt;
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."&lt;/p&gt;

&lt;p&gt;But I wanted something different. I wanted to understand &lt;strong&gt;what actually happens under the hood&lt;/strong&gt;. I wanted to know how production apps get deployed in real companies. I wanted to learn &lt;strong&gt;DevOps&lt;/strong&gt; — that mysterious word everyone throws around but nobody explains properly to beginners.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Total time:&lt;/strong&gt; About 3 weeks of evenings.&lt;br&gt;
&lt;strong&gt;Total cost:&lt;/strong&gt; $0 (everything on free tiers).&lt;br&gt;
&lt;strong&gt;Total sanity lost:&lt;/strong&gt; Some. Worth it.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Let's begin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I Deployed: A Brief Intro to the App&lt;/strong&gt;&lt;br&gt;
Before we dive into DevOps, let me quickly introduce the app&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BloodLinker&lt;/strong&gt; is a &lt;strong&gt;blood donation management system&lt;/strong&gt; with three user roles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;🩸 Donors&lt;/strong&gt; — Register, receive notifications about matching blood requests in their district, respond to requests, track donation history with a 90-day medical eligibility countdown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;🏥 Hospitals&lt;/strong&gt; — Register (pending admin approval), post blood requests, view donor responses, mark donations as completed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;🛡️ Admins&lt;/strong&gt; — Approve hospital registrations, monitor platform activity, manage users.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The stack:&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://bloodlinker.is-a.dev" rel="noopener noreferrer"&gt;https://bloodlinker.is-a.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;important&lt;/strong&gt; - 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The DevOps Stack — Every Tool Explained for Beginners&lt;/strong&gt;&lt;br&gt;
Before we touch any configuration, let me explain what each tool does and why it matters. Bookmark this section &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AWS EC2&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;You pay monthly, live there with EC2 — you rent compute power by the hour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost:&lt;/strong&gt; Free tier gives you &lt;strong&gt;750 hours/month of t3.micro&lt;/strong&gt; for 12 months. That's enough for one instance running 24/7 for free.&lt;/p&gt;
&lt;h3&gt;
  
  
  Docker
&lt;/h3&gt;

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

&lt;p&gt;&lt;strong&gt;Why we use it:&lt;/strong&gt; Solves the "it works on my machine" problem. The container runs the exact same way on your laptop, my server, or any cloud.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Analogy:&lt;/strong&gt; &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Without Docker:&lt;/strong&gt; You give someone a recipe + raw ingredients + your oven temperature. They might get a different result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;With Docker:&lt;/strong&gt; You give them a fully cooked meal in a sealed box. Everyone gets the same result.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Jenkins — The CI/CD Server
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; An automation server that runs your deployment pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why we use it:&lt;/strong&gt; Every time you &lt;code&gt;git push&lt;/code&gt;, Jenkins automatically:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pulls the new code&lt;/li&gt;
&lt;li&gt;Builds new Docker images&lt;/li&gt;
&lt;li&gt;Stops old containers&lt;/li&gt;
&lt;li&gt;Starts new containers&lt;/li&gt;
&lt;li&gt;Verifies the deployment worked&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;
  
  
  Nginx — The Reverse Proxy
&lt;/h3&gt;

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

&lt;p&gt;&lt;strong&gt;Why we use it:&lt;/strong&gt; Users shouldn't have to remember port numbers. &lt;code&gt;https://bloodlinker.is-a.dev&lt;/code&gt; is way better than IP Address.&lt;/p&gt;
&lt;h3&gt;
  
  
  Let's Encrypt + Certbot — Free SSL Certificates
&lt;/h3&gt;

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

&lt;p&gt;&lt;strong&gt;Why we use it:&lt;/strong&gt; HTTPS is non-negotiable in 2026. Without it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Browsers show "Not Secure" warnings&lt;/li&gt;
&lt;li&gt;Google penalizes your SEO&lt;/li&gt;
&lt;li&gt;Users don't trust the site&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  GitHub Webhooks — The Automation Trigger
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; GitHub sends a small HTTP request to your server whenever you push code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why we use it:&lt;/strong&gt; So Jenkins knows when to run the pipeline automatically. Without it, you'd have to click "Build Now" every time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa9yaydhkxwttwnap5dgr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa9yaydhkxwttwnap5dgr.png" alt=" " width="738" height="399"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;*&lt;em&gt;Let's start the deployment part&lt;br&gt;
*&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Phase 1: Setting Up AWS EC2
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Let's rent one.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 1.1: Launch the Instance
&lt;/h3&gt;

&lt;p&gt;Sign in to the AWS Console (&lt;a href="https://console.aws.amazon.com" rel="noopener noreferrer"&gt;https://console.aws.amazon.com&lt;/a&gt;) and navigate to &lt;strong&gt;EC2&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;"Launch instance"&lt;/strong&gt; and fill in:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Name&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;your_app_name-server&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AMI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ubuntu Server 24.04 LTS (Free tier eligible)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Instance type&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;t3.micro (Free tier eligible)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Key pair&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Create new → &lt;code&gt;app_name-key&lt;/code&gt; → &lt;code&gt;.pem&lt;/code&gt; format&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Storage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;20 GB gp3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;⚠️ Important note about the key pair:&lt;/strong&gt; When you click "Create key pair," a &lt;code&gt;.pem&lt;/code&gt; file downloads. &lt;strong&gt;Move it to a safe folder immediately&lt;/strong&gt; 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.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 1.2: Configure Security Groups
&lt;/h3&gt;

&lt;p&gt;This is where &lt;strong&gt;most beginners get stuck&lt;/strong&gt;. A security group is a firewall that decides what traffic is allowed in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Create a new security group&lt;/strong&gt; named &lt;code&gt;your_project_name-sg&lt;/code&gt; with these inbound rules:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SSH&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;My IP&lt;/td&gt;
&lt;td&gt;Remote access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;Anywhere (0.0.0.0/0)&lt;/td&gt;
&lt;td&gt;Web traffic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTPS&lt;/td&gt;
&lt;td&gt;443&lt;/td&gt;
&lt;td&gt;Anywhere (0.0.0.0/0)&lt;/td&gt;
&lt;td&gt;Secure web traffic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom TCP&lt;/td&gt;
&lt;td&gt;8080&lt;/td&gt;
&lt;td&gt;My IP&lt;/td&gt;
&lt;td&gt;Jenkins UI&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Why "My IP" for SSH?&lt;/strong&gt; Only your computer can access the server via SSH. This is a security best practice. &lt;strong&gt;Every time your home IP changes&lt;/strong&gt; (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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why "Anywhere" for HTTP/HTTPS?&lt;/strong&gt; Because your users will access the site from anywhere. That's the point.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 1.3: Launch and Get Your Elastic IP
&lt;/h3&gt;

&lt;p&gt;Click &lt;strong&gt;"Launch instance"&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Wait 2 minutes, then &lt;strong&gt;allocate an Elastic IP&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Left sidebar → &lt;strong&gt;Elastic IPs&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;"Allocate Elastic IP address"&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;"Allocate"&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Select the new IP → &lt;strong&gt;Actions&lt;/strong&gt; → &lt;strong&gt;"Associate Elastic IP address"&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Attach to your &lt;code&gt;bloodlinker-server&lt;/code&gt; instance&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;&lt;strong&gt;Save your Elastic IP somewhere safe.&lt;/strong&gt; You'll use it dozens of times.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 1.4: SSH In for the First Time
&lt;/h3&gt;

&lt;p&gt;Open PowerShell (Windows) or Terminal (Mac/Linux) and connect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"C:&lt;/span&gt;&lt;span class="se"&gt;\U&lt;/span&gt;&lt;span class="s2"&gt;sers&lt;/span&gt;&lt;span class="se"&gt;\Y&lt;/span&gt;&lt;span class="s2"&gt;ourName&lt;/span&gt;&lt;span class="se"&gt;\p&lt;/span&gt;&lt;span class="s2"&gt;ath&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;o&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="s2"&gt;loodlinker-key.pem"&lt;/span&gt; ubuntu@&amp;lt;YOUR_ELASTIC_IP&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Common first-time errors:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"UNPROTECTED PRIVATE KEY FILE"&lt;/strong&gt; — Windows gives &lt;code&gt;.pem&lt;/code&gt; files too many permissions. Fix:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="n"&gt;icacls&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"path\to\bloodlinker-key.pem"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;/inheritance:r&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;icacls&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"path\to\bloodlinker-key.pem"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;/grant:r&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;USERNAME&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;:(R)"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Connection timed out"&lt;/strong&gt; — Your security group doesn't allow SSH from your current IP. Update the rule to "My IP."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Permission denied (publickey)"&lt;/strong&gt; — Wrong key path, or the key isn't attached to this instance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If it works, you'll see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Welcome to Ubuntu 24.04.x LTS ...

ubuntu@ip-172-31-xx-xxx:~$
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;You're inside a real cloud server.&lt;/strong&gt; 🎉&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1.5: Basic Server Setup
&lt;/h3&gt;

&lt;p&gt;Once connected, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Update packages&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;

&lt;span class="c"&gt;# Install essentials&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; curl git unzip wget nano ufw

&lt;span class="c"&gt;# Set timezone (optional — replace with yours)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;timedatectl set-timezone Asia/Colombo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Add 2 GB of swap space&lt;/strong&gt; (critical for t3.micro with only 1 GB RAM):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;fallocate &lt;span class="nt"&gt;-l&lt;/span&gt; 2G /swapfile
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;600 /swapfile
&lt;span class="nb"&gt;sudo &lt;/span&gt;mkswap /swapfile
&lt;span class="nb"&gt;sudo &lt;/span&gt;swapon /swapfile
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'/swapfile none swap sw 0 0'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /etc/fstab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why swap?&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;free &lt;span class="nt"&gt;-h&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see &lt;code&gt;Swap: 2.0Gi&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 2: Dockerizing the App
&lt;/h2&gt;

&lt;p&gt;Now for the fun part. We're going to package the app into containers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2.1: Create the Backend Dockerfile
&lt;/h3&gt;

&lt;p&gt;In your project's &lt;code&gt;backend/&lt;/code&gt; folder, create a file called &lt;code&gt;Dockerfile&lt;/code&gt; (no extension):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# ============================================================&lt;/span&gt;
&lt;span class="c"&gt;# Backend Dockerfile — Node.js + Express&lt;/span&gt;
&lt;span class="c"&gt;# ============================================================&lt;/span&gt;

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:20-alpine&lt;/span&gt;

&lt;span class="c"&gt;# Set working directory&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;

&lt;span class="c"&gt;# Copy package files first (better caching)&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package*.json ./&lt;/span&gt;

&lt;span class="c"&gt;# Install production dependencies only&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--omit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev

&lt;span class="c"&gt;# Copy the rest of the code&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;

&lt;span class="c"&gt;# Expose port&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 5000&lt;/span&gt;

&lt;span class="c"&gt;# Start the server&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["npm", "start"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;node:20-alpine&lt;/code&gt;?&lt;/strong&gt; Alpine Linux is a tiny distro (~5 MB vs ~900 MB for full Node images). Smaller image = faster deploy.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Step 2.2: Create the Frontend Dockerfile
&lt;/h3&gt;

&lt;p&gt;For the frontend, we use a &lt;strong&gt;multi-stage build&lt;/strong&gt;. This is a professional technique that keeps the final image tiny.&lt;/p&gt;

&lt;p&gt;Create &lt;code&gt;frontend/Dockerfile&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# ============================================================&lt;/span&gt;
&lt;span class="c"&gt;# Frontend Dockerfile — Multi-stage build&lt;/span&gt;
&lt;span class="c"&gt;# ============================================================&lt;/span&gt;

&lt;span class="c"&gt;# ---------- STAGE 1: BUILD ----------&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:20-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;

&lt;span class="c"&gt;# Install dependencies&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package*.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt;

&lt;span class="c"&gt;# Copy source code&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;

&lt;span class="c"&gt;# Accept the API URL as a build argument&lt;/span&gt;
&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; VITE_API_URL&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; VITE_API_URL=$VITE_API_URL&lt;/span&gt;

&lt;span class="c"&gt;# Build the React app&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm run build

&lt;span class="c"&gt;# ---------- STAGE 2: SERVE ----------&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; nginx:alpine&lt;/span&gt;

&lt;span class="c"&gt;# Remove default Nginx welcome page&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /usr/share/nginx/html/&lt;span class="k"&gt;*&lt;/span&gt;

&lt;span class="c"&gt;# Copy the built React app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /app/dist /usr/share/nginx/html&lt;/span&gt;

&lt;span class="c"&gt;# Copy custom Nginx config for SPA routing&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; nginx.conf /etc/nginx/conf.d/default.conf&lt;/span&gt;

&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 80&lt;/span&gt;

&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["nginx", "-g", "daemon off;"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why multi-stage?&lt;/strong&gt; &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stage 1&lt;/strong&gt; has Node.js + build tools (~300 MB of temporary stuff)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stage 2&lt;/strong&gt; only copies the built &lt;code&gt;dist/&lt;/code&gt; folder (~25 MB final image)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; A production image that's &lt;strong&gt;10x smaller&lt;/strong&gt; than a naive single-stage build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⚠️ The critical bit:&lt;/strong&gt; &lt;code&gt;VITE_API_URL&lt;/code&gt; is passed as a &lt;strong&gt;build argument&lt;/strong&gt;. Vite bakes it into the JS bundle at build time. &lt;strong&gt;This means if the URL is wrong, you must rebuild — restarting won't help.&lt;/strong&gt; Remember this. It will save you hours later. (Spoiler: it doesn't save me hours later.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2.3: Create Frontend's Nginx Config
&lt;/h3&gt;

&lt;p&gt;Create &lt;code&gt;frontend/nginx.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ============================================================&lt;/span&gt;
&lt;span class="c1"&gt;# Nginx config INSIDE the frontend container&lt;/span&gt;
&lt;span class="c1"&gt;# ============================================================&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/usr/share/nginx/html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;index&lt;/span&gt; &lt;span class="s"&gt;index.html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# SPA routing — send everything to index.html&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="n"&gt;/index.html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# Cache static assets&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;.(js|css|png|jpg|svg|woff2?)&lt;/span&gt;$ &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;expires&lt;/span&gt; &lt;span class="s"&gt;1y&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt; &lt;span class="s"&gt;"public,&lt;/span&gt; &lt;span class="s"&gt;immutable"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;gzip&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;gzip_types&lt;/span&gt; &lt;span class="nc"&gt;text/css&lt;/span&gt; &lt;span class="nc"&gt;application/javascript&lt;/span&gt; &lt;span class="nc"&gt;application/json&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this matters:&lt;/strong&gt; Without &lt;code&gt;try_files $uri $uri/ /index.html&lt;/code&gt;, refreshing &lt;code&gt;/dashboard&lt;/code&gt; gives you a 404 — because there's no file at that path. This config sends all unknown routes to &lt;code&gt;index.html&lt;/code&gt;, letting React Router handle navigation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2.4: Create &lt;code&gt;.dockerignore&lt;/code&gt; Files
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Never&lt;/strong&gt; copy &lt;code&gt;node_modules&lt;/code&gt; or &lt;code&gt;.env&lt;/code&gt; into Docker images. Create &lt;code&gt;backend/.dockerignore&lt;/code&gt; and &lt;code&gt;frontend/.dockerignore&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node_modules
.env
.env.local
.git
.gitignore
*.md
.vscode
npm-debug.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; &lt;code&gt;node_modules&lt;/code&gt; is huge and gets reinstalled inside the container. &lt;code&gt;.env&lt;/code&gt; contains secrets that should never be baked into images.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2.5: Create docker-compose.yml
&lt;/h3&gt;

&lt;p&gt;This single file orchestrates both containers. Create it at the &lt;strong&gt;project root&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./backend&lt;/span&gt;
      &lt;span class="na"&gt;dockerfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Dockerfile&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bloodlinker-backend&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;MONGO_URI=${MONGO_URI}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;JWT_SECRET=${JWT_SECRET}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-7d}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;NODE_ENV=production&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;PORT=5000&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5000:5000"&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;bloodlinker-network&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wget"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--spider"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:5000/api/health"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;

  &lt;span class="na"&gt;frontend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./frontend&lt;/span&gt;
      &lt;span class="na"&gt;dockerfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Dockerfile&lt;/span&gt;
      &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;VITE_API_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${VITE_API_URL}&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bloodlinker-frontend&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3000:80"&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;bloodlinker-network&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;bloodlinker-network&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bridge&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key concepts:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;${MONGO_URI}&lt;/code&gt;&lt;/strong&gt; — Read from &lt;code&gt;.env&lt;/code&gt; at the project root (git-ignored)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;restart: unless-stopped&lt;/code&gt;&lt;/strong&gt; — Containers auto-start on server reboot&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;healthcheck&lt;/code&gt;&lt;/strong&gt; — Docker monitors the backend's health&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;networks&lt;/code&gt;&lt;/strong&gt; — Containers can reach each other by name&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 2.6: Create &lt;code&gt;.env&lt;/code&gt; at Project Root
&lt;/h3&gt;

&lt;p&gt;Create &lt;code&gt;.env&lt;/code&gt; at the project root (and add it to &lt;code&gt;.gitignore&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MONGO_URI=mongodb+srv://user:pass@cluster.mongodb.net/bloodlife?retryWrites=true&amp;amp;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;⚠️ Add &lt;code&gt;.env&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt; right now.&lt;/strong&gt; If you accidentally commit this, your MongoDB password leaks to GitHub forever. Rotate the password if that happens.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2.7: Test Locally
&lt;/h3&gt;

&lt;p&gt;Before deploying to AWS, test locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open &lt;code&gt;http://localhost:3000&lt;/code&gt; in your browser. If it loads, &lt;strong&gt;you're ready to move to the server&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Common first-time errors:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Cannot find module"&lt;/strong&gt; — Your &lt;code&gt;Dockerfile&lt;/code&gt; didn't copy a file. Check &lt;code&gt;COPY . .&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"MongoDB connection failed"&lt;/strong&gt; — Wrong &lt;code&gt;MONGO_URI&lt;/code&gt; or MongoDB Atlas IP whitelist missing &lt;code&gt;0.0.0.0/0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Port already in use"&lt;/strong&gt; — Something else is using port 5000 or 3000. Kill it: &lt;code&gt;docker stop $(docker ps -aq)&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once it works locally, stop the containers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose down
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Phase 3: Installing Jenkins on the Server
&lt;/h2&gt;

&lt;p&gt;Now let's install the automation server. SSH into your EC2 instance.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3.1: Install Java
&lt;/h3&gt;

&lt;p&gt;Jenkins requires Java 17 or higher:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; openjdk-21-jre
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;java &lt;span class="nt"&gt;-version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3.2: Install Jenkins
&lt;/h3&gt;

&lt;p&gt;Add the Jenkins repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Add GPG key&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;wget &lt;span class="nt"&gt;-O&lt;/span&gt; /etc/apt/keyrings/jenkins-keyring.asc &lt;span class="se"&gt;\&lt;/span&gt;
  https://pkg.jenkins.io/debian-stable/jenkins.io-2026.key

&lt;span class="c"&gt;# Add repository&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"deb [signed-by=/etc/apt/keyrings/jenkins-keyring.asc]"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://pkg.jenkins.io/debian-stable binary/ | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  /etc/apt/sources.list.d/jenkins.list &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null

&lt;span class="c"&gt;# Install&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; jenkins
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;⚠️ Pro tip:&lt;/strong&gt; If you see &lt;code&gt;NO_PUBKEY&lt;/code&gt; errors, your GPG key is outdated. The correct URL ends in &lt;code&gt;jenkins.io-2026.key&lt;/code&gt;, not &lt;code&gt;2023.key&lt;/code&gt;. This tripped me up for 30 minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3.3: Configure Jenkins Memory
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Critical for t3.micro with 1 GB RAM.&lt;/strong&gt; By default, Jenkins eats all your memory. Cap it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl edit jenkins
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight systemd"&gt;&lt;code&gt;&lt;span class="k"&gt;[Service]&lt;/span&gt;
&lt;span class="nt"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;"JAVA_OPTS=-Xms128m -Xmx400m -XX:MaxMetaspaceSize=192m"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reload and restart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart jenkins
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;-Xmx400m&lt;/code&gt;?&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3.4: Give Jenkins Access to Docker
&lt;/h3&gt;

&lt;p&gt;Jenkins needs to run &lt;code&gt;docker&lt;/code&gt; commands. Add the &lt;code&gt;jenkins&lt;/code&gt; user to the &lt;code&gt;docker&lt;/code&gt; group:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker jenkins
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart jenkins
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Verify:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; jenkins docker ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Should return an empty list, &lt;strong&gt;not "permission denied."&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3.5: Unlock Jenkins
&lt;/h3&gt;

&lt;p&gt;Open &lt;code&gt;http://&amp;lt;YOUR_ELASTIC_IP&amp;gt;:8080&lt;/code&gt; in your browser.&lt;/p&gt;

&lt;p&gt;Get the initial admin password:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo cat&lt;/span&gt; /var/lib/jenkins/secrets/initialAdminPassword
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paste it, click &lt;strong&gt;Continue&lt;/strong&gt;, install &lt;strong&gt;suggested plugins&lt;/strong&gt;, create an admin user, and finish.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3.6: Install Required Plugins
&lt;/h3&gt;

&lt;p&gt;Go to &lt;strong&gt;Manage Jenkins → Plugins → Available plugins&lt;/strong&gt;. Install:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Integration Plugin&lt;/strong&gt; — receives webhooks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker Pipeline&lt;/strong&gt; — runs Docker commands in pipelines&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker&lt;/strong&gt; — Docker integration for Jenkins&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Restart Jenkins after installing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3.7: Store Your Secrets
&lt;/h3&gt;

&lt;p&gt;We need to give Jenkins access to the MongoDB URI and JWT secret — &lt;strong&gt;without ever putting them in Git&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Go to &lt;strong&gt;Manage Jenkins → Credentials → System → Global credentials → Add Credentials&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Credential 1:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Kind: &lt;code&gt;Secret text&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Secret: &lt;code&gt;&amp;lt;your actual MongoDB URI&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;ID: &lt;code&gt;bloodlinker-mongo-uri&lt;/code&gt; (&lt;strong&gt;must match exactly&lt;/strong&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Credential 2:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Kind: &lt;code&gt;Secret text&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Secret: &lt;code&gt;&amp;lt;your actual JWT secret&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;ID: &lt;code&gt;bloodlinker-jwt-secret&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why "Secret text"?&lt;/strong&gt; It's the simplest credential type. Jenkins encrypts it and never shows it in logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⚠️ The ID must be exact.&lt;/strong&gt; If your Jenkinsfile references &lt;code&gt;bloodlinker-mongo-uri&lt;/code&gt;, the credential must have that &lt;strong&gt;exact same ID&lt;/strong&gt; (case-sensitive, no spaces).&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 4: The Jenkinsfile — Pipeline as Code
&lt;/h2&gt;

&lt;p&gt;This is the &lt;strong&gt;heart of the CI/CD setup&lt;/strong&gt;. The Jenkinsfile is a Groovy script that tells Jenkins what to do.&lt;/p&gt;

&lt;p&gt;Create a file called &lt;code&gt;Jenkinsfile&lt;/code&gt; at your &lt;strong&gt;project root&lt;/strong&gt; (no extension):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ============================================================&lt;/span&gt;
&lt;span class="c1"&gt;// BloodLinker CI/CD Pipeline&lt;/span&gt;
&lt;span class="c1"&gt;// ============================================================&lt;/span&gt;

&lt;span class="n"&gt;pipeline&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="n"&gt;any&lt;/span&gt;

    &lt;span class="c1"&gt;// Environment variables — secrets come from Jenkins credentials&lt;/span&gt;
    &lt;span class="n"&gt;environment&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;MONGO_URI&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;credentials&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'bloodlinker-mongo-uri'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;JWT_SECRET&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;credentials&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'bloodlinker-jwt-secret'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;JWT_EXPIRES_IN&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'7d'&lt;/span&gt;
        &lt;span class="n"&gt;NODE_ENV&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'production'&lt;/span&gt;
        &lt;span class="n"&gt;VITE_API_URL&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'https://bloodlinker.is-a.dev/api'&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;stages&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Checkout'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'📥 Code checked out from GitHub'&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'pwd &amp;amp;&amp;amp; ls -la'&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Prepare Environment'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'🔐 Writing .env file from credentials'&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'''
                    cat &amp;gt; .env &amp;lt;&amp;lt;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'
                '''&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Build Backend'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'🔨 Building backend Docker image...'&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'docker compose build backend'&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Build Frontend'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'🔨 Building frontend Docker image (no cache)...'&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'docker compose build --no-cache frontend'&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Deploy'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'🚀 Deploying new containers...'&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'docker compose down --remove-orphans || true'&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'docker compose up -d --force-recreate'&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'docker compose ps'&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Health Check'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'🏥 Checking backend health...'&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'''
                    sleep 15
                    curl -f http://localhost:5000/api/health || exit 1
                '''&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;success&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'🎉 Deployment successful!'&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;failure&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'❌ Deployment failed. Check logs above.'&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;always&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'rm -f .env || true'&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Walkthrough of Each Stage
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Checkout&lt;/strong&gt; — Jenkins automatically clones your repo before the pipeline runs. This stage just verifies the files are there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prepare Environment&lt;/strong&gt; — Writes a &lt;code&gt;.env&lt;/code&gt; file from Jenkins credentials. &lt;strong&gt;This is why we never commit &lt;code&gt;.env&lt;/code&gt; to Git&lt;/strong&gt; — it's generated fresh on every build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build Backend&lt;/strong&gt; — Runs &lt;code&gt;docker compose build backend&lt;/code&gt;. Docker builds the image.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build Frontend&lt;/strong&gt; — Uses &lt;code&gt;--no-cache&lt;/code&gt;. Why? &lt;strong&gt;This is critical.&lt;/strong&gt; Because &lt;code&gt;VITE_API_URL&lt;/code&gt; is a build-time argument, Docker caches might reuse an old image with a stale URL. &lt;code&gt;--no-cache&lt;/code&gt; forces a fresh build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deploy&lt;/strong&gt; — Stops old containers, starts new ones. &lt;code&gt;--force-recreate&lt;/code&gt; ensures containers restart with the new images.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Health Check&lt;/strong&gt; — Waits 15 seconds, then hits &lt;code&gt;/api/health&lt;/code&gt;. If it fails, the pipeline fails — so we don't deploy broken builds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Post actions&lt;/strong&gt; — Cleans up &lt;code&gt;.env&lt;/code&gt; from the workspace, reports success or failure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Create the Jenkins Job
&lt;/h3&gt;

&lt;p&gt;In the Jenkins UI:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;New Item&lt;/strong&gt; → &lt;strong&gt;Pipeline&lt;/strong&gt; → name it &lt;code&gt;bloodlinker-pipeline&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Under &lt;strong&gt;Build Triggers&lt;/strong&gt;, check &lt;strong&gt;GitHub hook trigger for GITScm polling&lt;/strong&gt; (we'll configure the webhook later)&lt;/li&gt;
&lt;li&gt;Under &lt;strong&gt;Pipeline&lt;/strong&gt;:

&lt;ul&gt;
&lt;li&gt;Definition: &lt;strong&gt;Pipeline script from SCM&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;SCM: &lt;strong&gt;Git&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Repository URL: &lt;code&gt;https://github.com/yourname/repo.git&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Branch Specifier: &lt;code&gt;*/main&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Script Path: &lt;code&gt;Jenkinsfile&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Save&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Trigger the First Build
&lt;/h3&gt;

&lt;p&gt;Click &lt;strong&gt;"Build Now"&lt;/strong&gt;. Watch the Console Output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If it succeeds → you've deployed your app via Jenkins!&lt;/strong&gt; 🎉&lt;/p&gt;

&lt;p&gt;**If it fails → this is where the learning begins. &lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 5: Nginx Reverse Proxy + Custom Domain
&lt;/h2&gt;

&lt;p&gt;Right now, your app runs on &lt;code&gt;http://&amp;lt;YOUR_IP&amp;gt;:3000&lt;/code&gt; (frontend) and &lt;code&gt;http://&amp;lt;YOUR_IP&amp;gt;:5000/api&lt;/code&gt; (backend). That's ugly, and it exposes internal ports.&lt;/p&gt;

&lt;p&gt;Let's fix that with Nginx and a real domain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5.1: Register a Free Custom Domain
&lt;/h3&gt;

&lt;p&gt;For a portfolio project, you don't want to pay $15/year for a domain. Instead, use &lt;strong&gt;is-a.dev&lt;/strong&gt; — a free subdomain service for developers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5.2: Install Nginx on the Server
&lt;/h3&gt;

&lt;p&gt;SSH back into your EC2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; nginx
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start nginx
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://&amp;lt;YOUR_ELASTIC_IP&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll see the Nginx welcome page. &lt;strong&gt;Delete it:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo rm&lt;/span&gt; /etc/nginx/sites-enabled/default
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 5.3: Configure Nginx as a Reverse Proxy
&lt;/h3&gt;

&lt;p&gt;In SSH Create file - &lt;code&gt;/etc/nginx/sites-available/bloodlinker&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /etc/nginx/sites-available/bloodlinker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the content of above file based on my project :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;yourname.is-a.dev&lt;/span&gt; &lt;span class="s"&gt;www.yourname.is-a.dev&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;client_max_body_size&lt;/span&gt; &lt;span class="mi"&gt;20M&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# API routes → backend container&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://localhost:5000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Upgrade&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Connection&lt;/span&gt; &lt;span class="s"&gt;"upgrade"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# Everything else → frontend container&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://localhost:3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Enable it:&lt;/strong&gt;&lt;br&gt;
In your SSH&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /etc/nginx/sites-available/bloodlinker /etc/nginx/sites-enabled/
&lt;span class="nb"&gt;sudo &lt;/span&gt;nginx &lt;span class="nt"&gt;-t&lt;/span&gt;    &lt;span class="c"&gt;# ALWAYS test before reloading!&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 5.4: Test
&lt;/h3&gt;

&lt;p&gt;Open &lt;code&gt;http://yourname.is-a.dev&lt;/code&gt;. You should see your app! 🎉&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Common issues:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Connection refused"&lt;/strong&gt; — Nginx isn't running, or the config isn't loaded. Check &lt;code&gt;sudo systemctl status nginx&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"502 Bad Gateway"&lt;/strong&gt; — Nginx is running, but your containers aren't. Run &lt;code&gt;docker ps&lt;/code&gt; to check.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Phase 6: HTTPS with Let's Encrypt
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Let's add free HTTPS.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6.1: Install Certbot
&lt;/h3&gt;

&lt;p&gt;In your SSH&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; certbot python3-certbot-nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Certbot is the official tool for Let's Encrypt. The &lt;code&gt;python3-certbot-nginx&lt;/code&gt; plugin automatically modifies your Nginx config.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6.2: Get the Certificate
&lt;/h3&gt;

&lt;p&gt;In your SSH&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;certbot &lt;span class="nt"&gt;--nginx&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; yourname.is-a.dev &lt;span class="nt"&gt;-d&lt;/span&gt; www.yourname.is-a.dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Certbot asks a series of questions:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Email address:&lt;/strong&gt; Your real email (for renewal reminders)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agree to TOS:&lt;/strong&gt; &lt;code&gt;A&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Share email with EFF:&lt;/strong&gt; &lt;code&gt;N&lt;/code&gt; (or &lt;code&gt;Y&lt;/code&gt;, your choice)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redirect HTTP to HTTPS:&lt;/strong&gt; &lt;code&gt;2&lt;/code&gt; (&lt;strong&gt;choose this!&lt;/strong&gt;)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;What happens next:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Certbot verifies you own the domain (via HTTP challenge)&lt;/li&gt;
&lt;li&gt;Downloads a certificate from Let's Encrypt (valid 90 days)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatically updates your Nginx config&lt;/strong&gt; to add HTTPS&lt;/li&gt;
&lt;li&gt;Sets up HTTP → HTTPS redirect&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Expected output:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 6.3: Verify
&lt;/h3&gt;

&lt;p&gt;Open &lt;code&gt;https://yourname.is-a.dev&lt;/code&gt;. You should see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🔒 &lt;strong&gt;Green padlock&lt;/strong&gt; in the address bar&lt;/li&gt;
&lt;li&gt;✅ Click the lock → "Connection is secure"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://yourname.is-a.dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Should auto-redirect to HTTPS.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6.4: Auto-Renewal
&lt;/h3&gt;

&lt;p&gt;Certificates expire every 90 days. Certbot handles this automatically via a systemd timer. Test it:&lt;/p&gt;

&lt;p&gt;In your SSH&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;certbot renew &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Should say: &lt;strong&gt;"Congratulations, all simulated renewals succeeded."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You never have to think about SSL certificates again.&lt;/strong&gt; Certbot checks twice a day and renews when needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 7: GitHub Webhooks — True Automation
&lt;/h2&gt;

&lt;p&gt;Right now, you have to click "Build Now" in Jenkins every time you want to deploy. That's not CI/CD — that's manual.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub webhooks fix this.&lt;/strong&gt; Every &lt;code&gt;git push&lt;/code&gt; triggers the pipeline automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7.1: Prepare the Security Group
&lt;/h3&gt;

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

&lt;p&gt;&lt;strong&gt;Update your security group:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;AWS Console → EC2 → Security Groups → &lt;code&gt;bloodlinker-sg&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Edit the &lt;strong&gt;Custom TCP 8080&lt;/strong&gt; rule&lt;/li&gt;
&lt;li&gt;Change source from &lt;strong&gt;"My IP"&lt;/strong&gt; to &lt;strong&gt;"Anywhere (0.0.0.0/0)"&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;⚠️ Security trade-off:&lt;/strong&gt; This exposes Jenkins publicly. Mitigate by:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Jenkins still requires authentication&lt;/li&gt;
&lt;li&gt;Use strong credentials&lt;/li&gt;
&lt;li&gt;(Later) Put Jenkins behind a subdomain with Nginx + SSL&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 7.2: Enable Webhook Trigger in Jenkins
&lt;/h3&gt;

&lt;p&gt;Jenkins → Your job → &lt;strong&gt;Configure&lt;/strong&gt; → &lt;strong&gt;Build Triggers&lt;/strong&gt; → check:&lt;/p&gt;

&lt;p&gt;☑ &lt;strong&gt;GitHub hook trigger for GITScm polling&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Save.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7.3: Create the Webhook in GitHub
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;GitHub repo → &lt;strong&gt;Settings&lt;/strong&gt; → &lt;strong&gt;Webhooks&lt;/strong&gt; → &lt;strong&gt;Add webhook&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payload URL:&lt;/strong&gt; &lt;code&gt;http://&amp;lt;YOUR_ELASTIC_IP&amp;gt;:8080/github-webhook/&lt;/code&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;⚠️ The trailing slash is critical&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content type:&lt;/strong&gt; &lt;code&gt;application/json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which events?&lt;/strong&gt; &lt;code&gt;Just the push event&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Active:&lt;/strong&gt; ☑&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Add webhook&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;GitHub sends a &lt;strong&gt;ping&lt;/strong&gt; test event immediately. You should see a green ✅ next to your webhook.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7.4: Test It
&lt;/h3&gt;

&lt;p&gt;Make a tiny change to your app and push:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git commit &lt;span class="nt"&gt;--allow-empty&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"test: trigger auto-deploy"&lt;/span&gt;
git push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Watch what happens:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Within 5-15 seconds: GitHub sends the webhook&lt;/li&gt;
&lt;li&gt;Within 30 seconds: Jenkins starts a new build &lt;strong&gt;automatically&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Within 2-3 minutes: Your app is updated live&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Welcome to real CI/CD.&lt;/strong&gt; 🎉&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7.5: Update App URLs to Use the Domain
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Critical step.&lt;/strong&gt; Update these three files:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Root &lt;code&gt;.env&lt;/code&gt;:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;VITE_API_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://yourname.is-a.dev/api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Jenkinsfile:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;VITE_API_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'https://yourname.is-a.dev/api'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;backend/server.js&lt;/code&gt; CORS:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allowedOrigins&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:5173&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://yourname.is-a.dev&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://www.yourname.is-a.dev&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Commit and push. The webhook triggers Jenkins. Jenkins rebuilds with the new URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⚠️ Why this matters:&lt;/strong&gt; Vite bakes &lt;code&gt;VITE_API_URL&lt;/code&gt; into the JS bundle at &lt;strong&gt;build time&lt;/strong&gt;. Change the URL → rebuild required. This was the source of one of my longest debugging sessions (see Section 8).&lt;/p&gt;

&lt;h3&gt;
  
  
  Was It Worth It?
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Absolutely.&lt;/strong&gt; Here's what I can now do that I couldn't before:&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;The $0 cost makes this the best ROI learning project I've ever done.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;They're not.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;They're just &lt;strong&gt;systems talking to each other&lt;/strong&gt;. Your code pushes to GitHub. GitHub pings Jenkins. Jenkins runs Docker. Docker runs your app. Nginx serves it to users. That's it.&lt;/p&gt;

&lt;p&gt;The hard part isn't the tools. &lt;strong&gt;The hard part is having the patience to debug when something breaks&lt;/strong&gt; — which happens constantly for the first two weeks.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;That intuition comes from doing the work.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;You've got this.&lt;/strong&gt; 🚀&lt;/p&gt;




&lt;h2&gt;
  
  
  Try It Yourself
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://bloodlinker.is-a.dev" rel="noopener noreferrer"&gt;https://bloodlinker.is-a.dev&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;If this article helped you, please:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;💬 Leave a comment with your own deployment story&lt;/li&gt;
&lt;li&gt;🔗 Share it with someone who's stuck where I was&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Follow me for more DevOps, MERN, and beginner-friendly tech writing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Thanks for reading. 💪&lt;/p&gt;




</description>
      <category>devops</category>
      <category>cicd</category>
      <category>docker</category>
      <category>aws</category>
    </item>
  </channel>
</rss>
