<?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: Manmohan Sharma</title>
    <description>The latest articles on DEV Community by Manmohan Sharma (@manmohan_sharma_a1a85750b).</description>
    <link>https://dev.to/manmohan_sharma_a1a85750b</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3813776%2F7c06402a-1978-4f44-9f73-d5f404c7466e.png</url>
      <title>DEV Community: Manmohan Sharma</title>
      <link>https://dev.to/manmohan_sharma_a1a85750b</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/manmohan_sharma_a1a85750b"/>
    <language>en</language>
    <item>
      <title>From Fork to Production: Deploying a Full-Stack App to AWS with Docker, CI/CD, and Semantic Release</title>
      <dc:creator>Manmohan Sharma</dc:creator>
      <pubDate>Mon, 09 Mar 2026 03:54:06 +0000</pubDate>
      <link>https://dev.to/manmohan_sharma_a1a85750b/from-fork-to-production-deploying-a-full-stack-app-to-aws-with-docker-cicd-and-semantic-release-1hhi</link>
      <guid>https://dev.to/manmohan_sharma_a1a85750b/from-fork-to-production-deploying-a-full-stack-app-to-aws-with-docker-cicd-and-semantic-release-1hhi</guid>
      <description>&lt;h1&gt;
  
  
  From Fork to Production: How I Deployed a Full-Stack App to AWS with Docker, CI/CD, and Semantic Release (And Everything That Broke Along the Way)
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;By Manmohan Sharma&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The Problem Statement
&lt;/h2&gt;

&lt;p&gt;I needed to deploy a full-stack SPA to AWS with a real CI/CD pipeline — not a toy demo, but the kind of setup that would actually hold up beyond a classroom presentation. The goal: take an open-source application, containerize it, build a complete CI/CD pipeline with GitHub Actions, deploy it to AWS with proper infrastructure, SSL certificates, a custom domain, and semantic versioning. Everything automated. Everything reproducible.&lt;/p&gt;

&lt;p&gt;Here's what the final system looks like:&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.amazonaws.com%2Fuploads%2Farticles%2Fwj4buykgdejf16ea693f.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.amazonaws.com%2Fuploads%2Farticles%2Fwj4buykgdejf16ea693f.png" alt=" " width="800" height="468"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;By the end of this project, I had: Docker containers for both frontend and backend, a CI pipeline that blocks bad PRs, a CD pipeline that automatically builds, tags, and deploys on merge, AWS infrastructure with RDS, ECR, EC2, Route53, and SSM, SSL certificates, semantic versioning with auto-generated changelogs, and two live environments (QA and RC). Let me walk you through every step, every failure, and every fix.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Picking an App (And Why I Threw Away My First Choice)
&lt;/h2&gt;

&lt;p&gt;My first instinct was to use &lt;a href="https://github.com/danny-avila/LibreChat" rel="noopener noreferrer"&gt;LibreChat&lt;/a&gt; — it's a fantastic project I've been working with. But it has 20+ existing GitHub Actions workflows, uses MongoDB (my assignment required MySQL), and the codebase is massive. Deploying it would be more about wrestling with complexity than demonstrating DevOps principles.&lt;/p&gt;

&lt;p&gt;I needed something with a clear checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;React frontend&lt;/li&gt;
&lt;li&gt;Express backend&lt;/li&gt;
&lt;li&gt;MySQL database&lt;/li&gt;
&lt;li&gt;Manageable size&lt;/li&gt;
&lt;li&gt;MIT license&lt;/li&gt;
&lt;li&gt;Clean separation between frontend and backend&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I landed on &lt;a href="https://github.com/ishfrfrg/SiPeKa" rel="noopener noreferrer"&gt;SiPeKa&lt;/a&gt; — an Employee Payroll System with 197 stars on GitHub. Exact stack match. Clean folder structure: &lt;code&gt;Frontend/&lt;/code&gt; and &lt;code&gt;Backend/&lt;/code&gt; directories, React + Vite on the front, Express + Sequelize on the back, MySQL for data.&lt;/p&gt;

&lt;p&gt;I forked it to my GitHub. First thing I noticed: everything was in Indonesian. I'd fix that later — it became the perfect demo PR for the CI/CD pipeline.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The First Problem: This App Doesn't Docker
&lt;/h2&gt;

&lt;p&gt;I opened the repo and did a quick inventory:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No Dockerfile. Not one.&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;docker-compose.yml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Database credentials hardcoded directly in source files.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;http://localhost:5000&lt;/code&gt; scattered across &lt;strong&gt;21 frontend files&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Zero tests. A &lt;code&gt;request_test/&lt;/code&gt; folder with empty &lt;code&gt;.rest&lt;/code&gt; files.&lt;/li&gt;
&lt;li&gt;No health endpoint.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before I could deploy this anywhere, I needed to make it portable. Docker is how you turn "it works on my machine" into "it works on every machine." But first, the app needed surgery.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Writing the Dockerfiles — Backend
&lt;/h2&gt;

&lt;p&gt;The backend Dockerfile was straightforward but deliberate:&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="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:20-alpine&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 — Docker layer caching means&lt;/span&gt;
&lt;span class="c"&gt;# npm ci only reruns when dependencies actually change&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 ci &lt;span class="nt"&gt;--only&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;production

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;

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

