<?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>Forem: Odoworitse Afari</title>
    <description>The latest articles on Forem by Odoworitse Afari (@odoworitse_afari_1cbfd3f4).</description>
    <link>https://forem.com/odoworitse_afari_1cbfd3f4</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%2F3720649%2F3b139d1b-3388-4f1a-bd11-a5207638d74c.jpg</url>
      <title>Forem: Odoworitse Afari</title>
      <link>https://forem.com/odoworitse_afari_1cbfd3f4</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/odoworitse_afari_1cbfd3f4"/>
    <language>en</language>
    <item>
      <title># Building a Three-Tier Architecture on Azure</title>
      <dc:creator>Odoworitse Afari</dc:creator>
      <pubDate>Sat, 14 Mar 2026 00:10:26 +0000</pubDate>
      <link>https://forem.com/odoworitse_afari_1cbfd3f4/-building-a-three-tier-architecture-on-azure-3hif</link>
      <guid>https://forem.com/odoworitse_afari_1cbfd3f4/-building-a-three-tier-architecture-on-azure-3hif</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Some assignments teach you tools. This one taught me how to think.&lt;/p&gt;

&lt;p&gt;As part of DMI Cohort 2, I was tasked with deploying the Book Review App — a Next.js frontend, Node.js backend, and MySQL database — in a fully production-style three-tier architecture on Azure. No step-by-step guide. Just the requirements, Azure documentation, and whatever problem-solving I could bring.&lt;/p&gt;

&lt;p&gt;This post covers what I built, the real challenges I hit, and the mental shift this assignment forced.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Is a Three-Tier Architecture?
&lt;/h2&gt;

&lt;p&gt;A three-tier architecture separates an application into three distinct layers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Web Tier&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Serves the frontend. Handles HTTP requests from users.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;App Tier&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Business logic. Processes requests, talks to the database.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Database Tier&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Where the data lives. Never exposed to the internet.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each tier lives in its own subnet with its own security rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Web tier → can talk to App tier&lt;/li&gt;
&lt;li&gt;App tier → can talk to Database tier&lt;/li&gt;
&lt;li&gt;Nothing else&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This isolation means a breach in one layer doesn't automatically compromise the others.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Designing the VNet and Subnets
&lt;/h2&gt;

&lt;p&gt;I created a custom VNet with the CIDR block &lt;code&gt;10.0.0.0/20&lt;/code&gt;. The assignment called for &lt;code&gt;6&lt;/code&gt; subnets across &lt;code&gt;2&lt;/code&gt; Availability Zones, but my Azure free subscription had quota limits — so I provisioned &lt;code&gt;4&lt;/code&gt; subnets and documented the constraint honestly.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Subnet&lt;/th&gt;
&lt;th&gt;CIDR&lt;/th&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;web-public&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10.0.2.0/24&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Web (public)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app-vm-1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10.0.6.0/24&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;App (private)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;db-vm-1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10.0.10.0/24&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Database (private)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;db-vm-2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10.0.12.0/24&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Database (private)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key principle: &lt;strong&gt;only the web subnet is public.&lt;/strong&gt; Everything else is private and unreachable from the internet directly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: NSGs and Load Balancers
&lt;/h2&gt;