&lt;span class="k"&gt;HEALTHCHECK&lt;/span&gt;&lt;span class="s"&gt; --interval=30s --timeout=3s --start-period=10s --retries=3 \&lt;/span&gt;
  CMD wget --no-verbose --tries=1 --spider http://localhost:5000/health || exit 1

&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "index.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth calling out:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer caching&lt;/strong&gt;: By copying &lt;code&gt;package.json&lt;/code&gt; and &lt;code&gt;package-lock.json&lt;/code&gt; before the rest of the source code, Docker caches the &lt;code&gt;npm ci&lt;/code&gt; layer. As long as dependencies don't change, rebuilds skip the slow install step entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HEALTHCHECK&lt;/strong&gt;: This instruction tells Docker itself whether the container is alive. It becomes critical later — Docker Compose uses it for startup ordering, and our deploy scripts use the health endpoint for verification.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;app.js&lt;/code&gt; refactor&lt;/strong&gt;: The original codebase had everything in one file — Express configuration, route mounting, and &lt;code&gt;app.listen()&lt;/code&gt; all tangled together. I split the Express app config from the server startup so tests could import the app without actually starting a server. This is a small change that unlocks testability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;/health&lt;/code&gt; endpoint&lt;/strong&gt;: I added a simple endpoint that returns &lt;code&gt;{ status: "ok", uptime: process.uptime() }&lt;/code&gt;. This tiny endpoint becomes the foundation for everything: Docker healthchecks, CI smoke tests, deployment verification. If &lt;code&gt;/health&lt;/code&gt; returns 200, the app is alive.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Writing the Dockerfiles — Frontend (Multi-Stage Build)
&lt;/h2&gt;