&lt;p&gt;I configured chained NSGs to enforce strict traffic rules across tiers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Web Tier NSG:&lt;/strong&gt; Allow inbound HTTP on Port &lt;code&gt;80&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App Tier NSG:&lt;/strong&gt; Allow inbound Port &lt;code&gt;3001&lt;/code&gt; — only from the Web Tier subnet (&lt;code&gt;10.0.2.0/24&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DB Tier NSG:&lt;/strong&gt; Allow inbound Port &lt;code&gt;3306&lt;/code&gt; — only from the App Tier subnet (&lt;code&gt;10.0.6.0/23&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a &lt;strong&gt;zero-trust internal network&lt;/strong&gt;. Each layer only trusts traffic from the layer directly above it.&lt;/p&gt;

&lt;p&gt;I then deployed two load balancers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Public Load Balancer&lt;/strong&gt; — Faces the internet, routes traffic to Web Tier VMs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal Load Balancer&lt;/strong&gt; — Lives inside the VNet, routes traffic from Web Tier to App Tier (frontend IP: &lt;code&gt;10.0.6.100&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The internal load balancer is the piece most people overlook. It means the App Tier &lt;strong&gt;never needs a public IP&lt;/strong&gt;. The traffic flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Internet → Public LB → Web VM → Internal LB → App VM → Database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At no point does the backend touch the public internet.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: VM Deployment and the Quota Problem
&lt;/h2&gt;

&lt;p&gt;This is where things got interesting.&lt;/p&gt;

&lt;p&gt;I deployed Ubuntu VMs for both tiers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Web Tier:&lt;/strong&gt; Next.js behind Nginx on Port &lt;code&gt;80&lt;/code&gt;, in the public subnet&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App Tier:&lt;/strong&gt; Node.js on Port &lt;code&gt;3001&lt;/code&gt;, in private subnets with no public internet access&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Midway through, I hit a &lt;strong&gt;&lt;code&gt;0&lt;/code&gt; vCPU quota limit&lt;/strong&gt; on my Azure free subscription. The portal blocked me from provisioning the next VM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Pivot to &lt;code&gt;Standard_B1s&lt;/code&gt; instances, which consumed fewer quota units and unblocked me.&lt;/p&gt;

&lt;p&gt;Then I hit the next problem. The App Tier VM sits in a private subnet — which is correct by design, but means you can't SSH into it directly from your local machine. The managed Azure Bastion service failed to connect in my environment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Build a manual jump-box. I used the Web Tier VM as a stepping stone:&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;# Step 1 — From local machine into the Web VM (public)&lt;/span&gt;
ssh &lt;span class="nt"&gt;-i&lt;/span&gt; web-vm-key.pem azureuser@&amp;lt;web-vm-public-ip&amp;gt;

&lt;span class="c"&gt;# Step 2 — From Web VM into the App VM (private IP only)&lt;/span&gt;
ssh &lt;span class="nt"&gt;-i&lt;/span&gt; mvn.pem azureuser@10.0.6.4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a classic production pattern. You never expose backend servers directly — you always go through a bastion or jump-box. I just had to build mine manually instead of using the managed service.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Database — Private, Redundant, and Ready
&lt;/h2&gt;

&lt;p&gt;I provisioned an Azure Database for MySQL Flexible Server entirely within the private database subnets with the following configuration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Multi-AZ&lt;/strong&gt; enabled for high availability&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read replica&lt;/strong&gt; configured for read scaling&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VNet integration&lt;/strong&gt; — no public endpoint, zero internet exposure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The database is invisible to the outside world. The only machines that can reach it are App Tier VMs within the designated subnet (&lt;code&gt;10.0.10.0/24&lt;/code&gt; and &lt;code&gt;10.0.12.0/24&lt;/code&gt;).&lt;/p&gt;




&lt;h2&gt;
  
  
  The Moment It Worked
&lt;/h2&gt;

&lt;p&gt;After the quota limits, the Bastion failure, and the jump-box setup — I opened a browser and navigated to the Web Tier's public IP: &lt;code&gt;20.227.32.150&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The Book Review App loaded. &lt;em&gt;The Pragmatic Programmer. Clean Code. JavaScript: The Good Parts.&lt;/em&gt; All pulling live data from a database sitting in a private subnet that the internet cannot touch.&lt;/p&gt;

&lt;p&gt;That moment made all the friction worth it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Troubleshooting is the job.&lt;/strong&gt;&lt;br&gt;
The &lt;code&gt;0&lt;/code&gt; vCPU quota limit and the Bastion failure weren't obstacles to the assignment — they were the assignment. Real infrastructure work is &lt;code&gt;40%&lt;/code&gt; planning and &lt;code&gt;60%&lt;/code&gt; adapting to what actually happens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Constraints force better decisions.&lt;/strong&gt;&lt;br&gt;
Not having &lt;code&gt;6&lt;/code&gt; subnets didn't make my architecture wrong. It made me document the tradeoff and move forward. That's a professional skill.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Private networking is a mindset, not a feature.&lt;/strong&gt;&lt;br&gt;
Once you understand &lt;em&gt;why&lt;/em&gt; each tier is isolated, the subnets and NSGs stop feeling like configuration work. They become decisions about trust — and trust is what security is built on.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;The AWS portion of this project is coming up next.&lt;/p&gt;

&lt;p&gt;After that, I'll be moving into &lt;strong&gt;Agentic AI for DevOps&lt;/strong&gt; — exploring how AI agents are starting to reshape how infrastructure is managed, automated, and reasoned about. It's the next frontier for this field, and I want to understand it deeply before it becomes the standard.&lt;/p&gt;

&lt;p&gt;Follow along: &lt;a href="https://linkedin.com/in/odoworitse-afari" rel="noopener noreferrer"&gt;linkedin.com/in/odoworitse-afari&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post is part of my DMI Cohort 2 learning journey with Pravin Mishra — CloudAdvisory.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>azure</category>
      <category>devops</category>
      <category>cloud</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Deploying a Full-Stack App on Azure: What I Learned Connecting a VM to a Private MySQL Database</title>
      <dc:creator>Odoworitse Afari</dc:creator>
      <pubDate>Sat, 14 Mar 2026 00:10:10 +0000</pubDate>
      <link>https://forem.com/odoworitse_afari_1cbfd3f4/deploying-a-full-stack-app-on-azure-what-i-learned-connecting-a-vm-to-a-private-mysql-database-56gh</link>
      <guid>https://forem.com/odoworitse_afari_1cbfd3f4/deploying-a-full-stack-app-on-azure-what-i-learned-connecting-a-vm-to-a-private-mysql-database-56gh</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;There's a difference between reading about cloud architecture and actually building it.&lt;/p&gt;

&lt;p&gt;This week, as part of my DevOps Micro Internship (DMI Cohort 2), I deployed the EpicBook web application on Microsoft Azure — a full-stack app with a React frontend, Node.js backend, and a MySQL database. What made this assignment different from previous ones wasn't just the tools. It was the deliberate security decisions I had to make at every step.&lt;/p&gt;

&lt;p&gt;This post walks through what I built, the decisions I made, and what actually clicked for me along the way.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;A two-tier deployment on Azure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Compute layer:&lt;/strong&gt; Ubuntu &lt;code&gt;22.04&lt;/code&gt; VM (&lt;code&gt;Standard B1s&lt;/code&gt;) running the EpicBook app with Nginx as a reverse proxy&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database layer:&lt;/strong&gt; Azure Database for MySQL Flexible Server, placed in a private subnet with no public internet access&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal was to get the app live and accessible via the VM's public IP, while keeping the database completely hidden from the outside world.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Designing the Network
&lt;/h2&gt;

&lt;p&gt;Before provisioning anything, I set up the network foundation.&lt;/p&gt;

&lt;p&gt;I created a Virtual Network (VNet) with the address space &lt;code&gt;10.0.0.0/16&lt;/code&gt;, then carved it into two subnets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;10.0.1.0/24&lt;/code&gt; — Public subnet for the VM&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;10.0.2.0/24&lt;/code&gt; — Private subnet for the MySQL database&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This separation is the core of secure cloud architecture. The public subnet is where traffic enters. The private subnet is where sensitive data lives — and it should never be directly reachable from the internet.&lt;/p&gt;

&lt;p&gt;I then attached Network Security Groups (NSGs) to enforce the rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Public subnet NSG:&lt;/strong&gt; Allow HTTP (Port &lt;code&gt;80&lt;/code&gt;) and SSH (Port &lt;code&gt;22&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private subnet NSG:&lt;/strong&gt; Allow MySQL (Port &lt;code&gt;3306&lt;/code&gt;) &lt;strong&gt;only from the VM's subnet&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last rule is important. It means even if someone somehow reached the private subnet, they couldn't connect to MySQL unless they were coming from the application VM itself.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Provisioning the VM and Installing Dependencies
&lt;/h2&gt;

&lt;p&gt;I deployed the Ubuntu VM into the public subnet, assigned a public IP, and SSHed in.&lt;/p&gt;

&lt;p&gt;Then I installed everything the app needed:&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="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="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;nodejs npm nginx git mysql-client &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I verified the installations before moving forward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;-v&lt;/span&gt;
npm &lt;span class="nt"&gt;-v&lt;/span&gt;
git &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A small habit that saves a lot of debugging later.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Deploying the EpicBook Application
&lt;/h2&gt;

&lt;p&gt;I cloned the repository and installed dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/pravinmishraaws/theepicbook.git
&lt;span class="nb"&gt;cd &lt;/span&gt;theepicbook
npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I configured Nginx as a reverse proxy. The key configuration line that matters for React apps is inside the Nginx server block:&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;_&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;/home/azureuser/theepicbook/build&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="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="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 $uri /index.html;&lt;/code&gt; line ensures React Router works correctly. Without it, refreshing any route other than &lt;code&gt;/&lt;/code&gt; returns a &lt;code&gt;404&lt;/code&gt; — because Nginx looks for a physical file that doesn't exist instead of handing control back to React.&lt;/p&gt;

&lt;p&gt;I also used environment variables to pass the database credentials to the Node.js backend. No hardcoded passwords in the codebase.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Setting Up the Private Database
&lt;/h2&gt;

&lt;p&gt;I provisioned an Azure Database for MySQL Flexible Server using &lt;strong&gt;Private Access (VNet Integration)&lt;/strong&gt;, placing it directly inside the private subnet.&lt;/p&gt;

&lt;p&gt;This means the database has no public endpoint. The only way to reach it is from within the VNet — specifically from the application VM.&lt;/p&gt;

&lt;p&gt;I imported the SQL dump to initialize the schema, then tested the connection from the VM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mysql &lt;span class="nt"&gt;-h&lt;/span&gt; epicbook-db1.mysql.database.azure.com &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;-u&lt;/span&gt; admin123 &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;-p&lt;/span&gt; bookstore &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"SELECT * FROM author LIMIT 5;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seeing the author records return in the terminal confirmed the entire chain was working:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VM → private subnet → MySQL → data ✓
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 5: Final Security Hardening
&lt;/h2&gt;

&lt;p&gt;One last step beyond the basics: I restricted SSH access in the NSG to my specific local IP address only. This means Port &lt;code&gt;22&lt;/code&gt; is no longer open to the entire internet — only my machine can SSH into the VM.&lt;/p&gt;

&lt;p&gt;I also verified the internal Linux firewall (&lt;code&gt;ufw&lt;/code&gt;) was not interfering with web traffic:&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;ufw status
&lt;span class="c"&gt;# Status: inactive&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Network design happens before deployment, not during.&lt;/strong&gt;&lt;br&gt;
VNets and subnets are not setup steps to rush through. They are the architecture. Everything else builds on top of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Private access is not optional for databases.&lt;/strong&gt;&lt;br&gt;
Putting a database in a public subnet with a public endpoint is a real risk that real companies have paid for. VNet integration and NSG rules are how you protect data properly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Environment variables are a non-negotiable habit.&lt;/strong&gt;&lt;br&gt;
Hardcoding credentials — even temporarily — is a risk. Using &lt;code&gt;.env&lt;/code&gt; files from the start costs nothing and prevents a lot.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Next up: the AWS portion of this project, and then I'm moving into Agentic AI for DevOps — specifically how AI agents are changing the way infrastructure is managed and automated.&lt;/p&gt;

&lt;p&gt;If you're also on the DevOps learning path, feel free to connect: &lt;a href="https://linkedin.com/in/odoworitse-afari" rel="noopener noreferrer"&gt;linkedin.com/in/odoworitse-afari&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post is part of my DMI Cohort 2 learning journey with Pravin Mishra — CloudAdvisory.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>azure</category>
      <category>devops</category>
      <category>cloud</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I Ran My First Real Agile Sprint and Shipped to Production Every Day for 5 Days</title>
      <dc:creator>Odoworitse Afari</dc:creator>
      <pubDate>Thu, 12 Mar 2026 16:42:11 +0000</pubDate>
      <link>https://forem.com/odoworitse_afari_1cbfd3f4/i-ran-my-first-real-agile-sprint-and-shipped-to-production-every-day-for-5-days-2d3i</link>
      <guid>https://forem.com/odoworitse_afari_1cbfd3f4/i-ran-my-first-real-agile-sprint-and-shipped-to-production-every-day-for-5-days-2d3i</guid>
      <description>&lt;h2&gt;
  
  
  &lt;em&gt;Here's what happened when I stopped reading about DevOps and actually did it&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;I've read dozens of articles about Agile sprints. Watched YouTube tutorials on Jira. Taken notes on CI/CD workflows.&lt;/p&gt;

&lt;p&gt;But until last week, I'd never actually run a sprint from start to finish.&lt;/p&gt;

&lt;p&gt;My internship at CloudAdvisory changed that. The assignment: build and deploy a footer to a portfolio website. Simple feature, right? Add version number, deployment date, author name. Done.&lt;/p&gt;

&lt;p&gt;Except the real assignment wasn't the footer. It was running a proper 5-day sprint, planning in Jira, committing daily, deploying to EC2, and documenting everything like a production team would.&lt;/p&gt;

&lt;p&gt;Here's what I learned shipping code every single day.&lt;/p&gt;




&lt;h2&gt;
  
  
  Day 0: Planning (The Part Everyone Skips)
&lt;/h2&gt;

&lt;p&gt;Most developers jump straight to coding. I used to do that too. Open VS Code, start typing, figure it out as I go.&lt;/p&gt;

&lt;p&gt;That's not how real teams work.&lt;/p&gt;

&lt;p&gt;I opened Jira and created a story: "Add footer with version and deploy date." Then I wrote acceptance criteria in Gherkin format—basically a checklist that defines "done":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Given I'm on the portfolio homepage
When I scroll to the footer
Then I should see:
  - Version number (v1.0)
  - Dynamic deploy date (today's date)
  - Author name (Odoworitse Afari)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I broke it into 5 daily subtasks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Day 1: Build the footer, commit, deploy&lt;/li&gt;
&lt;li&gt;Day 2: Make the date dynamic (no hardcoding)&lt;/li&gt;
&lt;li&gt;Day 3: Polish the UI, test on mobile&lt;/li&gt;
&lt;li&gt;Day 4: Update homepage tagline&lt;/li&gt;
&lt;li&gt;Day 5: Demo and retrospective&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;📸&lt;br&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%2F5ss30ylgr45bmtre2xjk.jpg" 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%2F5ss30ylgr45bmtre2xjk.jpg" alt="Jira sprint board showing story with 5 daily subtasks and sprint goal" width="800" height="392"&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I set a sprint goal: "Ship a visible footer to EC2 with daily progress documented in Jira."&lt;/p&gt;

&lt;p&gt;Then I clicked "Start Sprint."&lt;/p&gt;

&lt;p&gt;The clock started ticking.&lt;/p&gt;


&lt;h2&gt;
  
  
  Day 1: First Deployment (It's Not Just &lt;code&gt;git push&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;I cloned the repo and created a feature branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/pravinmishraaws/Pravin-Mishra-Portfolio
&lt;span class="nb"&gt;cd &lt;/span&gt;Pravin-Mishra-Portfolio
git checkout &lt;span class="nt"&gt;-b&lt;/span&gt; feature/footer-v1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Added the footer to &lt;code&gt;index.html&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;footer&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Pravin Mishra Portfolio v1.0 — Deployed on 6 Feb 2026 — By Odoworitse Afari&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/footer&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git add &lt;span class="nb"&gt;.&lt;/span&gt;
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"feat(footer): add version, deploy date, and author"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then came the reality check: deploying to EC2 isn't &lt;code&gt;git push&lt;/code&gt;. It's manual work.&lt;/p&gt;

&lt;p&gt;I had to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Copy files to the server using SCP&lt;/li&gt;
&lt;li&gt;SSH into the EC2 instance&lt;/li&gt;
&lt;li&gt;Move files to Nginx's web root&lt;/li&gt;
&lt;li&gt;Reload Nginx&lt;/li&gt;
&lt;li&gt;Verify the site is live
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scp &lt;span class="nt"&gt;-i&lt;/span&gt; my-key.pem &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; ubuntu@44.193.203.233:/tmp/portfolio
ssh &lt;span class="nt"&gt;-i&lt;/span&gt; my-key.pem ubuntu@44.193.203.233
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; /tmp/portfolio/&lt;span class="k"&gt;*&lt;/span&gt; /var/www/html/
&lt;span class="nb"&gt;sudo &lt;/span&gt;nginx &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;p&gt;&lt;strong&gt;📸&lt;br&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%2Fp0j19m9phxq6jt8ykwfs.jpg" 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%2Fp0j19m9phxq6jt8ykwfs.jpg" alt="Jira sprint board showing story titled 'Add footer with version and deploy date' with 5 daily subtasks and sprint goal" width="800" height="410"&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When I opened the site and saw my footer live on a public IP address, it hit different. This wasn't localhost. Real people could see this.&lt;/p&gt;

&lt;p&gt;I went back to Jira and moved Day 1 to "Done." Added a scrum comment: "Shipped footer v1 to production. Live at &lt;a href="http://44.193.203.233" rel="noopener noreferrer"&gt;http://44.193.203.233&lt;/a&gt;"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;📸 &lt;br&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%2Fabxa4no9uhq77infboew.jpg" 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%2Fabxa4no9uhq77infboew.jpg" alt="Jira board with Day 1 subtask moved to Done column with scrum comment" width="800" height="390"&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I learned:&lt;/strong&gt; Shipping to production on Day 1 changes everything. It's not theory anymore. You have to verify it works, test it in a browser, make sure nothing broke. That's the difference between coding and DevOps.&lt;/p&gt;


&lt;h2&gt;
  
  
  Day 2: Making It Actually Dynamic
&lt;/h2&gt;

&lt;p&gt;The footer from Day 1 had a problem: the date was hardcoded. "Deployed on 1 Feb 2026."&lt;/p&gt;

&lt;p&gt;That's fine if you never deploy again. But in real DevOps, you deploy multiple times a day. You can't manually update the date every time. That defeats the whole point of automation.&lt;/p&gt;

&lt;p&gt;I replaced the static date with JavaScript that generates today's date automatically:&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;footer&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;Pravin&lt;/span&gt; &lt;span class="nx"&gt;Mishra&lt;/span&gt; &lt;span class="nx"&gt;Portfolio&lt;/span&gt; &lt;span class="nx"&gt;v1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; 
    &lt;span class="nx"&gt;Deployed&lt;/span&gt; &lt;span class="nx"&gt;on&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;span&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deploy-date&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/span&amp;gt; —&lt;/span&gt;&lt;span class="err"&gt; 
&lt;/span&gt;    &lt;span class="nx"&gt;By&lt;/span&gt; &lt;span class="nx"&gt;Odoworitse&lt;/span&gt; &lt;span class="nx"&gt;Afari&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/p&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/footer&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;today&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;Date&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;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;day&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;numeric&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;month&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;short&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;year&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;numeric&lt;/span&gt;&lt;span class="dl"&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;formattedDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;today&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLocaleDateString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-GB&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;deploy-date&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;formattedDate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/script&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every page load shows the current date. No manual updates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;📸&lt;br&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%2Fioypo103xvqogycoypvd.jpg" 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%2Fioypo103xvqogycoypvd.jpg" alt="Portfolio website footer displaying version 1.0, deploy date, and author name on live EC2 server" width="800" height="409"&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I also updated the README to document how it works. Future developers (or future me) shouldn't have to reverse-engineer this.&lt;/p&gt;

&lt;p&gt;Committed, deployed, verified.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I learned:&lt;/strong&gt; Automation isn't just CI/CD pipelines. Sometimes it's replacing one hardcoded value with three lines of JavaScript. Every manual step you eliminate is one less thing that can break.&lt;/p&gt;


&lt;h2&gt;
  
  
  Day 3: Polish (Because "It Works" Isn't Enough)
&lt;/h2&gt;

&lt;p&gt;The footer worked, but it looked terrible. Text was cramped, contrast was weak, and I hadn't tested it on mobile yet.&lt;/p&gt;

&lt;p&gt;I updated the CSS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;footer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2rem&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#1a1a1a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#f0f0f0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;text-align&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.9rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border-top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#333&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;Then I opened Chrome DevTools and tested on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Desktop (1920x1080)&lt;/li&gt;
&lt;li&gt;Tablet (768px)&lt;/li&gt;
&lt;li&gt;Mobile (375px)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The footer was responsive and readable on all screen sizes.&lt;/p&gt;

&lt;p&gt;📸&lt;br&gt;
![Portfolio website footer displaying version 1.0, deploy date, and author name on live EC2 server]](&lt;a href="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/t09vqg2z5cema109i044.jpg" rel="noopener noreferrer"&gt;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/t09vqg2z5cema109i044.jpg&lt;/a&gt;)&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%2Ft3taecwa9eg3c8uu3grh.jpg" 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%2Ft3taecwa9eg3c8uu3grh.jpg" alt="Side-by-side comparison of footer display on desktop 1920x1080 resolution and mobile 375px viewport" width="800" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;📸 &lt;br&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%2F11vv2cfiksbbxn29mevy.jpg" 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%2F11vv2cfiksbbxn29mevy.jpg" alt="Before and after comparison showing footer with improved padding, contrast, and spacing" width="800" height="361"&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Committed, deployed, verified.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I learned:&lt;/strong&gt; "It works on my laptop" is not production-ready. Real software needs to work for everyone, on every device. Polish matters.&lt;/p&gt;


&lt;h2&gt;
  
  
  Day 4: Bonus Feature (Small Wins Add Up)
&lt;/h2&gt;

&lt;p&gt;While I was at it, I updated the homepage tagline. Instead of static text, I added a Discord invite link to the DMI community:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://discord.gg/dmi-cohort3"&lt;/span&gt; 
     &lt;span class="na"&gt;target=&lt;/span&gt;&lt;span class="s"&gt;"_blank"&lt;/span&gt; 
     &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"noopener noreferrer"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    Join the DMI Community on Discord
  &lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;📸 &lt;br&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%2F1dk5mfja6ujtx8yq7smu.jpg" 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%2F1dk5mfja6ujtx8yq7smu.jpg" alt="Homepage displaying Discord community invite link replacing previous static text" width="800" height="410"&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Small change, but now the homepage has a call-to-action instead of passive text.&lt;/p&gt;

&lt;p&gt;Committed, deployed, verified.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I learned:&lt;/strong&gt; Every sprint should leave the product better than it was. Small improvements compound.&lt;/p&gt;




&lt;h2&gt;
  
  
  Day 5: Demo, Retro, Burndown (Closing the Loop)
&lt;/h2&gt;

&lt;p&gt;I recorded a Loom video walking through everything I shipped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Footer with dynamic date&lt;/li&gt;
&lt;li&gt;Mobile responsiveness&lt;/li&gt;
&lt;li&gt;Homepage CTA&lt;/li&gt;
&lt;li&gt;Live deployment verification&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I ran a retrospective:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What went well:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Clear acceptance criteria made implementation straightforward&lt;/li&gt;
&lt;li&gt;Daily commits kept progress visible&lt;/li&gt;
&lt;li&gt;Incremental deployments caught issues early&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What could improve:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Should have tested mobile view on Day 1, not Day 3&lt;/li&gt;
&lt;li&gt;Manual deployments are slow—need automation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Finally, I opened the Jira Burndown Chart. It showed steady progress from Day 1 to Day 5. No scope creep, no blocked work, just consistent daily execution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;📸 &lt;br&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%2F1kg8v7i5pcvglojv9vmw.jpg" 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%2F1kg8v7i5pcvglojv9vmw.jpg" alt="Jira Burndown Chart for Sprint 1 showing steady downward progress from Day 1 to Day 5" width="800" height="388"&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sprint goal achieved.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Running a Real Sprint Taught Me
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Sprint Goals Keep You Focused
&lt;/h3&gt;

&lt;p&gt;"Ship visible footer improvements" isn't just admin overhead. It's a filter. Every time I thought about adding extra features, I asked: "Does this help achieve the sprint goal?" If not, it goes in the backlog.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Daily Commits Build Discipline
&lt;/h3&gt;

&lt;p&gt;Shipping something every single day, even if it's small, creates momentum. By Day 5, I'd deployed 5 times. That's 5 reps of the deployment process, 5 times verifying the site was live, 5 times documenting work in Jira.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Agile Is About Shipping, Not Tickets
&lt;/h3&gt;

&lt;p&gt;Moving a Jira ticket to "Done" feels good, but it's meaningless if the code isn't live. Real Agile is about shipping working software. Every day I asked: "Is this live? Can users see it?" That's the standard.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Documentation Is Part of the Work
&lt;/h3&gt;

&lt;p&gt;Updating the README, writing acceptance criteria, logging daily scrum notes this isn't busywork. It's how teams stay aligned. Without documentation, the next person wastes time figuring out what you built.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Retrospectives Drive Growth
&lt;/h3&gt;

&lt;p&gt;On Day 5, I didn't just celebrate finishing. I asked: "What slowed me down? What would I do differently?" That's how you improve.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;This sprint taught me that Agile isn't a process you follow. It's a mindset. Ship small, ship often, improve continuously.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My next steps:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Automate deployments write a script to eliminate manual SCP/SSH&lt;/li&gt;
&lt;li&gt;Add CI/CD set up GitHub Actions to deploy on every push&lt;/li&gt;
&lt;li&gt;Implement rollback what if a deployment breaks production?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're learning DevOps, try this: pick a small project, break it into daily tasks, and ship something every day for a week. You'll learn more in 5 days of shipping than in 5 weeks of tutorials.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tools I Used
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Jira&lt;/strong&gt; (Sprint planning, backlog, burndown)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Git + GitHub&lt;/strong&gt; (Version control)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS EC2&lt;/strong&gt; (Production server)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nginx&lt;/strong&gt; (Web server)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VS Code&lt;/strong&gt; (Code editor)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chrome DevTools&lt;/strong&gt; (Testing)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loom&lt;/strong&gt; (Sprint demo)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Connect
&lt;/h2&gt;

&lt;p&gt;I'm Odoworitse Afari, a junior DevOps engineer building in public. If you're on a similar path, let's connect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/in/odoworitse-afari" rel="noopener noreferrer"&gt;linkedin.com/in/odoworitse-afari&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/0dow0ri7s3" rel="noopener noreferrer"&gt;github.com/0dow0ri7s3&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>agile</category>
      <category>aws</category>
      <category>learninginpublic</category>
    </item>
    <item>
      <title>How to Deploy a React App on Ubuntu with Nginx: A Complete Guide</title>
      <dc:creator>Odoworitse Afari</dc:creator>
      <pubDate>Tue, 20 Jan 2026 14:15:38 +0000</pubDate>
      <link>https://forem.com/odoworitse_afari_1cbfd3f4/how-to-deploy-a-react-app-on-ubuntu-with-nginx-a-complete-guide-1ac2</link>
      <guid>https://forem.com/odoworitse_afari_1cbfd3f4/how-to-deploy-a-react-app-on-ubuntu-with-nginx-a-complete-guide-1ac2</guid>
      <description>&lt;p&gt;Author: Odoworitse Afari&lt;br&gt;
Date: 20/1/2026&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Introduction&lt;/strong&gt;&lt;br&gt;
Deploying a React application to production involves more than just running npm start. In this guide, I'll walk you through the complete process of deploying a React app on an Ubuntu server and serving it with Nginx—from initial setup to making it publicly accessible.&lt;br&gt;
This isn't theory. This is a real deployment with real challenges and real solutions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;About me:&lt;/strong&gt; I'm Odoworitse Afari, a DevOps Engineer documenting my journey building production infrastructure. Follow me on &lt;a href="//www.linkedin.com/in/odoworitse-afari"&gt;LinkedIn &lt;/a&gt;and &lt;a href="https://github.com/0dow0ri7s3" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; for more DevOps content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we'll cover:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Setting up Node.js and Nginx on Ubuntu&lt;br&gt;
Building a React app for production&lt;br&gt;
Configuring Nginx for Single Page Application (SPA) routing&lt;br&gt;
Troubleshooting common deployment issues&lt;/p&gt;

&lt;p&gt;By the end of this guide, you'll have a live React application accessible via your server's public IP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prerequisites&lt;/strong&gt;&lt;br&gt;
Before starting, you'll need:&lt;/p&gt;

&lt;p&gt;An Ubuntu server (VM, EC2, or any cloud provider)&lt;br&gt;
SSH access to your server&lt;br&gt;
Basic Linux command line knowledge&lt;br&gt;
A React application (I'll use a sample repo from GitHub)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Technology Stack&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Server OS: Ubuntu 20.04/22.04&lt;br&gt;
Runtime: Node.js &amp;amp; npm&lt;br&gt;
Web Server: Nginx&lt;br&gt;
Application: React (production build)&lt;/p&gt;

&lt;p&gt;Step 1: Installing Node.js and npm&lt;/p&gt;

&lt;p&gt;First, we need to install Node.js and npm on the Ubuntu server. These tools are essential for building the React application.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sudo apt update&lt;/code&gt;&lt;br&gt;
&lt;code&gt;sudo apt install -y nodejs npm&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Verify the installation:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;node -v &amp;amp;&amp;amp; npm -v&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;You should see version numbers for both Node.js and npm.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Node.js provides the JavaScript runtime needed to build React applications outside the browser. npm manages the project dependencies and build scripts. Without these, we can't create a production-ready build.&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%2Fof379mz0v6vx782z7gp9.jpg" 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%2Fof379mz0v6vx782z7gp9.jpg" alt="Node.js and npm installation" width="794" height="162"&gt;&lt;/a&gt;&lt;br&gt;
Node.js and npm successfully installed&lt;/p&gt;

&lt;p&gt;Step 2: Installing and Configuring Nginx&lt;br&gt;
Nginx will serve our React application's static files to users.&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Verify Nginx is running:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sudo systemctl status nginx&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;You should see active (running) in the output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The enable command ensures Nginx starts automatically after server reboots, preventing manual intervention during maintenance or unexpected restarts. This is critical for production reliability.&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%2Fgfsg0to491bhl4new7iq.jpg" 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%2Fgfsg0to491bhl4new7iq.jpg" alt="Nginx running status" width="800" height="185"&gt;&lt;/a&gt;&lt;br&gt;
Nginx service active and enabled&lt;/p&gt;

&lt;p&gt;Step 3: Getting the React Application&lt;br&gt;
Clone the React app repository to your server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;git clone https://github.com/pravinmishraaws/my-react-app.git
cd my-react-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why use Git for deployment?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In professional environments, code is always pulled from version control systems. This ensures consistency across environments and provides a clear audit trail of what's deployed.&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%2Fnk77lh6khxha1qjwekk1.jpg" 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%2Fnk77lh6khxha1qjwekk1.jpg" alt="Repository cloned" width="800" height="148"&gt;&lt;/a&gt;&lt;br&gt;
React app repository cloned successfully&lt;/p&gt;

&lt;p&gt;Step 4: Customizing the Application&lt;br&gt;
Before building, let's customize the app to prove ownership. Navigate to the source directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cd my-react-app/src
nano App.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Update the relevant section with your details:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;jsx
&amp;lt;h2&amp;gt;Deployed by: &amp;lt;strong&amp;gt;Your Full Name&amp;lt;/strong&amp;gt;&amp;lt;/h2&amp;gt;
&amp;lt;p&amp;gt;Date: &amp;lt;strong&amp;gt;DD/MM/YYYY&amp;lt;/strong&amp;gt;&amp;lt;/p&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save and exit (Ctrl+O, Enter, Ctrl+X in nano).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this step matters:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In real deployments, applications often need environment-specific configurations. While this example uses direct code editing, production systems typically handle this through environment variables or config files.&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%2Fhjfb6ox43fp0ippqlj01.jpg" 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%2Fhjfb6ox43fp0ippqlj01.jpg" alt="App.js customization" width="800" height="504"&gt;&lt;/a&gt;&lt;br&gt;
Application customized with deployment details&lt;/p&gt;

&lt;p&gt;Step 5: Building for Production&lt;br&gt;
React applications need to be built differently for production versus development.&lt;/p&gt;

&lt;p&gt;Install dependencies:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;npm install&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Create the production build:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;npm run build&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This creates an optimized build/ directory with minified, compressed files ready for production.&lt;/p&gt;

&lt;p&gt;Development vs Production builds:&lt;/p&gt;

&lt;p&gt;Development: Large file sizes, readable code, slower performance&lt;/p&gt;

&lt;p&gt;Production: Minified code, compressed assets, optimized performance, no source maps&lt;/p&gt;

&lt;p&gt;Production builds can be 10x smaller and significantly faster.&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%2Fu5j63z9aghu7lk2j91le.jpg" 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%2Fu5j63z9aghu7lk2j91le.jpg" alt="Build completion" width="800" height="406"&gt;&lt;/a&gt;&lt;br&gt;
Production build completed successfully&lt;/p&gt;

&lt;p&gt;Step 6: Deploying to Nginx&lt;br&gt;
Now we deploy the built application to Nginx's web root directory.&lt;/p&gt;

&lt;p&gt;Clear the default Nginx content:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sudo rm -rf /var/www/html/*&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Copy the production build:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sudo cp -r build/* /var/www/html/&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Set proper ownership and permissions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo chown -R www-data:www-data /var/www/html
sudo chmod -R 755 /var/www/html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Understanding permissions:&lt;/p&gt;

&lt;p&gt;Nginx runs as the www-data user on Ubuntu. Files must be owned by this user for Nginx to read them. The 755 permission allows the owner to read/write/execute, while others can only read/execute.&lt;/p&gt;

&lt;p&gt;Common mistake: Incorrect permissions lead to "403 Forbidden" errors—one of the most frequent deployment issues.&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%2F4lyxlwvmyzm1e3lm2kdm.jpg" 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%2F4lyxlwvmyzm1e3lm2kdm.jpg" alt="Files deployed to web root" width="800" height="177"&gt;&lt;/a&gt;&lt;br&gt;
Application files successfully deployed to /var/www/html&lt;/p&gt;

&lt;p&gt;Step 7: Configuring Nginx for React (Critical Step)&lt;br&gt;
React is a Single Page Application (SPA). All routing happens on the client side. Without proper Nginx configuration, refreshing any route besides / results in a 404 error.&lt;/p&gt;

&lt;p&gt;Replace the default Nginx configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;echo 'server {
  listen 80;
  server_name _;
  root /var/www/html;
  index index.html;

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

  error_page 404 /index.html;
}' | sudo tee /etc/nginx/sites-available/default &amp;gt; /dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Breaking down this configuration:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;listen 80;&lt;/code&gt; - Nginx listens on port 80 (HTTP)&lt;br&gt;
&lt;code&gt;root /var/www/html;&lt;/code&gt; - Where to find files&lt;br&gt;
&lt;code&gt;try_files $uri /index.html;&lt;/code&gt; - This is the key line&lt;/p&gt;

&lt;p&gt;The try_files directive tells Nginx:&lt;/p&gt;

&lt;p&gt;First, try to serve the requested file directly&lt;br&gt;
If the file doesn't exist, serve index.html instead&lt;/p&gt;

&lt;p&gt;This allows React Router to handle navigation.&lt;/p&gt;

&lt;p&gt;Restart Nginx to apply changes:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sudo systemctl restart nginx&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Most tutorials skip proper SPA configuration. Without it:&lt;/p&gt;

&lt;p&gt;Direct URL access fails (404 error)&lt;br&gt;
Page refreshes break (404 error)&lt;br&gt;
Only the home page works&lt;/p&gt;

&lt;p&gt;This is the #1 issue developers face when deploying React apps.&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%2Ffzszyzuedvdx3fn4zf7a.jpg" 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%2Ffzszyzuedvdx3fn4zf7a.jpg" alt="Nginx configuration" width="800" height="249"&gt;&lt;/a&gt;&lt;br&gt;
Nginx configured for SPA routing and restarted&lt;/p&gt;

&lt;p&gt;Step 8: Testing the Deployment&lt;br&gt;
Get your server's public IP:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;curl ifconfig.me&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Open your browser and navigate to:&lt;/p&gt;

&lt;p&gt;http://[your-public-ip]&lt;/p&gt;

&lt;p&gt;Your React application should now be live!&lt;br&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%2Ffe7piweeaq674pb95vb4.jpg" 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%2Ffe7piweeaq674pb95vb4.jpg" alt="Live application" width="800" height="294"&gt;&lt;/a&gt;&lt;br&gt;
React app successfully deployed and accessible via public IP&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Common Issues and Solutions&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Issue 1: 404 Errors on Page Refresh&lt;br&gt;
Symptom: Home page works, but refreshing /about shows 404.&lt;br&gt;
Cause: Nginx isn't configured for SPA routing.&lt;br&gt;
Solution: Ensure the try_files directive is in your Nginx config (Step 7).&lt;/p&gt;

&lt;p&gt;Issue 2: 403 Forbidden Error&lt;br&gt;
Symptom: Browser shows "403 Forbidden" when accessing the site.&lt;br&gt;
Cause: Incorrect file permissions or ownership.&lt;br&gt;
Solution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo chown -R www-data:www-data /var/www/html
sudo chmod -R 755 /var/www/html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Issue 3: Connection Refused&lt;br&gt;
Symptom: Browser can't connect to the server.&lt;br&gt;
Cause: Nginx isn't running, or firewall is blocking port 80.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;sudo systemctl status nginx&lt;/code&gt;  # Check if running&lt;br&gt;
&lt;code&gt;sudo ufw allow 80/tcp&lt;/code&gt;        # If using UFW firewall&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I Learned&lt;/strong&gt;&lt;br&gt;
Configuration is everything - One missing semicolon or wrong directive can break an entire deployment.&lt;/p&gt;

&lt;p&gt;Production builds matter - Development builds are 10x larger and significantly slower. Always use npm run build for production.&lt;/p&gt;

&lt;p&gt;SPA routing is non-obvious - Most deployment guides don't cover this properly, leading to broken apps in production.&lt;/p&gt;

&lt;p&gt;Permissions are critical - Understanding Linux file ownership prevents hours of debugging.&lt;/p&gt;

&lt;p&gt;Testing is mandatory - Never assume deployment worked. Always verify in a browser.&lt;/p&gt;

&lt;p&gt;Security Considerations (For Production)&lt;br&gt;
While this guide gets your app running, production deployments need additional hardening:&lt;/p&gt;

&lt;p&gt;Use HTTPS: Set up SSL/TLS certificates (Let's Encrypt is free)&lt;br&gt;
Configure firewall: Only open necessary ports&lt;br&gt;
Keep software updated: Regular security patches&lt;br&gt;
Use environment variables: Never hardcode secrets&lt;br&gt;
Set up monitoring: Track uptime and errors&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next Steps&lt;/strong&gt;&lt;br&gt;
Now that you have a working deployment, consider:&lt;/p&gt;

&lt;p&gt;Setting up a CI/CD pipeline to automate deployments&lt;br&gt;
Configuring a custom domain instead of using IP addresses&lt;br&gt;
Implementing HTTPS with Let's Encrypt&lt;br&gt;
Adding monitoring and logging (PM2, CloudWatch, or similar)&lt;br&gt;
Using a reverse proxy for multiple applications&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conclusion&lt;/strong&gt;&lt;br&gt;
Deploying a React app to production involves more than just copying files to a server. You need to understand:&lt;/p&gt;

&lt;p&gt;How to build for production&lt;br&gt;
How web servers serve static files&lt;br&gt;
How client-side routing works&lt;br&gt;
How permissions and ownership affect accessibility&lt;/p&gt;

&lt;p&gt;This guide walked you through a complete, working deployment. The challenges you'll face in real production environments will be similar—but now you have the foundation to troubleshoot and solve them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaway:&lt;/strong&gt; Most deployment issues come from misconfiguration, not broken code. Understanding your infrastructure is just as important as understanding your application.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;About the Author&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'm Odoworitse Afari, a DevOps Engineer focused on building scalable cloud infrastructure and automating deployment pipelines. I'm currently part of the DevOps Micro Internship (DMI) program, where I'm building real-world projects and documenting my journey.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Connect with me:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;LinkedIn: &lt;a href="//linkedin.com/in/odoworitse-afari"&gt;linkedin.com/in/odoworitse-afari&lt;/a&gt;&lt;br&gt;
GitHub: &lt;a href="//github.com/0dow0ri7s3"&gt;github.com/0dow0ri7s3&lt;/a&gt;&lt;br&gt;
Upwork: &lt;a href="//upwork.com/freelancers/~0121603c4d11c8e4e3"&gt;upwork.com/freelancers/~0121603c4d11c8e4e3&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I write about DevOps, cloud infrastructure, and automation. Follow me for more hands-on tutorials and project breakdowns.&lt;/p&gt;

&lt;p&gt;If you found this guide helpful:&lt;/p&gt;

&lt;p&gt;⭐ Star my GitHub repos&lt;br&gt;
💬 Connect with me on LinkedIn&lt;br&gt;
🔄 Share this article with others learning DevOps&lt;/p&gt;

</description>
      <category>devops</category>
      <category>react</category>
      <category>nginx</category>
      <category>ubuntu</category>
    </item>
  </channel>
</rss>