&lt;p&gt;The frontend Dockerfile is where things get interesting:&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;# 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;build&lt;/span&gt;

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&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="nt"&gt;--legacy-peer-deps&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&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:1.27-alpine&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /app/dist /usr/share/nginx/html&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 443&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; The build stage pulls in 500MB+ of &lt;code&gt;node_modules&lt;/code&gt; — React, Vite, Babel, all the build tooling. But the output of &lt;code&gt;npm run build&lt;/code&gt; is just static HTML, CSS, and JS files in a &lt;code&gt;/dist&lt;/code&gt; folder. The final image copies only those files into an Nginx container. Result: ~25MB final image instead of 500MB+. That's a 20x reduction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Nginx routing problem&lt;/strong&gt;: This is where I hit my first real head-scratcher. React Router handles client-side routes like &lt;code&gt;/login&lt;/code&gt;, &lt;code&gt;/dashboard&lt;/code&gt;, &lt;code&gt;/employees&lt;/code&gt;. But Express also has a &lt;code&gt;POST /login&lt;/code&gt; API route. If Nginx proxies all &lt;code&gt;/login&lt;/code&gt; requests to Express, the browser can't load the login &lt;em&gt;page&lt;/em&gt; — it gets the API response instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;: Prefix all backend API routes with &lt;code&gt;/api/&lt;/code&gt;. Nginx proxies &lt;code&gt;/api/*&lt;/code&gt; to Express, and serves &lt;code&gt;index.html&lt;/code&gt; for everything else via &lt;code&gt;try_files&lt;/code&gt;. This is the standard SPA deployment pattern:&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;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://backend:5000/&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="p"&gt;}&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;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;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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;try_files&lt;/code&gt; directive is the key — if the file exists (JS, CSS, images), serve it. If not, serve &lt;code&gt;index.html&lt;/code&gt; and let React Router handle the route on the client side.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Docker Compose — Making Local Dev Painless
&lt;/h2&gt;

&lt;p&gt;With both Dockerfiles written, I needed a way to run everything together:&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;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.8'&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql:8.0&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_ROOT_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_PASSWORD:-root_password}&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_DATABASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db_penggajian3&lt;/span&gt;
    &lt;span class="na"&gt;volumes&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/db/schema.sql:/docker-entrypoint-initdb.d/init.sql&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;mysql_data:/var/lib/mysql&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;mysqladmin"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ping"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-h"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;localhost"&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;10s&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;5&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="s"&gt;./Backend&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DB_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
      &lt;span class="na"&gt;DB_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
      &lt;span class="na"&gt;DB_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_PASSWORD:-root_password}&lt;/span&gt;
      &lt;span class="na"&gt;DB_NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db_penggajian3&lt;/span&gt;
      &lt;span class="na"&gt;SESS_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${SESS_SECRET:-dev_secret}&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&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;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="s"&gt;./Frontend&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;80:80"&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;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;mysql_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things to highlight:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Database seeding&lt;/strong&gt;: MySQL's Docker image automatically executes any &lt;code&gt;.sql&lt;/code&gt; file placed in &lt;code&gt;/docker-entrypoint-initdb.d/&lt;/code&gt;. One volume mount and the database is initialized with the full schema.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Health-based startup ordering&lt;/strong&gt;: &lt;code&gt;depends_on&lt;/code&gt; with &lt;code&gt;condition: service_healthy&lt;/code&gt; means the backend won't start until MySQL is actually accepting connections — not just when the container is running. Without this, the backend tries to connect to MySQL before it's ready, crashes, and you waste 10 minutes debugging why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One command&lt;/strong&gt;: &lt;code&gt;docker-compose up&lt;/code&gt; and you have the full app — database, backend, frontend — running locally. That's the whole point. Identical to production.&lt;/p&gt;

&lt;p&gt;I also created a &lt;code&gt;docker-compose.prod.yml&lt;/code&gt; that swaps the local MySQL container for RDS connection strings and pulls pre-built images from ECR instead of building locally.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. The Hardcoded URL Problem — 21 Files of &lt;code&gt;http://localhost:5000&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This one was tedious but important. A simple grep found &lt;code&gt;http://localhost:5000&lt;/code&gt; hardcoded in &lt;strong&gt;21 frontend files&lt;/strong&gt;. Some had &lt;code&gt;const API_URL = 'http://localhost:5000'&lt;/code&gt;, others had it inline in axios calls like &lt;code&gt;axios.get('http://localhost:5000/employees')&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;: Replace every instance with an empty string, making all API URLs relative. Instead of &lt;code&gt;axios.get('http://localhost:5000/api/employees')&lt;/code&gt;, it becomes &lt;code&gt;axios.get('/api/employees')&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For local development without Docker, I added a Vite dev proxy:&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="c1"&gt;// vite.config.js&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;proxy&lt;/span&gt;&lt;span class="p"&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;/api&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;http://localhost:5000&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;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Docker, Nginx handles the proxying. In dev mode, Vite handles it. The frontend code doesn't care — it just hits &lt;code&gt;/api/whatever&lt;/code&gt; and the right thing happens.&lt;/p&gt;

&lt;p&gt;This is a common issue when "Dockerizing" an app that was only ever run in dev mode. The app assumes frontend and backend are on different ports/origins. In a Docker deployment with Nginx as a reverse proxy, they're behind the same origin.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Database Config — No More Hardcoded Credentials
&lt;/h2&gt;

&lt;p&gt;The original &lt;code&gt;Database.js&lt;/code&gt; was a security horror show:&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;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Sequelize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;db_penggajian3&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;root&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="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;localhost&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;dialect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mysql&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hardcoded database name, hardcoded root user, empty password, hardcoded localhost. This works on your laptop. It works nowhere else.&lt;/p&gt;

&lt;p&gt;The fix:&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;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Sequelize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DB_NAME&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;db_penggajian3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DB_USER&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;root&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DB_PASSWORD&lt;/span&gt; &lt;span class="o"&gt;||&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;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DB_HOST&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;localhost&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dialect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mysql&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;Environment variables with sensible defaults. Works locally with defaults, works in Docker with env vars from &lt;code&gt;docker-compose.yml&lt;/code&gt;, works in production with SSM-injected values.&lt;/p&gt;

&lt;p&gt;I also added &lt;code&gt;store.sync()&lt;/code&gt; for the session table. Without this, the express-session Sequelize store doesn't create its table, so login succeeds (credentials are correct) but the session isn't persisted. You log in, get redirected, and immediately get bounced back to the login page because there's no session. That one cost me 30 minutes of staring at network requests before I figured it out.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Adding Tests to a Zero-Test Codebase
&lt;/h2&gt;

&lt;p&gt;The repo had zero automated tests. A &lt;code&gt;request_test/&lt;/code&gt; folder contained empty &lt;code&gt;.rest&lt;/code&gt; files — placeholders that never got filled in.&lt;/p&gt;

&lt;p&gt;My philosophy for this was pragmatic: for CI/CD, you need a quality gate. Not 100% coverage — just enough to catch "the app is fundamentally broken."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backend tests (7 tests with Vitest + Supertest):&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="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Health Endpoint&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;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;returns 200 OK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/health&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;returns valid JSON with status and uptime&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/health&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;status&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;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;uptime&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;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Auth Routes&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;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;returns 401 without session&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/me&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rejects login with empty credentials&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/login&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;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&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;Frontend tests (4 tests with Vitest + jsdom):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;React imports correctly&lt;/li&gt;
&lt;li&gt;Redux store initializes&lt;/li&gt;
&lt;li&gt;React Router imports resolve&lt;/li&gt;
&lt;li&gt;App component renders without crashing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Total: 11 tests. Run time: under 2 seconds.&lt;/strong&gt; Simple, but they catch the things that matter — the app starts, the health endpoint works, authentication is enforced, the frontend builds and renders. If any of these fail, something is seriously wrong.&lt;/p&gt;




&lt;h2&gt;
  
  
  10. CI Pipeline — PR Checks That Actually Block Bad Code
&lt;/h2&gt;

&lt;p&gt;With tests in place, I built the CI pipeline. The philosophy: every PR must pass automated checks before it can be merged. No exceptions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Commitlint&lt;/strong&gt;: I configured &lt;a href="https://commitlint.js.org/" rel="noopener noreferrer"&gt;commitlint&lt;/a&gt; to enforce &lt;a href="https://www.conventionalcommits.org/" rel="noopener noreferrer"&gt;Conventional Commits&lt;/a&gt;. Every commit message must follow the pattern &lt;code&gt;type: description&lt;/code&gt; — &lt;code&gt;feat:&lt;/code&gt;, &lt;code&gt;fix:&lt;/code&gt;, &lt;code&gt;docs:&lt;/code&gt;, &lt;code&gt;chore:&lt;/code&gt;, etc. Bad message format = PR blocked. This isn't just style policing — Semantic Release uses these prefixes to automatically determine version bumps.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;pr-check.yml&lt;/code&gt; workflow runs &lt;strong&gt;4 jobs in parallel&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Commit validation&lt;/strong&gt; — commitlint checks all commit messages in the PR&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend tests&lt;/strong&gt; — install deps, run Vitest&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frontend tests + build&lt;/strong&gt; — install deps, run Vitest, then &lt;code&gt;npm run build&lt;/code&gt; (catches build errors)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker build&lt;/strong&gt; — build both Docker images (catches Dockerfile errors)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then the failures started.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure #1: Missing lock file.&lt;/strong&gt; &lt;code&gt;npm ci&lt;/code&gt; requires &lt;code&gt;package-lock.json&lt;/code&gt; to exist. The original repo had it in &lt;code&gt;.gitignore&lt;/code&gt;. Fix: remove &lt;code&gt;package-lock.json&lt;/code&gt; from &lt;code&gt;.gitignore&lt;/code&gt;, generate and commit the lock file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure #2: Case sensitivity.&lt;/strong&gt; The frontend imported images from &lt;code&gt;Assets/&lt;/code&gt; (capital A). macOS is case-insensitive, so this worked fine locally. GitHub Actions runs on Linux, which is case-sensitive. &lt;code&gt;Assets/&lt;/code&gt; !== &lt;code&gt;assets/&lt;/code&gt;. Fix: &lt;code&gt;git mv Frontend/src/Assets Frontend/src/assets&lt;/code&gt; and update all imports.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure #3: Peer dependency conflicts.&lt;/strong&gt; The project used React 18 with some older Vite plugins that declared peer dependency ranges for React 17. &lt;code&gt;npm ci&lt;/code&gt; fails by default when peer deps conflict. Fix: use &lt;code&gt;npm install --legacy-peer-deps&lt;/code&gt; in CI workflows. Not ideal, but pragmatic.&lt;/p&gt;

&lt;p&gt;Three failures, three fixes, three lessons. This is normal. CI exists to catch exactly these kinds of issues before they reach production.&lt;/p&gt;




&lt;h2&gt;
  
  
  11. AWS — Setting Up the Playground
&lt;/h2&gt;

&lt;p&gt;I set up all AWS infrastructure via CLI commands — no clicking around in the console. Everything reproducible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RDS MySQL (db.t3.micro):&lt;/strong&gt; The production database. Imported the SQL schema. The security group only allows connections from the app EC2's security group — not the public internet. This means even if someone gets the database credentials, they can't connect unless they're on an authorized EC2 instance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ECR (Elastic Container Registry):&lt;/strong&gt; Two repositories — &lt;code&gt;sipeka-backend&lt;/code&gt; and &lt;code&gt;sipeka-frontend&lt;/code&gt;. This is our private Docker registry. GitHub Actions pushes images here; EC2 instances pull from here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EC2 instances (2x t3.small):&lt;/strong&gt; QA and RC environments. Bootstrapped with a userdata script that installs Docker and Docker Compose on first boot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IAM Role:&lt;/strong&gt; The EC2 instances have an IAM role that allows them to pull from ECR and read SSM parameters. No hardcoded AWS keys on the instance. Ever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SSM Parameter Store:&lt;/strong&gt; Database host, user, password (encrypted with KMS), session secret (encrypted). The deploy script fetches these at container startup. Secrets never touch a &lt;code&gt;.env&lt;/code&gt; file, never get committed to git, never appear in Docker image layers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security Groups:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Security Group&lt;/th&gt;
&lt;th&gt;Ports&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;App SG&lt;/td&gt;
&lt;td&gt;22 (SSH), 80 (HTTP), 443 (HTTPS), 5000 (API)&lt;/td&gt;
&lt;td&gt;0.0.0.0/0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RDS SG&lt;/td&gt;
&lt;td&gt;3306 (MySQL)&lt;/td&gt;
&lt;td&gt;App SG only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Cost:&lt;/strong&gt; About $45/month when everything's running. About $2/month with EC2 and RDS stopped. My approach: stop everything when not actively developing or presenting, start it up when needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  12. The Build Pipeline — Merge to Main, Deploy to Production
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;build-push.yml&lt;/code&gt; workflow triggers on every push to &lt;code&gt;main&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Run all tests&lt;/strong&gt; — fail fast. Don't waste 5 minutes building Docker images if a test fails in 2 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build both Docker images&lt;/strong&gt; — backend and frontend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Push to ECR&lt;/strong&gt; — tagged with both the git SHA (unique, traceable) and &lt;code&gt;latest&lt;/code&gt; (for easy pulling).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trigger the infra repo&lt;/strong&gt; — via &lt;code&gt;repository_dispatch&lt;/code&gt; event. This tells the infrastructure repo "hey, there are new images ready to deploy."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Semantic Release&lt;/strong&gt; runs in parallel with the build:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Analyzes all commit messages since the last release&lt;/li&gt;
&lt;li&gt;Determines the version bump: &lt;code&gt;feat:&lt;/code&gt; = minor (v1.0.0 → v1.1.0), &lt;code&gt;fix:&lt;/code&gt; = patch (v1.1.0 → v1.1.1)&lt;/li&gt;
&lt;li&gt;Generates a CHANGELOG entry&lt;/li&gt;
&lt;li&gt;Creates a git tag&lt;/li&gt;
&lt;li&gt;Publishes a GitHub Release&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result: every merge to &lt;code&gt;main&lt;/code&gt; automatically produces versioned Docker images in ECR and a GitHub Release with a changelog. No manual tagging. No "what version is deployed?" conversations. The commit messages &lt;em&gt;are&lt;/em&gt; the release notes.&lt;/p&gt;




&lt;h2&gt;
  
  
  13. The Infra Repo — Why Two Repos?
&lt;/h2&gt;

&lt;p&gt;The project uses two repositories — a source repo (app code) and an infra repo (deployment logic). I split the repos because I didn't want deployment scripts mixed in with application code. A developer adding a feature shouldn't need to touch deployment workflows, and a deployment fix shouldn't require changes to the app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source repo contains:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Application source code&lt;/li&gt;
&lt;li&gt;Dockerfiles and docker-compose files&lt;/li&gt;
&lt;li&gt;CI workflows (PR checks, build &amp;amp; push)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Infra repo contains:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deployment scripts&lt;/li&gt;
&lt;li&gt;Nightly smoke test workflow&lt;/li&gt;
&lt;li&gt;Nginx SSL configuration&lt;/li&gt;
&lt;li&gt;RC promotion workflow&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The nightly workflow&lt;/strong&gt; is the most interesting piece:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Spin up a temporary EC2 instance&lt;/li&gt;
&lt;li&gt;Pull the latest images from ECR&lt;/li&gt;
&lt;li&gt;Run smoke tests — health endpoint returns 200, frontend serves HTML, auth endpoint responds&lt;/li&gt;
&lt;li&gt;If all tests pass, deploy to the QA EC2 instance&lt;/li&gt;
&lt;li&gt;Terminate the temporary instance&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This means the QA environment is automatically updated every night with the latest passing build. If smoke tests fail, nothing gets deployed and the team gets notified.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The deploy script&lt;/strong&gt; does the actual deployment:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;SSH into the target EC2 instance&lt;/li&gt;
&lt;li&gt;Fetch secrets from SSM Parameter Store&lt;/li&gt;
&lt;li&gt;Stop old containers (&lt;code&gt;docker stop&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Pull new images from ECR (&lt;code&gt;docker pull&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Start new containers with environment variables from SSM&lt;/li&gt;
&lt;li&gt;Verify the health endpoint returns 200&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  14. The &lt;code&gt;/login&lt;/code&gt; Bug — When It Hit Me in Production
&lt;/h2&gt;

&lt;p&gt;Remember the Nginx routing problem from Section 5? Here's what it actually looked like when I deployed to EC2 for the first time.&lt;/p&gt;

&lt;p&gt;I navigated to &lt;code&gt;http://&amp;lt;IP&amp;gt;/login&lt;/code&gt; in my browser and got: &lt;code&gt;Cannot GET /login&lt;/code&gt;. The login page worked perfectly in local dev mode, but on EC2, Nginx was forwarding the browser's GET request to Express, which only had a POST handler for &lt;code&gt;/login&lt;/code&gt;. Express returned "Cannot GET /login."&lt;/p&gt;

&lt;p&gt;This is when I went back and added the &lt;code&gt;/api/&lt;/code&gt; prefix to all backend routes, updated all 21 frontend API calls, and fixed the Nginx config. It was the same fix I described earlier, but I'm calling it out separately because this is &lt;strong&gt;the&lt;/strong&gt; classic SPA deployment bug. If you take one thing from this blog: frontend routing and backend API routing must never overlap. Prefix your API routes. Always.&lt;/p&gt;




&lt;h2&gt;
  
  
  15. Domain and SSL
&lt;/h2&gt;

&lt;p&gt;A production deployment needs a real domain and HTTPS. Here's what I did:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Route53 setup:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Created a hosted zone for &lt;code&gt;themanmohan.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Added A records: &lt;code&gt;sipeka.themanmohan.com&lt;/code&gt; → QA EC2 IP, &lt;code&gt;sipeka-rc.themanmohan.com&lt;/code&gt; → RC EC2 IP&lt;/li&gt;
&lt;li&gt;Updated nameservers at the domain registrar to point to Route53's NS records&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;SSL via Let's Encrypt:&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;certbot certonly &lt;span class="nt"&gt;--standalone&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; sipeka.themanmohan.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then updated the Nginx config to serve over HTTPS:&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;sipeka.themanmohan.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;301&lt;/span&gt; &lt;span class="s"&gt;https://&lt;/span&gt;&lt;span class="nv"&gt;$host$request_uri&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&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;443&lt;/span&gt; &lt;span class="s"&gt;ssl&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;sipeka.themanmohan.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;ssl_certificate&lt;/span&gt; &lt;span class="n"&gt;/etc/letsencrypt/live/sipeka.themanmohan.com/fullchain.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_certificate_key&lt;/span&gt; &lt;span class="n"&gt;/etc/letsencrypt/live/sipeka.themanmohan.com/privkey.pem&lt;/span&gt;&lt;span class="p"&gt;;&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://backend:5000/&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="p"&gt;}&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;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;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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;/etc/letsencrypt&lt;/code&gt; directory is mounted into the frontend container as a volume, so the Nginx process inside Docker can read the certificates from the host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auto-renewal:&lt;/strong&gt; A cron job runs at 3 AM, stops Nginx briefly for the renewal (certbot needs port 80), then restarts it. Let's Encrypt certs expire every 90 days, so this keeps things fresh without manual intervention.&lt;/p&gt;




&lt;h2&gt;
  
  
  16. Semantic Release and RC Promotion
&lt;/h2&gt;

&lt;p&gt;Semantic Release is the automation layer that ties commit discipline to version management.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.releaserc.json&lt;/code&gt; configuration chains together plugins:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;commit-analyzer&lt;/strong&gt; — reads commit messages, determines bump type&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;release-notes-generator&lt;/strong&gt; — generates human-readable release notes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;changelog&lt;/strong&gt; — updates &lt;code&gt;CHANGELOG.md&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;git&lt;/strong&gt; — creates the version tag&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;github&lt;/strong&gt; — publishes the GitHub Release&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The math is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Commit with &lt;code&gt;feat:&lt;/code&gt; prefix → &lt;strong&gt;minor&lt;/strong&gt; bump (v1.0.0 → v1.1.0)&lt;/li&gt;
&lt;li&gt;Commit with &lt;code&gt;fix:&lt;/code&gt; prefix → &lt;strong&gt;patch&lt;/strong&gt; bump (v1.1.0 → v1.1.1)&lt;/li&gt;
&lt;li&gt;Commit with &lt;code&gt;BREAKING CHANGE&lt;/code&gt; → &lt;strong&gt;major&lt;/strong&gt; bump (v1.1.1 → v2.0.0)&lt;/li&gt;
&lt;li&gt;Commits with &lt;code&gt;docs:&lt;/code&gt;, &lt;code&gt;chore:&lt;/code&gt;, &lt;code&gt;style:&lt;/code&gt; → &lt;strong&gt;no release&lt;/strong&gt; (they don't affect the running software)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;strong&gt;RC promotion workflow&lt;/strong&gt; ties it all together. When Semantic Release publishes a new version (say &lt;code&gt;v1.1.3&lt;/code&gt;), the &lt;code&gt;release.yml&lt;/code&gt; workflow fires a &lt;code&gt;repository_dispatch&lt;/code&gt; event to the infra repo with the version tag and git SHA. The infra repo's &lt;code&gt;rc-deploy.yml&lt;/code&gt; then:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Retags the image in ECR using the AWS ECR API — copies the manifest from the SHA tag to the version tag (e.g., &lt;code&gt;c450be94...&lt;/code&gt; → &lt;code&gt;v1.1.3&lt;/code&gt;). No docker pull/push needed, just a manifest copy.&lt;/li&gt;
&lt;li&gt;SSHes into the RC EC2 instance&lt;/li&gt;
&lt;li&gt;Pulls the version-tagged image and deploys it&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The key detail: the image in ECR gets tagged with a &lt;strong&gt;human-readable version number&lt;/strong&gt; (like &lt;code&gt;v1.1.3&lt;/code&gt;), not a git SHA. So when I check what's running on RC, I see &lt;code&gt;sipeka-backend:v1.1.3&lt;/code&gt; instead of &lt;code&gt;sipeka-backend:c450be9416e9bb923f1d3a4b&lt;/code&gt;. That's the whole point — traceability.&lt;/p&gt;

&lt;p&gt;Result: &lt;code&gt;sipeka-rc.themanmohan.com&lt;/code&gt; always runs the latest release candidate, with its own SSL certificate, tagged with a proper version number.&lt;/p&gt;




&lt;h2&gt;
  
  
  17. Proving It Works — The Translation PR
&lt;/h2&gt;

&lt;p&gt;All this infrastructure means nothing if I can't demonstrate the full cycle working end-to-end. So I created a real feature branch.&lt;/p&gt;

&lt;p&gt;The original app was entirely in Indonesian — all UI labels, button text, page titles, form placeholders. I created a branch called &lt;code&gt;feat/english-translation&lt;/code&gt; and translated the UI across 26 files.&lt;/p&gt;

&lt;p&gt;The commit message: &lt;code&gt;feat: translate UI from Indonesian to English&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;I opened &lt;strong&gt;PR #1&lt;/strong&gt;. Within seconds, four checks kicked off:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Commitlint&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend Tests&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frontend Tests + Build&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker Build&lt;/td&gt;
&lt;td&gt;PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I merged the PR. Here's what happened automatically:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;build-push.yml&lt;/code&gt; triggered&lt;/li&gt;
&lt;li&gt;Tests ran and passed&lt;/li&gt;
&lt;li&gt;Docker images were built and pushed to ECR with the git SHA tag&lt;/li&gt;
&lt;li&gt;Semantic Release analyzed the commit: &lt;code&gt;feat:&lt;/code&gt; prefix → minor bump → created &lt;strong&gt;v1.1.0&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;GitHub Release published with auto-generated release notes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;repository_dispatch&lt;/code&gt; triggered the infra repo&lt;/li&gt;
&lt;li&gt;New images deployed to QA EC2&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I opened my browser, navigated to &lt;code&gt;https://sipeka.themanmohan.com&lt;/code&gt; — English UI, SSL padlock icon, login works, dashboard loads with employee data.&lt;/p&gt;

&lt;p&gt;From commit to production. Fully automated. Zero manual steps.&lt;/p&gt;




&lt;h2&gt;
  
  
  18. Things That Broke and What I Learned
&lt;/h2&gt;

&lt;p&gt;No project goes smoothly. Here's every significant failure I encountered and how I resolved it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;Assets&lt;/code&gt; vs &lt;code&gt;assets&lt;/code&gt; saga:&lt;/strong&gt; macOS filesystems are case-insensitive by default. I created the project on my Mac, where &lt;code&gt;import logo from './Assets/logo.png'&lt;/code&gt; works fine. GitHub Actions runs on Ubuntu (case-sensitive), where &lt;code&gt;Assets/&lt;/code&gt; ≠ &lt;code&gt;assets/&lt;/code&gt;. CI caught it immediately. Fix: &lt;code&gt;git mv Frontend/src/Assets Frontend/src/assets&lt;/code&gt; and update all imports. Lesson: always test on Linux-like environments. Or better yet, let CI do it for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missing lock files:&lt;/strong&gt; &lt;code&gt;npm ci&lt;/code&gt; — the command you should use in CI instead of &lt;code&gt;npm install&lt;/code&gt; — requires &lt;code&gt;package-lock.json&lt;/code&gt; to exist. The original repo had it in &lt;code&gt;.gitignore&lt;/code&gt; (a common but incorrect practice). Fix: remove it from &lt;code&gt;.gitignore&lt;/code&gt;, run &lt;code&gt;npm install&lt;/code&gt; to generate it, commit it. Lesson: lock files should always be committed. They guarantee reproducible builds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Peer dependency hell:&lt;/strong&gt; The project used React 18 with some Vite plugins that hadn't updated their peer dependency ranges. &lt;code&gt;npm ci&lt;/code&gt; fails by default when peer deps don't match. Fix: &lt;code&gt;npm install --legacy-peer-deps&lt;/code&gt; in CI workflows. Not a permanent fix, but pragmatic. Lesson: peer dependency management in the npm ecosystem is a mess. Accept it and move on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Session table missing:&lt;/strong&gt; The Express session store (using &lt;code&gt;connect-session-sequelize&lt;/code&gt;) needs &lt;code&gt;store.sync()&lt;/code&gt; to create its database table. Without it, the session store has nowhere to write. Login succeeds (the credentials are valid), the session is created in memory, but it's never persisted. The next request has no session. You're logged out instantly. Fix: add &lt;code&gt;store.sync()&lt;/code&gt; after creating the session store. Lesson: read the docs for every middleware you use. The defaults are rarely sufficient.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shell variable expansion:&lt;/strong&gt; Argon2 password hashes contain &lt;code&gt;$&lt;/code&gt; characters (like &lt;code&gt;$argon2id$v=19$m=65536...&lt;/code&gt;). When I passed these through bash as environment variables, the shell interpreted &lt;code&gt;$v&lt;/code&gt;, &lt;code&gt;$m&lt;/code&gt;, etc. as variable references and replaced them with empty strings. The hash was corrupted, and no password would ever match. Fix: hash passwords inside the Docker container using a Node.js script, not through bash. Lesson: never pass values containing &lt;code&gt;$&lt;/code&gt; through shell variable expansion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forked repo permissions:&lt;/strong&gt; GitHub Actions' &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; has limited permissions on forked repositories. Semantic Release needs to push tags and create releases, which the default token can't do on forks. Fix: create a Personal Access Token (PAT) with the necessary permissions and add it as a repository secret. Lesson: forked repos have different permission models than original repos. Plan for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The nightly build double failure:&lt;/strong&gt; The first time the nightly workflow ran, it failed with two simultaneous errors. &lt;code&gt;Unable to locate credentials&lt;/code&gt; — the temp EC2 couldn't talk to AWS. And &lt;code&gt;permission denied while trying to connect to the Docker daemon socket&lt;/code&gt; — every &lt;code&gt;docker&lt;/code&gt; command was rejected. Two errors, two root causes, one workflow run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The credentials fix:&lt;/strong&gt; I was launching the temp EC2 with &lt;code&gt;aws ec2 run-instances&lt;/code&gt; but never attached an IAM instance profile. The QA and RC instances had &lt;code&gt;sipeka-ec2-role&lt;/code&gt; attached, but I forgot to add &lt;code&gt;--iam-instance-profile Name=sipeka-ec2-profile&lt;/code&gt; for the temp instance. No IAM role = no AWS credentials = can't pull from ECR or read SSM secrets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Docker fix:&lt;/strong&gt; The EC2 userdata script runs &lt;code&gt;usermod -aG docker ubuntu&lt;/code&gt; to add the user to the docker group. But group membership changes only take effect on the &lt;strong&gt;next login session&lt;/strong&gt;. The userdata runs during boot, SSH connects moments later — that session was established &lt;em&gt;before&lt;/em&gt; the group change propagated. Fix: added &lt;code&gt;sudo chmod 666 /var/run/docker.sock&lt;/code&gt; at the start of the deploy script.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The stale smoke test:&lt;/strong&gt; After fixing the first two issues, the nightly build ran again — temp EC2 booted, Docker worked, images pulled, containers started. But the smoke test still failed: &lt;code&gt;Auth Endpoint (/me) - expected 401, got 404&lt;/code&gt;. The smoke test was hitting &lt;code&gt;/me&lt;/code&gt; but the backend routes had been prefixed with &lt;code&gt;/api/&lt;/code&gt; weeks earlier. The smoke test script was never updated to match. Fix: changed &lt;code&gt;http://$HOST:5000/me&lt;/code&gt; to &lt;code&gt;http://$HOST:5000/api/me&lt;/code&gt; in the smoke test. Lesson: when you change API routes, grep your &lt;em&gt;entire&lt;/em&gt; project for the old paths — including test scripts, deployment scripts, and monitoring checks. Not just the frontend.&lt;/p&gt;




&lt;h2&gt;
  
  
  19. Architecture Summary and Quick Reference
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Final Architecture
&lt;/h3&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.amazonaws.com%2Fuploads%2Farticles%2Fsprs2g5rl5ybmmxdg10b.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.amazonaws.com%2Fuploads%2Farticles%2Fsprs2g5rl5ybmmxdg10b.png" alt=" " width="800" height="464"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  AWS Resources
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Monthly Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;QA EC2&lt;/td&gt;
&lt;td&gt;t3.small&lt;/td&gt;
&lt;td&gt;QA environment&lt;/td&gt;
&lt;td&gt;~$15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RC EC2&lt;/td&gt;
&lt;td&gt;t3.small&lt;/td&gt;
&lt;td&gt;Release candidate environment&lt;/td&gt;
&lt;td&gt;~$15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RDS MySQL&lt;/td&gt;
&lt;td&gt;db.t3.micro&lt;/td&gt;
&lt;td&gt;Production database&lt;/td&gt;
&lt;td&gt;~$13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ECR&lt;/td&gt;
&lt;td&gt;2 repositories&lt;/td&gt;
&lt;td&gt;Docker image registry&lt;/td&gt;
&lt;td&gt;~$1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Route53&lt;/td&gt;
&lt;td&gt;Hosted zone&lt;/td&gt;
&lt;td&gt;DNS management&lt;/td&gt;
&lt;td&gt;~$0.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSM&lt;/td&gt;
&lt;td&gt;Parameter Store&lt;/td&gt;
&lt;td&gt;Secrets management&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total (running)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$45&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total (stopped)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$2&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  GitHub Secrets Checklist
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Secret&lt;/th&gt;
&lt;th&gt;Used In&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;&lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;build-push.yml, nightly.yml&lt;/td&gt;
&lt;td&gt;ECR and AWS authentication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;build-push.yml, nightly.yml&lt;/td&gt;
&lt;td&gt;ECR and AWS authentication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AWS_ACCOUNT_ID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;build-push.yml, nightly.yml&lt;/td&gt;
&lt;td&gt;ECR repository URI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;INFRA_REPO_PAT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;release.yml, build-push.yml&lt;/td&gt;
&lt;td&gt;Semantic Release + repo dispatch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EC2_SSH_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;nightly.yml, rc-deploy.yml&lt;/td&gt;
&lt;td&gt;SSH into EC2 instances&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;QA_EC2_HOST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;nightly.yml&lt;/td&gt;
&lt;td&gt;QA instance IP/hostname&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RC_EC2_HOST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;rc-deploy.yml&lt;/td&gt;
&lt;td&gt;RC instance IP/hostname&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SG_ID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;nightly.yml&lt;/td&gt;
&lt;td&gt;Security group for temp EC2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SUBNET_ID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;nightly.yml&lt;/td&gt;
&lt;td&gt;Subnet for temp EC2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Useful Commands
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Start/stop EC2 instances&lt;/span&gt;
aws ec2 start-instances &lt;span class="nt"&gt;--instance-ids&lt;/span&gt; i-0521e182a7edf0629   &lt;span class="c"&gt;# QA&lt;/span&gt;
aws ec2 stop-instances &lt;span class="nt"&gt;--instance-ids&lt;/span&gt; i-0521e182a7edf0629

&lt;span class="c"&gt;# Start/stop RDS&lt;/span&gt;
aws rds start-db-instance &lt;span class="nt"&gt;--db-instance-identifier&lt;/span&gt; sipeka-db
aws rds stop-db-instance &lt;span class="nt"&gt;--db-instance-identifier&lt;/span&gt; sipeka-db

&lt;span class="c"&gt;# SSH into QA&lt;/span&gt;
ssh &lt;span class="nt"&gt;-i&lt;/span&gt; manmohan.pem ubuntu@54.186.118.251

&lt;span class="c"&gt;# Check what's running&lt;/span&gt;
docker ps
docker logs backend &lt;span class="nt"&gt;--tail&lt;/span&gt; 100

&lt;span class="c"&gt;# Verify deployment&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://sipeka.themanmohan.com/health | jq &lt;span class="nb"&gt;.&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://sipeka-rc.themanmohan.com/health | jq &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;This project took a zero-infrastructure open-source app and turned it into a fully automated, containerized, CI/CD-enabled, SSL-secured, semantically versioned deployment on AWS. Every piece exists for a reason — Docker for portability, Compose for local parity, GitHub Actions for automation, ECR for image storage, SSM for secrets, Route53 for DNS, Let's Encrypt for SSL, Semantic Release for versioning.&lt;/p&gt;

&lt;p&gt;The biggest lesson? &lt;strong&gt;Nothing works on the first try.&lt;/strong&gt; Case sensitivity bugs, missing lock files, shell variable corruption, route collisions — every step had a surprise. The value of CI/CD isn't that it prevents these problems. It's that it catches them &lt;em&gt;before your users do&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;If you're building something similar, my advice is simple: start with Docker (make it portable), add a health endpoint (make it observable), write a few tests (make it verifiable), then build the pipeline around it. Everything else is just connecting the dots.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;Source Repository: &lt;a href="https://github.com/manmohan659/sipeka" rel="noopener noreferrer"&gt;github.com/manmohan659/sipeka&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Infrastructure Repository: &lt;a href="https://github.com/manmohan659/sipeka-infra" rel="noopener noreferrer"&gt;github.com/manmohan659/sipeka-infra&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Live QA: &lt;a href="https://sipeka.themanmohan.com" rel="noopener noreferrer"&gt;sipeka.themanmohan.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Live RC: &lt;a href="https://sipeka-rc.themanmohan.com" rel="noopener noreferrer"&gt;sipeka-rc.themanmohan.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

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