<?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: Vivian Chiamaka Okose</title>
    <description>The latest articles on DEV Community by Vivian Chiamaka Okose (@vivian_okose).</description>
    <link>https://dev.to/vivian_okose</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1544809%2Fa6ff14f1-d604-4864-9aef-1dd849205106.png</url>
      <title>DEV Community: Vivian Chiamaka Okose</title>
      <link>https://dev.to/vivian_okose</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vivian_okose"/>
    <language>en</language>
    <item>
      <title>I Locked Myself Out of My Own Cloud Server. Here Is How I Got Back In.</title>
      <dc:creator>Vivian Chiamaka Okose</dc:creator>
      <pubDate>Wed, 29 Jul 2026 14:35:00 +0000</pubDate>
      <link>https://dev.to/vivian_okose/i-locked-myself-out-of-my-own-cloud-server-here-is-how-i-got-back-in-2c96</link>
      <guid>https://dev.to/vivian_okose/i-locked-myself-out-of-my-own-cloud-server-here-is-how-i-got-back-in-2c96</guid>
      <description>&lt;p&gt;I did something this week that sounds like a mistake and was actually the most useful thing I have learned in months.&lt;/p&gt;

&lt;p&gt;I locked myself out of my own server. On purpose. A server that was running fine, serving a live website, with no way back in. No SSH, no password, and no keyboard to plug in, because it lives in an Amazon data centre I will never enter.&lt;/p&gt;

&lt;p&gt;Then I recovered it. And now I understand something most engineers only read about.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F43yapl60552d90lfiif9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F43yapl60552d90lfiif9.png" alt="Back in after the disk-swap recovery" width="800" height="389"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Logged back into the server that had locked me out.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Let me walk through the whole thing, because the recovery only makes sense once you see how the server was built.&lt;/p&gt;

&lt;h2&gt;
  
  
  Renting a computer by the hour
&lt;/h2&gt;

&lt;p&gt;The cloud is someone else's computer that you rent by the hour. You get a slice of a machine in a warehouse, use it, and hand it back when you are done. Amazon calls this service EC2, and one rented machine is an "instance."&lt;/p&gt;

&lt;p&gt;The mental shift that matters: you pay for the machine existing, not for using it. A server you forgot about costs the same as one serving thousands of people. That single fact shapes every decision in this module, and it is why cost discipline shows up at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Locking the front door before building anything
&lt;/h2&gt;

&lt;p&gt;The very first step had nothing to do with servers. It was locking down the account itself.&lt;/p&gt;

&lt;p&gt;When you sign up for AWS, you get a "root" account that can do anything, including run up unlimited charges if the credentials leak. There is a whole economy of bots scanning the internet for leaked AWS keys, and they spin up expensive servers within minutes of finding one.&lt;/p&gt;

&lt;p&gt;So the first move is to lock root away behind multi-factor authentication and create a weaker, everyday identity to actually work with. That everyday identity is an IAM user. Root is the master key you lock in a drawer; the IAM user is the normal key you carry.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy4253liauesrghlz59f2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy4253liauesrghlz59f2.png" alt="Root account with MFA enabled" width="800" height="402"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Root account secured with multi-factor authentication.&lt;/em&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7q27emclu4hatgcnuhq0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7q27emclu4hatgcnuhq0.png" alt="IAM user inside its group" width="799" height="374"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;An everyday IAM user, in an Administrators group, so I never work as root.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The server that would not let me in
&lt;/h2&gt;

&lt;p&gt;With the account secured, I launched my first instance. And immediately hit a wall.&lt;/p&gt;

&lt;p&gt;SSH timed out. Not "permission denied," which would mean I reached the server but was not allowed in. A timeout, which means the knock got no answer at all.&lt;/p&gt;

&lt;p&gt;My security group, AWS's firewall, was correct: SSH open to my IP, web traffic open to everyone. So why the silence?&lt;/p&gt;

&lt;p&gt;I worked through it one layer at a time. Was my key wrong? A wrong key gets rejected, not ignored. Had my home IP changed? It matched. Was the subnet missing its route to the internet? The gateway was there.&lt;/p&gt;

&lt;p&gt;Then I found it. A network ACL, a second firewall sitting above the security group, set to deny all traffic. Someone had configured this account's network in a non-standard way long before I arrived. My rules were perfect; the layer above them blocked everything.&lt;/p&gt;

&lt;p&gt;I made a call that felt like the professional one: stop patching someone else's confusing setup, and build my own clean network from scratch. A fresh VPC, one public subnet, a proper gateway, all wired correctly. I launched into that, and SSH worked instantly.&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;The instance running in my own clean network, finally reachable.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The lesson stuck harder than any happy-path tutorial could have taught it. A timeout is not one problem, it is a checklist: security group, your IP, the route, the network ACL. And when you inherit a mess, rebuilding clean often beats untangling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hardening, against real threats this time
&lt;/h2&gt;

&lt;p&gt;I had hardened Linux servers before, but always on my own laptop where the threats were hypothetical. This one had a public address, which means bots start probing it within minutes of it going live.&lt;/p&gt;

&lt;p&gt;Same baseline, real stakes: patch everything first, put up a firewall that denies everything by default and opens only SSH and the web ports, and run fail2ban to jail anything that hammers the login repeatedly.&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;The firewall: everything denied by default, only SSH and the web ports open.&lt;/em&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F96n9mp7uudwmj4djuvoq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F96n9mp7uudwmj4djuvoq.png" alt="fail2ban active" width="723" height="262"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;fail2ban standing guard, ready to jail repeat login offenders.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Giving it a name and a padlock
&lt;/h2&gt;

&lt;p&gt;A raw IP address is no way to reach a website. So I pointed a subdomain of my own domain, app.viviancloud.site, at the server, while carefully leaving my main domain on its existing site. One domain, different subdomains for different things, which is exactly how real companies organise this.&lt;/p&gt;

&lt;p&gt;Then HTTPS. For years, certificates cost money and were fiddly. A nonprofit called Let's Encrypt made them free and automatic. One tool, one command, and my site loaded with a padlock and a certificate that renews itself. The plain-HTTP postcard became a sealed envelope.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F62cm9d5tr9z0yh6qejmb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F62cm9d5tr9z0yh6qejmb.png" alt="My own page live" width="799" height="366"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;My own page, served from the EC2 instance.&lt;/em&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw6f9wb9q1flqn056yt51.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw6f9wb9q1flqn056yt51.png" alt="The site on my real domain" width="800" height="441"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Reachable by name at app.viviancloud.site.&lt;/em&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsspecn2nn9qqsfno6xmt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsspecn2nn9qqsfno6xmt.png" alt="The HTTPS padlock in the address bar" width="800" height="324"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The padlock: HTTPS live, certificate valid and auto-renewing.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Data on its own disk
&lt;/h2&gt;

&lt;p&gt;I attached a second disk, separate from the one the operating system lives on. The reason is practical: keep your data apart from your OS, and you can rebuild the OS whenever you like without touching the data. Format it, mount it, and add it to the system's startup config so the mount survives a reboot. I proved it by actually rebooting and watching the disk come back on its own.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fszxxo0c1y93n7m3b0axn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fszxxo0c1y93n7m3b0axn.png" alt="The data disk mounted and surviving a reboot" width="567" height="92"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The separate data disk, still mounted after a full reboot.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The lockout, and the way back
&lt;/h2&gt;

&lt;p&gt;Now the part I opened with.&lt;/p&gt;

&lt;p&gt;To learn recovery for real, I created the disaster. I emptied the file that lists which keys are allowed to log in, disconnected, and confirmed I was locked out. Permission denied, on my own running server.&lt;/p&gt;

&lt;p&gt;But before breaking anything, I took a snapshot: a full backup of the disk, my undo button.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvo6z42l941lvzewz6mpc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvo6z42l941lvzewz6mpc.png" alt="The safety snapshot taken before the drill" width="799" height="370"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A snapshot first. Never break something risky without a backup.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here is the recovery, and the single idea that makes it possible: the file that decides who can log in lives on the disk, and a disk is a movable object.&lt;/p&gt;

&lt;p&gt;So I stopped the server, detached its disk, and attached that disk to a second "rescue" instance as a spare drive. From the rescue instance, I could reach into the locked server's files, and I wrote my key back onto its login list, with the exact ownership and permissions SSH insists on. Then I detached the disk, reattached it to the original server as its boot drive, and started it up.&lt;/p&gt;

&lt;p&gt;I logged in. The same key that got "permission denied" ten minutes earlier now worked.&lt;/p&gt;

&lt;p&gt;It is exactly like being locked out of your house, unbolting the front door, carrying it to a locksmith who fits a new lock you have a key for, and rehanging it. The house never changed. You fixed the lock by taking the door somewhere you could work on it.&lt;/p&gt;

&lt;p&gt;The deeper realisation: a server accepts any key whose public half is written in its login file. So a lost key is never a dead end. You do not recover the old key, you generate a new one and install it. The lock is always replaceable.&lt;/p&gt;

&lt;p&gt;There are faster recovery routes when you set them up in advance, but the disk-swap always works, with no agent and no preparation. It is the universal fallback, which is why it is the one worth learning first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building carefully, tearing down completely
&lt;/h2&gt;

&lt;p&gt;The module ended where cloud work always should: accounting for the cost and knowing how to remove everything.&lt;/p&gt;

&lt;p&gt;Total spend for all of this: zero dollars, by staying on free-tier resources and stopping the instance between sessions. I set a billing alarm as a tripwire, and wrote a teardown runbook listing every resource and how to remove it in order, because a resource you forget is a resource you keep paying for.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiy26a2v011wjrqk544xh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiy26a2v011wjrqk544xh.png" alt="The billing alarm set as a cost tripwire" width="800" height="352"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A billing alarm: emails me if estimated charges ever cross five dollars.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually learned
&lt;/h2&gt;

&lt;p&gt;Not just the steps. The principles underneath them.&lt;/p&gt;

&lt;p&gt;That cloud bills on existence, not use. That you default everything to closed and open only what each service needs. That a lost key is a lock to replace, not a catastrophe. And that knowing how to cleanly destroy infrastructure matters as much as knowing how to build it.&lt;/p&gt;

&lt;p&gt;I locked myself out of a server and got back in by hand. I will not forget how that works.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>cloud</category>
      <category>security</category>
    </item>
    <item>
      <title>Your Build Output Is Not an Artifact Yet. Here Are the Four Things It Needs.</title>
      <dc:creator>Vivian Chiamaka Okose</dc:creator>
      <pubDate>Thu, 23 Jul 2026 12:24:56 +0000</pubDate>
      <link>https://dev.to/vivian_okose/your-build-output-is-not-an-artifact-yet-here-are-the-four-things-it-needs-969</link>
      <guid>https://dev.to/vivian_okose/your-build-output-is-not-an-artifact-yet-here-are-the-four-things-it-needs-969</guid>
      <description>&lt;p&gt;In December 2021, a flaw was found in a Java logging library called Log4j. Not a famous library. The boring thing that writes messages to a log file.&lt;/p&gt;

&lt;p&gt;Then every company on earth asked the same question at once: &lt;strong&gt;are we using it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Most couldn't answer. Not out of carelessness, but because nobody keeps a list of what is actually inside their software. Their app pulled in a library, which pulled in another, which quietly pulled in Log4j four levels down. Teams spent entire weekends grepping through servers trying to work out whether they were exposed.&lt;/p&gt;

&lt;p&gt;The companies that answered in ten minutes had one thing the others didn't. A list.&lt;/p&gt;

&lt;p&gt;That list is called an SBOM, and it's one of four things I learned to attach to my own artifacts this week.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, what a build actually does
&lt;/h2&gt;

&lt;p&gt;Source code is text. &lt;code&gt;UserService.java&lt;/code&gt; is a file full of words like &lt;code&gt;public class&lt;/code&gt; and &lt;code&gt;return&lt;/code&gt;. A processor cannot run that. It speaks something much lower level.&lt;/p&gt;

&lt;p&gt;Building is what closes that gap. It's the cooking, and source code is the recipe.&lt;/p&gt;

&lt;p&gt;Nobody serves a recipe card to a customer. They serve the dish. Nobody deploys source code to a production server either; they deploy the built artifact.&lt;/p&gt;

&lt;p&gt;I built two of them. A Java service with Maven, which produced a 4.8K JAR file. A Node service with npm, which produced a 1.7K .tgz. Different languages, same idea: source in, shippable thing out.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Every build tool is the same tool wearing different clothes
&lt;/h2&gt;

&lt;p&gt;Java uses Maven. Node uses npm. Python uses pip. Three tools, three sets of commands, and it looks like three separate things to learn.&lt;/p&gt;

&lt;p&gt;It isn't. They all answer the same five questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A config file.&lt;/strong&gt; What is this project and what does it need? (&lt;code&gt;pom.xml&lt;/code&gt;, &lt;code&gt;package.json&lt;/code&gt;, &lt;code&gt;requirements.txt&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A lock file.&lt;/strong&gt; Which exact versions did we actually install? (&lt;code&gt;package-lock.json&lt;/code&gt;, pinned versions)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A registry.&lt;/strong&gt; Where do the libraries come from? (Maven Central, npmjs.com, PyPI)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cache.&lt;/strong&gt; Where do downloaded libraries live locally? (&lt;code&gt;~/.m2&lt;/code&gt;, &lt;code&gt;node_modules/&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An artifact.&lt;/strong&gt; What comes out at the end? (&lt;code&gt;.jar&lt;/code&gt;, &lt;code&gt;.tgz&lt;/code&gt;, &lt;code&gt;.whl&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Config, lock, registry, cache, artifact. Once you see those five boxes, learning the third tool takes minutes, because it's just the first tool with different filenames.&lt;/p&gt;

&lt;p&gt;One genuine difference worth knowing. Java compiles everything upfront, translating the whole book before anyone reads it. Node and Python translate live, line by line, as the program runs. That's why my first Maven build took 64 seconds and &lt;code&gt;npm install&lt;/code&gt; took 4.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four questions that turn a build output into an artifact
&lt;/h2&gt;

&lt;p&gt;Here's the part most tutorials skip. A file sitting in a &lt;code&gt;target&lt;/code&gt; folder isn't ready to ship. A real artifact answers four questions.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's in it?
&lt;/h3&gt;

&lt;p&gt;An SBOM, a Software Bill of Materials, is a complete itemised list of every component inside your artifact. It's the ingredients label on food packaging.&lt;/p&gt;

&lt;p&gt;I generated mine with a tool called Syft. My Node service listed two entries: my own code, and one library called dotenv.&lt;/p&gt;

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

&lt;p&gt;Small, but the version was interesting. My config file asked for &lt;code&gt;^16.4.5&lt;/code&gt;, meaning "16.4.5 or anything compatible." What actually got installed was 16.6.1. Fuzzy request, specific reality. That gap is precisely why lock files exist.&lt;/p&gt;

&lt;p&gt;Real applications run this and get three hundred entries. Most of which nobody on the team chose.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it safe?
&lt;/h3&gt;

&lt;p&gt;Knowing what's inside is step one. Step two is checking whether any of it is known to be broken. Security researchers maintain public databases of flaws found in software libraries, and a tool called Grype cross-checks your ingredients list against them.&lt;/p&gt;

&lt;p&gt;It's a food safety inspection. You have the ingredients; now someone checks them against the recall list.&lt;/p&gt;

&lt;p&gt;Both my artifacts came back clean, which was true and slightly boring. So I deliberately installed an old version of a popular library called lodash to see what a real finding 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1mbqs7289l4e4vn8fdhe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1mbqs7289l4e4vn8fdhe.png" alt="Grype vulnerability findings" width="799" height="318"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Six findings from one library. Three rated High. Read one row as a sentence: you have lodash 4.17.15, it has a high severity flaw, version 4.17.21 fixes it.&lt;/p&gt;

&lt;p&gt;Two columns earn their place. EPSS estimates how likely the flaw is to be exploited in the wild, and the top result sat at 22.4%, the 97th percentile. That's not theoretical; attackers are actively using it. RISK blends severity with that likelihood so you know what to fix on Monday morning.&lt;/p&gt;

&lt;p&gt;And every single one had a fix already available. Most vulnerabilities are not unsolvable mysteries. They're someone forgetting to update a library for two years.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it the one I built?
&lt;/h3&gt;

&lt;p&gt;In 2016, attackers broke into the servers of Linux Mint and swapped the download file for their own version with a backdoor in it. Same filename, same page, same site. Anyone downloading that day got malware.&lt;/p&gt;

&lt;p&gt;Underneath the download link sat a line of gibberish characters. Anyone who ran one command against their file and compared it would have known instantly.&lt;/p&gt;

&lt;p&gt;That line is a checksum: a fingerprint calculated from a file's exact contents. It's the tamper-evident seal on a medicine bottle.&lt;/p&gt;

&lt;p&gt;I made one for my JAR, verified it passed, then appended a single character to the file and checked again.&lt;/p&gt;

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

&lt;p&gt;One character. FAILED, immediately. There is no such thing as small tampering; any change at all breaks the seal, which is exactly what makes it worth publishing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Who made it?
&lt;/h3&gt;

&lt;p&gt;A checksum proves the file didn't change. It doesn't prove who made it. An attacker who controls the download page can replace both the file and the checksum, and everything matches perfectly. It's just their seal now.&lt;/p&gt;

&lt;p&gt;Signing closes that gap. A signed release carries cryptographic proof it came from a specific person, and it cannot be forged without their private key.&lt;/p&gt;

&lt;p&gt;I signed my release tag with the same SSH key I set up months ago for signed commits, and got back: &lt;code&gt;Good "git" signature for vivian-signing-2026&lt;/code&gt;. On GitHub it shows a green Verified badge.&lt;/p&gt;

&lt;p&gt;A checksum is a seal anyone can apply. A signature is your handwriting, notarised.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I stopped relying on my memory
&lt;/h2&gt;

&lt;p&gt;Producing all of that took eight separate commands in the right order with the right flags. Which is fine on a Tuesday morning and useless at 5pm on a Friday when you need to ship a fix and the scan feels optional.&lt;/p&gt;

&lt;p&gt;So I wrapped the whole path in one script. Build, SBOM, scan, checksum, in that order, stopping dead if anything fails.&lt;/p&gt;

&lt;p&gt;![The full build and verify run]&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhgc9ny1e0bnsm9b7jrrs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhgc9ny1e0bnsm9b7jrrs.png" alt=" " width="800" height="449"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One command, four steps, three files at the end: the artifact, its ingredients list, and its seal. You can't skip a step because there are no steps to skip.&lt;/p&gt;

&lt;p&gt;That's also the bridge to what comes next. A CI pipeline is essentially this script running on a server, triggered by a push. Write it now, hand it to a robot later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things that caught me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A command finishing without error is not proof it did the right thing.&lt;/strong&gt; My Node tests ran green while testing nothing at all, because I'd pointed the test runner at the wrong path. Five tests written, one thing ran. I only caught it because the count said 1 and I expected 5.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Node's package doesn't contain its dependencies.&lt;/strong&gt; Scanning the .tgz found zero packages, which looked broken. It wasn't. npm deliberately excludes &lt;code&gt;node_modules&lt;/code&gt;, so the box holds the recipe and the ingredients get fetched on arrival. Java bakes them in; Node doesn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build output does not belong in git.&lt;/strong&gt; My first commit attempt swept in compiled classes, test reports, and the JAR itself. All of it reproducible from source with one command. Commit the recipe, not the meal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question I can answer now
&lt;/h2&gt;

&lt;p&gt;If someone hands me a JAR with no SBOM, what should I assume about it?&lt;/p&gt;

&lt;p&gt;That nobody knows what's inside it. Including the person who built it.&lt;/p&gt;

&lt;p&gt;It might be perfectly fine. But there's no way to answer the Log4j question about it, no way to know whether it's carrying something with a public exploit, and no way to prove it's the file they think it is.&lt;/p&gt;

&lt;p&gt;That's not ready to ship. It's just bytes.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>security</category>
      <category>java</category>
      <category>nodejs</category>
    </item>
    <item>
      <title>I Deleted My Database on Purpose. It Was Back in 47 Seconds.</title>
      <dc:creator>Vivian Chiamaka Okose</dc:creator>
      <pubDate>Fri, 17 Jul 2026 06:11:01 +0000</pubDate>
      <link>https://dev.to/vivian_okose/i-deleted-my-database-on-purpose-it-was-back-in-47-seconds-2dm0</link>
      <guid>https://dev.to/vivian_okose/i-deleted-my-database-on-purpose-it-was-back-in-47-seconds-2dm0</guid>
      <description>&lt;p&gt;There's a sentence I can't stop thinking about:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A backup you have never restored is a wish, not a backup.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Most engineers set up backups and never test them. The backup job runs every night, everyone feels safe, and then one day a real disaster hits, they reach for the backup, and discover it's been silently broken for months. Now the data is genuinely gone, at the worst possible moment.&lt;/p&gt;

&lt;p&gt;So this week I stopped wishing. I destroyed my own database on purpose and proved I could bring it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, I learned before I built
&lt;/h2&gt;

&lt;p&gt;I did something different this module: I asked to understand the concepts &lt;em&gt;before&lt;/em&gt; touching a single command. What a database actually is. Why SQL and NoSQL both exist. What containers really are. What migrations do.&lt;/p&gt;

&lt;p&gt;It changed everything. Instead of copying steps and hoping, I knew &lt;em&gt;why&lt;/em&gt; each command mattered. If you're learning DevOps and you find yourself typing commands you don't understand, stop and go learn the idea underneath. The labs land completely differently after that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack, from one command
&lt;/h2&gt;

&lt;p&gt;I built a four-container stack that starts with a single command: Postgres (the strict, organized store for important data), Redis (the fast, flexible one), Adminer (a web view of the data), and pgBouncer (connection pooling).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjpcmxix0r3ptyeq3zqng.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjpcmxix0r3ptyeq3zqng.png" alt="The stack, healthy" width="800" height="217"&gt;&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc7tgso6sjrloj7su3xh4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc7tgso6sjrloj7su3xh4.png" alt="The stack, healthy II" width="800" height="122"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The idea that made containers click for me: a container is a sealed lunchbox holding software &lt;em&gt;plus everything it needs to run&lt;/em&gt;. It works identically on my laptop, your laptop, or a server in a data center. No more "but it works on my machine."&lt;/p&gt;

&lt;p&gt;And one crucial piece: a &lt;strong&gt;volume&lt;/strong&gt;. Containers are disposable, you throw them away and make new ones. A volume is the storage box that survives that. Without one, your data dies with the container. That distinction matters more than anything else in this setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version control, but for your database's shape
&lt;/h2&gt;

&lt;p&gt;Your database's structure changes constantly as an app grows. Add a column, add a table, add an index. The tempting way is to just log in and type the change. The problem: nobody has a record of what changed or when, and now your database, your teammate's, and production are all subtly different.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Migrations&lt;/strong&gt; fix that. They're numbered, recorded files, V1, V2, V3, applied in order, with the tool keeping a history of exactly what ran and when.&lt;/p&gt;

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

&lt;p&gt;They are, quite literally, git for your database structure. Which is why the concept felt instantly familiar after last month's Git module.&lt;/p&gt;

&lt;p&gt;One detail worth stealing: I added a column in V2, and backfilled its values in V3, deliberately two separate migrations, not one. Because if an app is live while you change the database, adding &lt;em&gt;and&lt;/em&gt; filling in one step means a moment where the app and database disagree. Additive first, destructive later. That staging is how you change a database without breaking the thing using it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The refusal that impressed me most
&lt;/h2&gt;

&lt;p&gt;Here's my favourite moment. In the real world, you almost never get to start a database from scratch. You inherit one that's been running for years, full of customer data you cannot wipe. So I simulated that: an existing database, real rows, no migration history.&lt;/p&gt;

&lt;p&gt;I pointed the migration tool at it, and it &lt;strong&gt;refused&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Found non-empty schema but no schema history table.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It was saying: &lt;em&gt;there's data here I didn't create, I don't know what version this is, and I won't guess and risk breaking it.&lt;/em&gt; That refusal is a feature. The tool protecting two years of data from a stranger's assumptions.&lt;/p&gt;

&lt;p&gt;The fix is called &lt;strong&gt;baselining&lt;/strong&gt;: you tell it "whatever exists now, call that version 1, count my changes from version 2." Like joining a company mid-project and saying "I'm not recreating what happened before I arrived. Today is my starting line."&lt;/p&gt;

&lt;p&gt;That's the version of this skill you'll actually use at work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I destroyed everything
&lt;/h2&gt;

&lt;p&gt;The main event. Here's the drill:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Count the rows: &lt;strong&gt;3 authors, 3 posts&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Take a backup (8.2K file)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Drop the entire database&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Restore it&lt;/li&gt;
&lt;li&gt;Verify the counts match&lt;/li&gt;
&lt;li&gt;Time it&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 3 feels wrong to type. You're deleting the thing. And then:&lt;/p&gt;

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

&lt;blockquote&gt;
&lt;p&gt;FATAL: database "lumberyard_dev" does not exist&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the message on someone's screen at 3am when a room goes quiet. My data was genuinely gone.&lt;/p&gt;

&lt;p&gt;Then I restored it from that 8.2K file.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;3 authors, 3 posts.&lt;/strong&gt; Exact match. Nothing lost. &lt;strong&gt;47 seconds.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That number matters more than it looks. In a real incident, everyone asks "how long until we're back?" Most people guess. I can answer, because I measured it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell past me
&lt;/h2&gt;

&lt;p&gt;Three things I actually learned, beyond the commands:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the file size.&lt;/strong&gt; A zero-byte backup file is a silent catastrophe that only surfaces when you desperately need it. Always look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"No errors" is not "it worked."&lt;/strong&gt; Verify the row counts. A restore that finishes quietly but incompletely is worse than one that fails loudly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backups don't belong in git.&lt;/strong&gt; I committed my dump file, then realized: backups contain real data, they bloat the repo forever, and in a real job that's publishing customer data. They belong in object storage. I removed it and gitignored &lt;code&gt;*.dump&lt;/code&gt;. A small fix, but the instinct matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real takeaway
&lt;/h2&gt;

&lt;p&gt;Code is replaceable. You lose it, you redeploy from git. Data is not. Lose it and it may be gone forever.&lt;/p&gt;

&lt;p&gt;Which is why the most valuable thing I did this week wasn't building the stack or writing migrations. It was deleting everything and proving I could get it back. Most junior engineers have never restored a backup. They've just trusted one exists.&lt;/p&gt;

&lt;p&gt;Don't trust. Drill.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Next up: containers, properly.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>devops</category>
      <category>docker</category>
    </item>
    <item>
      <title>The Week Git Stopped Being Scary: Secrets, Conflicts, and Recovery</title>
      <dc:creator>Vivian Chiamaka Okose</dc:creator>
      <pubDate>Mon, 13 Jul 2026 06:41:37 +0000</pubDate>
      <link>https://dev.to/vivian_okose/the-week-git-stopped-being-scary-secrets-conflicts-and-recovery-4k8m</link>
      <guid>https://dev.to/vivian_okose/the-week-git-stopped-being-scary-secrets-conflicts-and-recovery-4k8m</guid>
      <description>&lt;p&gt;&lt;em&gt;Most people learn just enough Git to &lt;strong&gt;&lt;code&gt;add&lt;/code&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;code&gt;commit&lt;/code&gt;&lt;/strong&gt;, and &lt;strong&gt;&lt;code&gt;push&lt;/code&gt;&lt;/strong&gt;. I used to be one of them. This module was about crossing the gap between "I use Git" and "I trust Git with my work", the gap that, on most teams, separates a junior from a mid-level engineer. Here is everything I built, including the part where I got completely lost and how I found my way back.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it impossible to commit a secret
&lt;/h2&gt;

&lt;p&gt;The single most valuable thing I set up: a pre-commit hook chain that scans every commit &lt;em&gt;before&lt;/em&gt; it happens. I tested it by deliberately trying to commit a fake AWS key.&lt;/p&gt;

&lt;p&gt;The commit never happened. The scanner (gitleaks) spotted the key, pointed to the exact file and line, and blocked it cold. Nothing leaked. Nothing to rotate.&lt;/p&gt;

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

&lt;p&gt;This matters because leaked cloud keys get found by attackers within seconds of hitting a public repo, and the cleanup is genuinely painful. The whole philosophy here: turn discipline into automation. Not "remember to check for secrets," but "make it physically impossible to commit one." The same chain also enforces good commit messages, so a lazy "fixed stuff" gets rejected at the door.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Surviving my first merge conflict
&lt;/h2&gt;

&lt;p&gt;I used to think merge conflicts were a disaster. They're not. A conflict is just Git saying "two people changed the same spot, you decide which wins."&lt;/p&gt;

&lt;p&gt;I created one on purpose in a throwaway sandbox: changed the same line on two branches, then tried to combine them. Git stopped and marked the clash with &lt;code&gt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&lt;/code&gt; and &lt;code&gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&lt;/code&gt; markers.&lt;/p&gt;

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

&lt;p&gt;Resolving it was simpler than the reputation suggests: edit the file to what I actually want, delete the marker lines, tell Git it's resolved. That's the entire skill. Once I'd done it once, conflicts stopped being frightening.&lt;/p&gt;

&lt;h2&gt;
  
  
  The concept that unlocked everything
&lt;/h2&gt;

&lt;p&gt;Halfway through, I got completely lost. Moving a commit between branches made no sense to me. I stopped and admitted I didn't understand, rather than pretending.&lt;/p&gt;

&lt;p&gt;The fix was reframing what a branch actually &lt;em&gt;is&lt;/em&gt;. A branch is not a copy of your code. It's a sticky note stuck on one commit. "Moving a commit to another branch" is really just sliding sticky notes around while the commit itself stays put. The moment that clicked, branching, resetting, and recovery all suddenly made sense.&lt;/p&gt;

&lt;p&gt;Admitting I was lost was the best decision I made all module.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recovering deleted work
&lt;/h2&gt;

&lt;p&gt;Then I broke things on purpose. I ran &lt;code&gt;git reset --hard&lt;/code&gt; and wiped three commits, including a merge. They vanished. In a real panic, this is the stomach-drop moment.&lt;/p&gt;

&lt;p&gt;Except they weren't gone. Git keeps a diary called the reflog, a record of everywhere I've been. My "deleted" commits were still in it. One command, and everything came back.&lt;/p&gt;

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

&lt;p&gt;This is the calm that experienced engineers have and beginners don't. &lt;code&gt;git log&lt;/code&gt; only shows my current path; &lt;code&gt;git reflog&lt;/code&gt; remembers the paths I left, including the lost work. Knowing that safety net exists changes how it feels to make a mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  Locking down main like a real team
&lt;/h2&gt;

&lt;p&gt;Finally, I protected my main branch: no direct pushes, changes must come through a pull request, commits must be signed, history must stay linear. I proved it worked by trying to push straight to main, and watching GitHub reject it.&lt;/p&gt;

&lt;p&gt;Then I did the full professional flow: made a branch, pushed it, opened a pull request, watched my automated checks run on GitHub's servers, and merged through the proper door.&lt;/p&gt;

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

&lt;p&gt;That flow, branch, push, PR, pass checks, merge, is exactly what I'll do every day in a DevOps job. Building it on my own repo, with no pressure, is the best practice I could ask for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually learned
&lt;/h2&gt;

&lt;p&gt;The commands matter, but the mindset matters more. Git isn't a thing to fear; it's a safety net that remembers almost everything. The real skills are: make good habits automatic so they don't depend on memory, and know your recovery moves before you need them.&lt;/p&gt;

&lt;p&gt;And the most important lesson had nothing to do with Git: when I was lost, saying so out loud, instead of nodding along, was what let me actually learn. That's a habit worth keeping.&lt;/p&gt;

&lt;p&gt;Next up: databases.&lt;/p&gt;

</description>
      <category>git</category>
      <category>devops</category>
      <category>devsecops</category>
      <category>beginners</category>
    </item>
    <item>
      <title>From Blank VM to Bastion: Everything I Learned Hardening Linux</title>
      <dc:creator>Vivian Chiamaka Okose</dc:creator>
      <pubDate>Thu, 09 Jul 2026 08:49:25 +0000</pubDate>
      <link>https://dev.to/vivian_okose/from-blank-vm-to-bastion-everything-i-learned-hardening-linux-26ok</link>
      <guid>https://dev.to/vivian_okose/from-blank-vm-to-bastion-everything-i-learned-hardening-linux-26ok</guid>
      <description>&lt;p&gt;&lt;em&gt;A full walkthrough of a hands-on Linux module: hardening a server, building a multi-user permission system, writing a diagnostic toolkit, and setting up a real jump host. Plus the seven times the docs lied and the delete command that scared me.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every server I will ever touch in DevOps runs Linux. Every container is a Linux process. Every CI runner shells out to bash. So I spent a module getting genuinely fluent at the command line, not copy-pasting my way through, but understanding every step. Here is everything I built, and more importantly, everything that went wrong on the way, because that is where the real learning was.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;A hardened Ubuntu 24.04 server.&lt;/strong&gt; Starting from a blank VM, I brought it to a production baseline: a deny-by-default firewall (UFW) with only SSH allowed, fail2ban guarding against brute-force login attempts, automatic security updates, a correct timezone, and SSH locked down to key-only auth with no root login.&lt;/p&gt;

&lt;p&gt;The order matters more than it looks. Enable the firewall before allowing SSH, and you lock yourself out of your own server. Allow SSH first, then enable. That sequence has saved more careers than any clever command.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;A multi-tenant permission system.&lt;/strong&gt; Four users, each with a different level of access: two full developers with sudo constrained to specific commands, a read-only user who can look but not touch, and a contractor sealed into her own sandbox with an account set to auto-expire in 30 days. Then I tested every boundary by becoming each user and confirming the walls held.&lt;/p&gt;

&lt;p&gt;This is where Linux permissions stopped being abstract. Watching a read-only user get "Permission denied" when they tried to write, and watching a "restricted" admin get blocked from reading the password file, made access control real in a way no diagram could.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;A four-tool diagnostic toolkit.&lt;/strong&gt; I got tired of typing the same six commands every time something felt slow, so I wrote tools that do it for me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;sysnap&lt;/code&gt;: a one-screen snapshot of a server's health (load, memory, top processes, disk, open ports, failed logins)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;logscan&lt;/code&gt;: search the system logs for a pattern, with context, scoped to a time or service&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;diskmap&lt;/code&gt;: find what's actually eating the disk, biggest-first&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;netcheck&lt;/code&gt;: confirm I'm really online and reaching the things I depend on&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each is 30 to 45 lines of bash, all shellcheck-clean, all covered by a smoke test.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fisgyicgn13i9qy5mv2z9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fisgyicgn13i9qy5mv2z9.png" alt="sysnap running on a live server" width="800" height="590"&gt;&lt;/a&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqyb9cvoczlt07is0ap7b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqyb9cvoczlt07is0ap7b.png" alt="All four scripts pass shellcheck" width="800" height="159"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A real jump host (bastion).&lt;/strong&gt; Production servers don't sit on the open internet. They hide behind a gateway you have to hop through. I built that with three VMs: a client, a bastion, and a private server with no direct route from outside. With one line of SSH config (&lt;code&gt;ProxyJump&lt;/code&gt;) and one command, &lt;code&gt;ssh bedrock-private&lt;/code&gt;, SSH tunneled through the bastion to reach the hidden box.&lt;/p&gt;

&lt;p&gt;The proof it worked: SSH printed &lt;code&gt;&amp;lt;no hostip for proxy command&amp;gt;&lt;/code&gt; during the handshake, its way of saying "I reached this through a proxy, not directly." That phrase is the fingerprint of a working jump host.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The seven times the docs lied
&lt;/h2&gt;

&lt;p&gt;Here is what the tutorials never prepare you for: the documentation and my actual system disagreed seven separate times. Every time, the system was right. Learning to read what the machine tells me, instead of trusting what the guide claims, was the single most valuable skill of the module.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The OS version was wrong.&lt;/strong&gt; I launched "22.04" and got 24.04. &lt;code&gt;lsb_release -a&lt;/code&gt; is the source of truth, not the launch command.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A find-and-replace silently did nothing.&lt;/strong&gt; My &lt;code&gt;sed&lt;/code&gt; pattern didn't match, so it changed nothing, with no error. I only caught it by verifying with &lt;code&gt;grep&lt;/code&gt; afterward. Silent no-ops are the most dangerous kind.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A command flag didn't exist.&lt;/strong&gt; The guide used &lt;code&gt;adduser --expiredate&lt;/code&gt;; my version had no such flag. The fix was a different tool (&lt;code&gt;chage -E&lt;/code&gt;).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A missing package.&lt;/strong&gt; &lt;code&gt;setfacl&lt;/code&gt; didn't exist until I installed &lt;code&gt;acl&lt;/code&gt;. "Command not found" is not always a typo.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;My restricted user wasn't restricted.&lt;/strong&gt; A user I'd limited to three commands still had full admin power, because &lt;code&gt;sudo&lt;/code&gt; group membership silently overrode my rule. Checking effective permissions, not intended ones, revealed it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Mutually exclusive flags.&lt;/strong&gt; &lt;code&gt;df -T&lt;/code&gt; and &lt;code&gt;df --output&lt;/code&gt; couldn't be combined on my build.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;grep&lt;/code&gt; &lt;strong&gt;broke my test.&lt;/strong&gt; My smoke test failed because &lt;code&gt;grep&lt;/code&gt; returns a failure exit code when it finds zero matches, which my &lt;code&gt;set -euo pipefail&lt;/code&gt; correctly caught. Zero failed logins is good news, but &lt;code&gt;grep&lt;/code&gt; calls it a failure. Guard it with &lt;code&gt;|| true&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The mistake that taught me the most
&lt;/h2&gt;

&lt;p&gt;Near the end, I ran a recursive delete with a folder path accidentally on the list, and wiped my entire screenshots folder. No recycle bin. No undo.&lt;/p&gt;

&lt;p&gt;Except the work survived, because it lived in two other places: committed in git, and backed up separately. One &lt;code&gt;git checkout&lt;/code&gt; and a re-copy, and everything was back in under a minute.&lt;/p&gt;

&lt;p&gt;That scare taught me more than any smooth step. Three habits I now hold without exception:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Commit often.&lt;/strong&gt; Recovery took a minute instead of hours because the work was already saved somewhere safe.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;ls&lt;/code&gt; &lt;strong&gt;before you&lt;/strong&gt; &lt;code&gt;rm&lt;/code&gt;&lt;strong&gt;.&lt;/strong&gt; Look at exactly what you are about to delete.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Verify, don't assume.&lt;/strong&gt; A command running without error does not mean it did what you intended.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The real takeaway
&lt;/h2&gt;

&lt;p&gt;The gap between "works in the tutorial" and "works on my actual machine" is where the real skill lives. Run the command, read the error, understand what the system is telling you, adapt, repeat. Tutorials cannot teach that loop, because tutorials never fail. Your real system will, and that is a gift.&lt;/p&gt;

&lt;p&gt;Next up: version control done properly. On to Git.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>devops</category>
      <category>bash</category>
      <category>ssh</category>
    </item>
    <item>
      <title>From Code to Cloud: Deploying 11 Microservices to AWS EKS with Terraform and ArgoCD</title>
      <dc:creator>Vivian Chiamaka Okose</dc:creator>
      <pubDate>Sat, 06 Jun 2026 08:40:56 +0000</pubDate>
      <link>https://dev.to/vivian_okose/from-code-to-cloud-deploying-11-microservices-to-aws-eks-with-terraform-and-argocd-21fh</link>
      <guid>https://dev.to/vivian_okose/from-code-to-cloud-deploying-11-microservices-to-aws-eks-with-terraform-and-argocd-21fh</guid>
      <description>&lt;p&gt;The CI pipeline was passing. Images were in ECR. Everything looked ready.&lt;/p&gt;

&lt;p&gt;Then I ran &lt;code&gt;kubectl get pods&lt;/code&gt; and saw this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;emailservice        0/1    CrashLoopBackOff   12 (3m ago)    24m
recommendationservice  0/1    ImagePullBackOff    0              5m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was just the beginning. Before the app fully loaded on a live AWS URL, I hit authentication failures, image pull errors, a Kubernetes version mismatch in Terraform, and a pod scheduling limit I didn't know existed.&lt;/p&gt;

&lt;p&gt;This is the full story of deploying to AWS EKS — what broke, why, and how each problem got solved.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;p&gt;Before getting into what broke, here is what the deployment stack looks like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Terraform&lt;/strong&gt; — provisions the VPC, EKS cluster, node groups, and IAM roles on AWS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ArgoCD&lt;/strong&gt; — watches the Git repo and syncs the Helm chart to the cluster automatically&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Helm&lt;/strong&gt; — packages all 11 Kubernetes manifests into one deployable chart&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS EKS&lt;/strong&gt; — managed Kubernetes on AWS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Amazon ECR&lt;/strong&gt; — private container registry where CI pushes the Docker images&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The flow is: Terraform creates the infrastructure, kubectl connects to the cluster, ArgoCD is installed, and from that point on every Git push triggers an automatic deployment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1 — Provisioning the Infrastructure with Terraform
&lt;/h2&gt;

&lt;p&gt;The Terraform config creates everything the cluster needs: a VPC with public and private subnets across two availability zones, a NAT gateway for private subnet egress, an EKS cluster, and a managed node group with EC2 worker nodes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"eks"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform-aws-modules/eks/aws"&lt;/span&gt;
  &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 20.0"&lt;/span&gt;

  &lt;span class="nx"&gt;cluster_name&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster_name&lt;/span&gt;
  &lt;span class="nx"&gt;cluster_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster_version&lt;/span&gt;

  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_id&lt;/span&gt;
  &lt;span class="nx"&gt;subnet_ids&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_subnets&lt;/span&gt;

  &lt;span class="nx"&gt;cluster_endpoint_public_access&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="nx"&gt;eks_managed_node_groups&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;main&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;instance_types&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;node_instance_type&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="nx"&gt;min_size&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;node_min_count&lt;/span&gt;
      &lt;span class="nx"&gt;max_size&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;node_max_count&lt;/span&gt;
      &lt;span class="nx"&gt;desired_size&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;node_desired_count&lt;/span&gt;
      &lt;span class="nx"&gt;ami_type&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AL2_x86_64"&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;Running &lt;code&gt;terraform apply&lt;/code&gt; takes about 15 minutes. When it completes it outputs the cluster endpoint, certificate data, and ECR registry URL.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform init
terraform plan
terraform apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fxsbog569rm7otsynmo9z.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%2Fxsbog569rm7otsynmo9z.png" alt="terraform init" width="800" height="328"&gt;&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%2F29bue34zttvwalii8tso.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%2F29bue34zttvwalii8tso.png" alt="terraform init" width="800" height="418"&gt;&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%2Fdnbvhk4x0teqbo69ncwz.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%2Fdnbvhk4x0teqbo69ncwz.png" alt="terraform apply" width="799" height="299"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After apply, connect kubectl to the new cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws eks update-kubeconfig &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; online-boutique-cluster &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Problem 1 — kubectl Authentication Failure
&lt;/h2&gt;

&lt;p&gt;The first thing that happened after the cluster was created was this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error: You must be logged in to the server
(the server has asked for the client to provide credentials)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fw96eh8r5y0kkr0xnlotl.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%2Fw96eh8r5y0kkr0xnlotl.png" alt="kubectl get nodes" width="800" height="210"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Running &lt;code&gt;aws eks update-kubeconfig&lt;/code&gt; had added the cluster to the local kubeconfig but &lt;code&gt;kubectl get nodes&lt;/code&gt; kept failing with credential errors.&lt;/p&gt;

&lt;p&gt;The cause was that the cluster was created by one IAM identity but the local AWS CLI was authenticated as a different IAM user. EKS only grants automatic access to the identity that created the cluster. Everyone else has to be explicitly granted access.&lt;/p&gt;

&lt;p&gt;The fix was creating an access entry for the current user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws eks create-access-entry &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cluster-name&lt;/span&gt; online-boutique-cluster &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--principal-arn&lt;/span&gt; arn:aws:iam::164885464623:user/crystal &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--type&lt;/span&gt; STANDARD

aws eks associate-access-policy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cluster-name&lt;/span&gt; online-boutique-cluster &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--principal-arn&lt;/span&gt; arn:aws:iam::164885464623:user/crystal &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-arn&lt;/span&gt; arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--access-scope&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cluster
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, &lt;code&gt;kubectl get nodes&lt;/code&gt; returned the two worker nodes as expected.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 2 — Kubernetes Version Mismatch in Terraform
&lt;/h2&gt;

&lt;p&gt;When I later ran &lt;code&gt;terraform apply&lt;/code&gt; to scale the node group, Terraform tried to downgrade the cluster from Kubernetes 1.30 to 1.29.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: updating EKS Cluster (online-boutique-cluster) version:
Unsupported Kubernetes minor version update from 1.30 to 1.29
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;cluster_version&lt;/code&gt; variable in &lt;code&gt;variables.tf&lt;/code&gt; said &lt;code&gt;1.29&lt;/code&gt; but the actual cluster was already running &lt;code&gt;1.30&lt;/code&gt; — AWS had auto-upgraded it. Terraform noticed the mismatch and tried to make reality match the config, which meant a downgrade. AWS doesn't allow Kubernetes downgrades.&lt;/p&gt;

&lt;p&gt;The fix was updating the variable to match the actual running version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"cluster_version"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Kubernetes version for EKS"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1.30"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a good reminder to always keep Terraform variables in sync with the actual state of your infrastructure, especially after AWS performs automatic maintenance upgrades.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2 — Installing ArgoCD on EKS
&lt;/h2&gt;

&lt;p&gt;With the cluster running and kubectl authenticated, the next step was installing ArgoCD:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create namespace argocd
kubectl apply &lt;span class="nt"&gt;-n&lt;/span&gt; argocd &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fggezinyk0amdoqqdbjmb.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%2Fggezinyk0amdoqqdbjmb.png" alt="Argocd up and active" width="800" height="387"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One error came up during the install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The CustomResourceDefinition "applicationsets.argoproj.io" is invalid:
metadata.annotations: Too long: must have at most 262144 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a known issue with large CRDs and &lt;code&gt;kubectl apply&lt;/code&gt;. The manifest is too big to fit in the annotation that &lt;code&gt;kubectl apply&lt;/code&gt; uses to track changes. The fix is to use &lt;code&gt;kubectl create&lt;/code&gt; for that specific CRD:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/crds/applicationset-crd.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that all ArgoCD pods came up healthy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; argocd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&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%2F4pxji8fdgmyogxz6hafl.png" alt="ALb" width="800" height="390"&gt;
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Step 3 — Deploying the App via ArgoCD
&lt;/h2&gt;

&lt;p&gt;With ArgoCD running, the next step was creating the Application manifest that tells ArgoCD where to find the Helm chart and where to deploy it:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argoproj.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Application&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;online-boutique&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;repoURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/pawsible-cloud/online-boutique-platform.git&lt;/span&gt;
    &lt;span class="na"&gt;targetRevision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;helm-chart&lt;/span&gt;
    &lt;span class="na"&gt;helm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;releaseName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;online-boutique&lt;/span&gt;
      &lt;span class="na"&gt;valueFiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;values.yaml&lt;/span&gt;
  &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://kubernetes.default.svc&lt;/span&gt;
    &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;online-boutique&lt;/span&gt;
  &lt;span class="na"&gt;syncPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;automated&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;prune&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;selfHeal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;syncOptions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;CreateNamespace=true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Applying this triggered the first sync:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; argocd/application.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ArgoCD pulled the Helm chart from Git, rendered the manifests, and started creating pods in the &lt;code&gt;online-boutique&lt;/code&gt; namespace.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 3 — ImagePullBackOff on Two Services
&lt;/h2&gt;

&lt;p&gt;The first &lt;code&gt;kubectl get pods -n online-boutique&lt;/code&gt; showed most services running but two stuck:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;emailservice          0/1   ImagePullBackOff   0   5m
recommendationservice 0/1   ImagePullBackOff   0   5m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running &lt;code&gt;kubectl describe pod&lt;/code&gt; on one of them showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Failed to pull image "emailservice": pull access denied,
repository does not exist or may require 'docker login'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The image reference in the deployment was just &lt;code&gt;emailservice&lt;/code&gt; — no registry, no tag. Kubernetes didn't know where to pull from so it tried Docker Hub, got denied, and gave up.&lt;/p&gt;

&lt;p&gt;The Helm values file had the image field set incorrectly. The correct reference needed the full ECR URI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;164885464623.dkr.ecr.us-east-1.amazonaws.com/online-boutique/emailservice:v0.10.5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After fixing the values file and pushing to Git, ArgoCD detected the change and redeployed with the correct image references.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 4 — The 500 Error on the Live Site
&lt;/h2&gt;

&lt;p&gt;The app got a load balancer URL. Opening it in the browser showed this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP Status: 500 Internal Server Error
rpc error: code = Unavailable
dial tcp 172.20.197.197:7070: connect: connection refused
could not retrieve cart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fdl1ooefvk87fixfgzaq8.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%2Fdl1ooefvk87fixfgzaq8.png" alt="Alb not working" width="800" height="388"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The frontend was up but cartservice was unreachable. Checking the pods showed cartservice was in &lt;code&gt;Pending&lt;/code&gt; state — it had never scheduled onto a node.&lt;/p&gt;

&lt;p&gt;That led to Problem 5.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 5 — Pod Scheduling Failures (The Unexpected One)
&lt;/h2&gt;

&lt;p&gt;Three pods were stuck in Pending. Running &lt;code&gt;kubectl describe pod&lt;/code&gt; showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0/2 nodes are available: 2 Too many pods.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not out of memory. Not out of CPU. Too many pods.&lt;/p&gt;

&lt;p&gt;Here is what I didn't know: AWS calculates the maximum number of pods a node can run based on the number of network interfaces and IP addresses the instance type supports, not just available resources. A &lt;code&gt;t3.medium&lt;/code&gt; node maxes out at 11 pods per node.&lt;/p&gt;

&lt;p&gt;With 11 app services plus ArgoCD plus Prometheus all competing for slots across 2 nodes (22 slots total), and system pods also taking space, there weren't enough slots for every pod to schedule.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl describe nodes | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; 5 &lt;span class="s2"&gt;"Allocatable"&lt;/span&gt;
&lt;span class="c"&gt;# pods: 11&lt;/span&gt;
&lt;span class="c"&gt;# pods: 11&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first attempt was adding a third node by updating Terraform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"node_desired_count"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the AWS account had a restriction blocking &lt;code&gt;t3.medium&lt;/code&gt; On-Demand instance launches on a free tier account.&lt;/p&gt;

&lt;p&gt;The practical fix was scaling down the load generator — the least critical service — to free up exactly enough slots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl scale deployment loadgenerator &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-n&lt;/span&gt; online-boutique &lt;span class="nt"&gt;--replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That freed one slot. The remaining pending pods scheduled immediately. Every service came up. The app loaded.&lt;/p&gt;




&lt;h2&gt;
  
  
  The App Live on AWS
&lt;/h2&gt;

&lt;p&gt;After all of that — the authentication fix, the version mismatch, the image pull errors, the scheduling limit — the app finally loaded on the AWS load balancer URL.&lt;/p&gt;

&lt;p&gt;Every service running. Every pod healthy. ArgoCD showing Synced and Healthy.&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%2Fqpmzhndvxokzsnvq62u4.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%2Fqpmzhndvxokzsnvq62u4.png" alt="App Live on AWS" width="799" height="428"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Project Taught Me About EKS
&lt;/h2&gt;

&lt;p&gt;A few things that aren't obvious until you actually run into them:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EKS access control is IAM-based.&lt;/strong&gt; The identity that creates the cluster gets access automatically. Everyone else needs an explicit access entry. If you're working in a team or switching between IAM users, set up access entries from the start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pod limits on AWS are network-based, not resource-based.&lt;/strong&gt; t3.medium maxes at 11 pods per node regardless of how much CPU or memory is free. If you're running a lot of services, plan your node count accordingly or use a larger instance type.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Terraform and AWS can drift.&lt;/strong&gt; AWS performs automatic upgrades and maintenance. If your Terraform config doesn't reflect the actual state, the next apply will try to reconcile the difference — sometimes in a direction you don't want.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitOps makes debugging easier.&lt;/strong&gt; Because every deployment comes from Git, you always know exactly what version of the config is running. When something breaks you know where to look.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Comes Next
&lt;/h2&gt;

&lt;p&gt;With the app running on EKS and ArgoCD managing deployments, the next step was setting up monitoring. Prometheus and Grafana give visibility into what the cluster is actually doing — pod health, resource usage, and alerts when things go wrong.&lt;/p&gt;

&lt;p&gt;The next post covers the full monitoring setup, what the ServiceMonitor limitation taught me about how the Online Boutique is instrumented, and how to build useful Grafana dashboards with the metrics you actually have.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part of an ongoing series documenting a full DevOps project built on Google's Online Boutique microservices demo, deployed to AWS EKS with Terraform, GitHub Actions, ArgoCD, Helm, Prometheus, and Grafana.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Repo: &lt;a href="https://github.com/pawsible-cloud/online-boutique-platform" rel="noopener noreferrer"&gt;github.com/pawsible-cloud/online-boutique-platform&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>aws</category>
      <category>terraform</category>
    </item>
    <item>
      <title>Why My CI Pipeline Kept Failing (And What I Had to Redesign to Fix It)</title>
      <dc:creator>Vivian Chiamaka Okose</dc:creator>
      <pubDate>Fri, 05 Jun 2026 17:43:42 +0000</pubDate>
      <link>https://dev.to/vivian_okose/why-my-ci-pipeline-kept-failing-and-what-i-had-to-redesign-to-fix-it-53da</link>
      <guid>https://dev.to/vivian_okose/why-my-ci-pipeline-kept-failing-and-what-i-had-to-redesign-to-fix-it-53da</guid>
      <description>&lt;p&gt;My CI pipeline kept rejecting pushes. Same error, every single run.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;error: failed to push some refs to 'https://github.com/...'
Updates were rejected because the remote contains work that
you do not have locally.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first time it happened I thought it was a simple git issue. Pull, rebase, push. Done.&lt;/p&gt;

&lt;p&gt;It wasn't done. It happened again on the next run. And the one after that.&lt;/p&gt;

&lt;p&gt;This is the story of what was actually broken, why a retry loop didn't fix it, and the architectural change that finally solved it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Pipeline Was Doing
&lt;/h2&gt;

&lt;p&gt;The CI pipeline builds 11 Docker images — one per microservice — and pushes them to Amazon ECR. After each build, it updates a &lt;code&gt;values.yaml&lt;/code&gt; file in the repo with the new image tag, commits the change, and pushes it back to Git. ArgoCD watches that file and deploys automatically when it changes.&lt;/p&gt;

&lt;p&gt;Here is a simplified version of what each job was doing:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build, tag, and push image to ECR&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;docker build -t "$IMAGE_URI" ./src/${{ matrix.service }}/&lt;/span&gt;
    &lt;span class="s"&gt;docker push "$IMAGE_URI"&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Update image tag in Helm values&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;yq -i ".${{ matrix.service }}.image.tag = \"${IMAGE_TAG}\"" \&lt;/span&gt;
      &lt;span class="s"&gt;helm-chart/values.yaml&lt;/span&gt;
    &lt;span class="s"&gt;git add helm-chart/values.yaml&lt;/span&gt;
    &lt;span class="s"&gt;git commit -m "ci: update ${{ matrix.service }} image to ${IMAGE_TAG}"&lt;/span&gt;
    &lt;span class="s"&gt;git push&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks reasonable. The problem is that matrix strategy runs all 11 jobs &lt;strong&gt;in parallel&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Actual Problem: A Race Condition
&lt;/h2&gt;

&lt;p&gt;Here is what happens when 11 jobs all run at the same time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;All 11 jobs check out the repo at the same commit&lt;/li&gt;
&lt;li&gt;All 11 build their image and push to ECR&lt;/li&gt;
&lt;li&gt;All 11 try to update &lt;code&gt;values.yaml&lt;/code&gt; and push at roughly the same time&lt;/li&gt;
&lt;li&gt;The first job to push wins&lt;/li&gt;
&lt;li&gt;Every other job gets rejected because the remote has moved forward
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;! [rejected] vivian -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vivian &lt;span class="o"&gt;(&lt;/span&gt;fetch first&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;error: failed to push some refs
hint: Updates were rejected because the remote contains work
hint: that you do not have locally.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a classic race condition. Multiple writers, one file, no coordination.&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%2F19tux7ko7bvynofberf6.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%2F19tux7ko7bvynofberf6.png" alt="CI pipeline failing" width="800" height="386"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The First Fix Attempt: Retry with Rebase
&lt;/h2&gt;

&lt;p&gt;The first thing I tried was adding a retry loop. Fetch the latest, rebase, try again. If it fails, wait a random number of seconds and retry up to 5 times.&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Update image tag in Helm values&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;for i in 1 2 3 4 5; do&lt;/span&gt;
      &lt;span class="s"&gt;git fetch origin&lt;/span&gt;
      &lt;span class="s"&gt;git rebase origin/${{ github.ref_name }}&lt;/span&gt;

      &lt;span class="s"&gt;yq -i ".${{ matrix.service }}.image.tag = \"${IMAGE_TAG}\"" \&lt;/span&gt;
        &lt;span class="s"&gt;helm-chart/values.yaml&lt;/span&gt;

      &lt;span class="s"&gt;git add helm-chart/values.yaml&lt;/span&gt;
      &lt;span class="s"&gt;git diff --staged --quiet &amp;amp;&amp;amp; echo "No changes to commit" &amp;amp;&amp;amp; exit 0&lt;/span&gt;
      &lt;span class="s"&gt;git commit -m "ci: update ${{ matrix.service }} image to ${IMAGE_TAG}"&lt;/span&gt;

      &lt;span class="s"&gt;git push &amp;amp;&amp;amp; echo "Push succeeded" &amp;amp;&amp;amp; exit 0&lt;/span&gt;

      &lt;span class="s"&gt;echo "Push failed, attempt $i of 5. Retrying..."&lt;/span&gt;
      &lt;span class="s"&gt;sleep $((RANDOM % 10 + 5))&lt;/span&gt;
    &lt;span class="s"&gt;done&lt;/span&gt;

    &lt;span class="s"&gt;echo "All push attempts failed"&lt;/span&gt;
    &lt;span class="s"&gt;exit 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This helped but didn't fully solve it. During the rebase on retry, two jobs would hit a &lt;strong&gt;merge conflict&lt;/strong&gt; on the same file because both had modified &lt;code&gt;values.yaml&lt;/code&gt; in different ways.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;CONFLICT (content): Merge conflict in helm-chart/values.yaml
error: could not apply fad9a46...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A retry loop treats the symptom. The real problem is the design.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Real Fix: One Writer, Not Eleven
&lt;/h2&gt;

&lt;p&gt;The root cause is that 11 jobs should never be writing to the same file at the same time. The solution is to separate the concerns completely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build jobs do one thing: build and push the image.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A single downstream job, running only after all 11 builds finish, updates &lt;code&gt;values.yaml&lt;/code&gt; once with all the new tags in a single commit.&lt;/p&gt;

&lt;p&gt;Here is the redesigned workflow:&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;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build-and-push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build ${{ matrix.service }}&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;fail-fast&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;matrix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;frontend&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cartservice&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;productcatalogservice&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;currencyservice&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;paymentservice&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;shippingservice&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;emailservice&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;checkoutservice&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;recommendationservice&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;adservice&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;loadgenerator&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Checkout code&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure AWS credentials&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;aws-access-key-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_ACCESS_KEY_ID }}&lt;/span&gt;
          &lt;span class="na"&gt;aws-secret-access-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_SECRET_ACCESS_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.AWS_REGION }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Login to Amazon ECR&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;login-ecr&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/amazon-ecr-login@v2&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build, tag, and push image to ECR&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;IMAGE_TAG&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.sha }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;docker build -t "$IMAGE_URI" ./src/${{ matrix.service }}/&lt;/span&gt;
          &lt;span class="s"&gt;docker push "$IMAGE_URI"&lt;/span&gt;

  &lt;span class="na"&gt;update-helm-values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Update Helm values&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build-and-push&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.ref == 'refs/heads/main' || github.ref == 'refs/heads/vivian'&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Checkout code&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install yq&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;sudo wget -qO /usr/local/bin/yq \&lt;/span&gt;
            &lt;span class="s"&gt;https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64&lt;/span&gt;
          &lt;span class="s"&gt;sudo chmod +x /usr/local/bin/yq&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Update all image tags in Helm values&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;IMAGE_TAG&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.sha }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;services=(&lt;/span&gt;
            &lt;span class="s"&gt;frontend cartservice productcatalogservice currencyservice&lt;/span&gt;
            &lt;span class="s"&gt;paymentservice shippingservice emailservice checkoutservice&lt;/span&gt;
            &lt;span class="s"&gt;recommendationservice adservice loadgenerator&lt;/span&gt;
          &lt;span class="s"&gt;)&lt;/span&gt;
          &lt;span class="s"&gt;for service in "${services[@]}"; do&lt;/span&gt;
            &lt;span class="s"&gt;yq -i ".$service.image.tag = \"${IMAGE_TAG}\"" \&lt;/span&gt;
              &lt;span class="s"&gt;helm-chart/values.yaml&lt;/span&gt;
            &lt;span class="s"&gt;echo "Updated $service to ${IMAGE_TAG}"&lt;/span&gt;
          &lt;span class="s"&gt;done&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Commit and push updated values&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;IMAGE_TAG&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.sha }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;git config user.name "GitHub Actions Bot"&lt;/span&gt;
          &lt;span class="s"&gt;git config user.email "actions@github.com"&lt;/span&gt;
          &lt;span class="s"&gt;git add helm-chart/values.yaml&lt;/span&gt;
          &lt;span class="s"&gt;git diff --staged --quiet &amp;amp;&amp;amp; echo "No changes to commit" &amp;amp;&amp;amp; exit 0&lt;/span&gt;
          &lt;span class="s"&gt;git commit -m "ci: update all images to ${IMAGE_TAG}"&lt;/span&gt;
          &lt;span class="s"&gt;git push origin ${{ github.ref_name }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key line is &lt;code&gt;needs: build-and-push&lt;/code&gt;. This tells GitHub Actions to wait until every single build job has completed successfully before running the update job.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Changed and Why It Works
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt; 11 jobs, each writing to &lt;code&gt;values.yaml&lt;/code&gt; as soon as their build finished. No coordination. First push wins, rest fail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt; 11 jobs build and push images. They do nothing else. One job runs after all of them finish, loops through all services, updates every tag in a single pass, commits once, pushes once.&lt;/p&gt;

&lt;p&gt;One writer. No conflicts. Clean history.&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%2Fj9jzi0owc20bpb01e310.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%2Fj9jzi0owc20bpb01e310.png" alt="CI pipeline passing after the fix" width="799" height="386"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The full workflow history tells the story clearly — the red X runs on the left where the old design kept failing, and the green checkmarks after the fix went in.&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%2Fu0ybffg9jf0ajcleoh7k.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%2Fu0ybffg9jf0ajcleoh7k.png" alt="Workflow history showing the debugging journey" width="800" height="388"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Broader Lesson
&lt;/h2&gt;

&lt;p&gt;Race conditions in CI pipelines are easy to miss because each individual step looks correct. The build step is correct. The push step is correct. The commit step is correct. But the system design is wrong.&lt;/p&gt;

&lt;p&gt;When you have parallel jobs touching shared state, you need to ask: who owns this resource? In this case, &lt;code&gt;values.yaml&lt;/code&gt; should have exactly one writer. Once I framed it that way, the fix was obvious.&lt;/p&gt;

&lt;p&gt;If you are building a similar GitOps pipeline with GitHub Actions and Helm, separate your build jobs from your manifest update job from the start. It saves you a frustrating debugging session later.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Comes Next
&lt;/h2&gt;

&lt;p&gt;This pipeline feeds directly into ArgoCD, which watches &lt;code&gt;values.yaml&lt;/code&gt; and syncs the cluster automatically when the file changes. In the next post I'll walk through setting up ArgoCD on EKS, connecting it to the repo, and the pod scheduling problem that showed up once everything was deployed.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part of an ongoing series documenting a full DevOps project built on Google's Online Boutique microservices demo, deployed to AWS EKS with Terraform, GitHub Actions, ArgoCD, Helm, Prometheus, and Grafana.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Repo: &lt;a href="https://github.com/pawsible-cloud/online-boutique-platform" rel="noopener noreferrer"&gt;github.com/pawsible-cloud/online-boutique-platform&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>githubactions</category>
      <category>kubernetes</category>
      <category>cicd</category>
    </item>
    <item>
      <title>I Deployed 11 Microservices Locally With kind, Then Broke One on Purpose published</title>
      <dc:creator>Vivian Chiamaka Okose</dc:creator>
      <pubDate>Thu, 28 May 2026 18:10:47 +0000</pubDate>
      <link>https://dev.to/vivian_okose/i-deployed-11-microservices-locally-with-kind-then-broke-one-on-purposepublished-55jd</link>
      <guid>https://dev.to/vivian_okose/i-deployed-11-microservices-locally-with-kind-then-broke-one-on-purposepublished-55jd</guid>
      <description>&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%2Fynup2kzzvbiqatc9yumz.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%2Fynup2kzzvbiqatc9yumz.png" alt="Online Boutique" width="799" height="428"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Online Boutique running locally on kind. 11 microservices, 5 languages, one &lt;code&gt;kubectl apply&lt;/code&gt; command.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I want to be honest with you: before this deployment, "microservices" was just a word I had read about. I knew the definition. I could explain it in theory. But I had never actually run a microservices application and watched it breathe.&lt;/p&gt;

&lt;p&gt;This changed that.&lt;/p&gt;

&lt;p&gt;Google's Online Boutique is a demo e-commerce app made up of 11 separate services, written in five different programming languages, all talking to each other over gRPC. I ran all of it on my laptop using &lt;strong&gt;kind&lt;/strong&gt;, and then I did something that is not in any tutorial: I injected a CPU fault into one of the services just to see what would happen.&lt;/p&gt;


&lt;h2&gt;
  
  
  What is Online Boutique and Why Does It Matter?
&lt;/h2&gt;

&lt;p&gt;Online Boutique is an open-source cloud-native reference application built by Google. It is not a toy. Google uses it internally to demo Kubernetes, gRPC, distributed tracing, and service mesh patterns.&lt;/p&gt;

&lt;p&gt;What makes it interesting for learning is the architecture. Every part of the app is a completely separate service with its own codebase, its own container, and its own runtime:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;frontend&lt;/strong&gt; is Go. It is the HTTP entry point — the thing your browser talks to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;cartservice&lt;/strong&gt; is C#. It stores your cart items in Redis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;checkoutservice&lt;/strong&gt; is Go. It calls six other services to complete one order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;recommendationservice&lt;/strong&gt; is Python. It suggests related products.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;adservice&lt;/strong&gt; is Java. It returns ads based on page context.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They all run at the same time. They all depend on each other. That is exactly what a microservices production environment looks like.&lt;/p&gt;


&lt;h2&gt;
  
  
  Why I Used kind Instead of minikube
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;kind&lt;/strong&gt; stands for Kubernetes IN Docker. Instead of spinning up a virtual machine (like minikube does), kind runs your entire Kubernetes cluster inside Docker containers. That means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No hypervisor needed&lt;/li&gt;
&lt;li&gt;Faster cluster startup&lt;/li&gt;
&lt;li&gt;Lower resource overhead&lt;/li&gt;
&lt;li&gt;Clean teardown: &lt;code&gt;kind delete cluster&lt;/code&gt; removes everything&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have Docker running, you can have a Kubernetes cluster in under a minute.&lt;/p&gt;


&lt;h2&gt;
  
  
  Setting It Up
&lt;/h2&gt;

&lt;p&gt;Install kind and kubectl, then create your cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kind create cluster &lt;span class="nt"&gt;--name&lt;/span&gt; online-boutique
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clone the repo and deploy:&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/GoogleCloudPlatform/microservices-demo.git
&lt;span class="nb"&gt;cd &lt;/span&gt;microservices-demo
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; ./release/kubernetes-manifests.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2F039lnjfkogtrcsybm05w.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%2F039lnjfkogtrcsybm05w.png" alt="Online Boutique" width="800" height="736"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;One command creates 22 Kubernetes resources across 11 services.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Now watch the pods come up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;--watch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fgx8t8biaaoa53r1bs91e.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%2Fgx8t8biaaoa53r1bs91e.png" alt="Pods in ContainerCreating phase" width="799" height="401"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The pull phase. Docker is downloading container images for all 11 services. This is the real process — not just the happy ending.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;First run takes 5–10 minutes because Docker has to pull images. After that it is fast because images are cached locally.&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%2Fo1bql04gf4aov2besoh8.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%2Fo1bql04gf4aov2besoh8.png" alt="All 11 pods 1/1 Running" width="799" height="370"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;All 11 pods Running. This is the screenshot I had been working towards.&lt;/em&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Accessing the Application
&lt;/h2&gt;

&lt;p&gt;kind does not give you a LoadBalancer (that requires a cloud provider). Use port-forward instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl port-forward deployment/frontend 8080:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;strong&gt;&lt;a href="http://localhost:8080" rel="noopener noreferrer"&gt;http://localhost:8080&lt;/a&gt;&lt;/strong&gt; in your browser.&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%2Fs2chdkhyes513fdjgw53.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%2Fs2chdkhyes513fdjgw53.png" alt="Online Boutique homepage" width="799" height="428"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;An e-commerce store running entirely on my laptop. 11 services, all talking to each other over gRPC.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I clicked through the whole app. Added items to the cart. Completed a checkout. Every action crossed multiple service boundaries: the frontend called the productcatalogservice, the cartservice wrote to Redis, the checkoutservice called the paymentservice, emailservice, and shippingservice in sequence.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Thing That Surprised Me: Live Traffic Was Already Running
&lt;/h2&gt;

&lt;p&gt;After everything was up, I ran this out of curiosity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl logs &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;frontend &lt;span class="nt"&gt;--tail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The logs were already full of requests. Not from me.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;loadgenerator&lt;/strong&gt; service — which is part of the deployment — had been quietly simulating real user traffic the whole time. It browses the store, adds items to carts, and checks out orders on a loop, automatically. I did not configure it. It just happened.&lt;/p&gt;

&lt;p&gt;That was the moment the architecture clicked for me: in a real production system, observability tools would already be watching this traffic, flagging anomalies, measuring latency. The loadgenerator is a built-in simulation of production load.&lt;/p&gt;




&lt;h2&gt;
  
  
  Then I Broke It on Purpose
&lt;/h2&gt;

&lt;p&gt;Once I confirmed everything was healthy, I injected a CPU fault into the recommendationservice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; deployment/recommendationservice &lt;span class="nt"&gt;--&lt;/span&gt; sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"while true; do true; done"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this does: it opens a shell inside the running container and starts an infinite loop that burns CPU as fast as possible. The container is now consuming all the CPU it can get.&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%2Fhk8ko68bd7mltzdfpyur.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%2Fhk8ko68bd7mltzdfpyur.png" alt="kubectl top pods showing recommendationservice CPU spike" width="690" height="353"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;CPU usage on recommendationservice spiking after fault injection. Other services remain stable.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I expected:&lt;/strong&gt; Kubernetes would notice, restart the pod, and everything would be fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What actually happened:&lt;/strong&gt; Nothing. Kubernetes did not restart the pod. It just kept running, getting slower and slower, because the container was not crashing. It was degrading.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Kubernetes only restarts containers that fail health checks or crash outright. A container that is alive but overwhelmed is invisible to the default scheduler.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The recommendation feature slowed down. The rest of the app kept working fine — cart, checkout, payment, all normal.&lt;/p&gt;

&lt;p&gt;To recover, I deleted the pod manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl delete pod &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;recommendationservice
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kubernetes immediately created a fresh one. Clean slate. Thirty seconds later, recommendations were back to normal.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Took Away From This
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;kubectl apply -f&lt;/code&gt; is more powerful than it looks.&lt;/strong&gt; One command, 22 resources, 11 services. The manifest is the source of truth.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Microservices isolate failure well.&lt;/strong&gt; A broken recommendationservice did not take down the cart or checkout. That is the point of the architecture.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Kubernetes does not save you from everything.&lt;/strong&gt; A degraded-but-not-dead service is an invisible problem without resource limits, liveness probes, and readiness probes properly configured.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;kind is excellent for local Kubernetes experimentation.&lt;/strong&gt; Fast, clean, no cloud cost.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The loadgenerator is underrated.&lt;/strong&gt; It gives you something to observe immediately without setting up synthetic monitoring yourself.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




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

&lt;p&gt;Next step for me is deploying the same application on &lt;strong&gt;AWS EKS&lt;/strong&gt; and &lt;strong&gt;Azure AKS&lt;/strong&gt;. The Kubernetes manifests will not change at all. That is the whole point of cloud-native architecture: the workload does not care where it runs. The infrastructure is the variable.&lt;/p&gt;

&lt;p&gt;I will document both cloud deployments the same way I documented this one.&lt;/p&gt;




&lt;p&gt;If you are learning DevOps or cloud engineering and have not tried running Online Boutique locally yet — do it. Then break something. That is where the real learning is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/vivianokose" rel="noopener noreferrer"&gt;github.com/vivianokose&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://linkedin.com/in/okosechiamaka" rel="noopener noreferrer"&gt;linkedin.com/in/okosechiamaka&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>cloudengineering</category>
      <category>microservices</category>
    </item>
    <item>
      <title>I Ran 9 Kubernetes Labs on a Local KIND Cluster — Here Is Everything I Learned</title>
      <dc:creator>Vivian Chiamaka Okose</dc:creator>
      <pubDate>Sun, 26 Apr 2026 19:20:50 +0000</pubDate>
      <link>https://dev.to/vivian_okose/i-ran-9-kubernetes-labs-on-a-local-kind-cluster-here-is-everything-i-learned-2ed2</link>
      <guid>https://dev.to/vivian_okose/i-ran-9-kubernetes-labs-on-a-local-kind-cluster-here-is-everything-i-learned-2ed2</guid>
      <description>&lt;p&gt;&lt;em&gt;By Vivian Chiamaka Okose | DevOps Engineer&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;If you have been putting off learning Kubernetes because it feels overwhelming, this post is for you.&lt;/p&gt;

&lt;p&gt;This week I completed a 9 hands-on labs covering the core building blocks of Kubernetes: Pods, ReplicaSets, Deployments, Horizontal Pod Autoscaler, health probes, and all three Service types. I ran everything locally on a KIND (Kubernetes IN Docker) cluster inside WSL Ubuntu on Windows, with zero cloud costs.&lt;/p&gt;

&lt;p&gt;Here is exactly what I did, what broke, and what I now understand that I did not before.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Setup: KIND on WSL
&lt;/h2&gt;

&lt;p&gt;Before I could run a single &lt;code&gt;kubectl&lt;/code&gt; command, I needed a cluster. I chose KIND because it runs Kubernetes entirely inside Docker containers on your local machine. If you already have Docker, you are most of the way there.&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;# Install KIND&lt;/span&gt;
curl &lt;span class="nt"&gt;-Lo&lt;/span&gt; ./kind https://kind.sigs.k8s.io/dl/v0.23.0/kind-linux-amd64
&lt;span class="nb"&gt;chmod&lt;/span&gt; +x ./kind
&lt;span class="nb"&gt;sudo mv&lt;/span&gt; ./kind /usr/local/bin/kind

&lt;span class="c"&gt;# Create the cluster&lt;/span&gt;
kind create cluster &lt;span class="nt"&gt;--name&lt;/span&gt; k8s-labs
kubectl get nodes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Within two minutes I had a real Kubernetes node showing &lt;code&gt;Ready&lt;/code&gt;. That felt good.&lt;/p&gt;




&lt;h2&gt;
  
  
  Assignment 1: Pods, ReplicaSets, and Deployments
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Lab 1: Your First Pod
&lt;/h3&gt;

&lt;p&gt;A Pod is the atomic unit in Kubernetes. Everything else in the system is built around it. I created one two ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The imperative way&lt;/strong&gt; (fast, not repeatable):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl run nginx-pod &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nginx
kubectl get pods
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The declarative way&lt;/strong&gt; (YAML, production standard):&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Pod&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx-pod&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx-container&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;nginx&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="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; nginx-pod.yaml
kubectl describe pod nginx-pod
kubectl logs nginx-pod
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; nginx-pod &lt;span class="nt"&gt;--&lt;/span&gt; /bin/bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key insight: YAML is how you work in production because it is version-controllable, reviewable, and reproducible. The imperative method is fine for quick experiments but you would never use it to manage a real system.&lt;/p&gt;




&lt;h3&gt;
  
  
  Lab 2: ReplicaSets — the First Sign of Real Resilience
&lt;/h3&gt;

&lt;p&gt;A single Pod has a problem: if it crashes or is deleted, it is gone. You would have to manually recreate it. That is not acceptable in production.&lt;/p&gt;

&lt;p&gt;A ReplicaSet fixes this by ensuring a fixed number of Pod replicas are always running.&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ReplicaSet&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx-replicaset&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&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;nginx:1.21.1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I applied this, confirmed 3 Pods were running, then deleted one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl delete pod nginx-replicaset-xxxxx
kubectl get pods
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before I could even blink, Kubernetes had already created a replacement. That moment is when auto-healing stops being a concept and becomes something you have actually seen.&lt;/p&gt;

&lt;p&gt;I also scaled from 3 to 5 by changing &lt;code&gt;replicas: 3&lt;/code&gt; to &lt;code&gt;replicas: 5&lt;/code&gt; and reapplying. Two extra Pods appeared immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The catch:&lt;/strong&gt; ReplicaSets do not support rolling updates. That is where Deployments come in.&lt;/p&gt;




&lt;h3&gt;
  
  
  Lab 3: Deployments — Zero Downtime Updates
&lt;/h3&gt;

&lt;p&gt;A Deployment manages a ReplicaSet and adds the features you actually need for production: rolling updates and rollback.&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;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RollingUpdate&lt;/span&gt;
  &lt;span class="na"&gt;rollingUpdate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;maxSurge&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="na"&gt;maxUnavailable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This config means: spin up 1 new Pod before killing an old one, never leave any Pods unavailable. That is zero downtime.&lt;/p&gt;

&lt;p&gt;To update the nginx image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;set &lt;/span&gt;image deployment/nginx-deployment &lt;span class="nv"&gt;nginx&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nginx:1.23.0
kubectl rollout status deployment/nginx-deployment
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To roll back instantly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl rollout undo deployment/nginx-deployment
kubectl rollout &lt;span class="nb"&gt;history &lt;/span&gt;deployment/nginx-deployment
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rollout history command showed me the revision log like a git blame for infrastructure. That is powerful.&lt;/p&gt;




&lt;h2&gt;
  
  
  Assignment 2: Auto-Scaling and Health Management
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Lab 4: HPA — Let Kubernetes Decide How Many Pods You Need
&lt;/h3&gt;

&lt;p&gt;The Horizontal Pod Autoscaler reads CPU (or memory) metrics and scales your Deployment up or down automatically.&lt;/p&gt;

&lt;p&gt;For this to work on KIND, I needed to install the metrics-server with a flag for local clusters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
kubectl patch deployment metrics-server &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I created an HPA:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl autoscale deployment php-apache &lt;span class="nt"&gt;--cpu-percent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;50 &lt;span class="nt"&gt;--min&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;--max&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in a second terminal, I hammered it with a load generator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl run &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;--tty&lt;/span&gt; load-generator &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;busybox:1.28 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Never &lt;span class="nt"&gt;--&lt;/span&gt; /bin/sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"while sleep 0.01; do wget -q -O- http://php-apache; done"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watching &lt;code&gt;kubectl get hpa --watch&lt;/code&gt; while the load generator ran was one of those moments that makes DevOps genuinely fun. The Pod count went from 1 to 5. When I killed the load generator, it scaled back down on its own.&lt;/p&gt;

&lt;p&gt;This is why autoscaling matters: you pay for what you use, and your app handles spikes without anyone manually touching it.&lt;/p&gt;




&lt;h3&gt;
  
  
  Lab 5: Readiness Probes — the Traffic Gate
&lt;/h3&gt;

&lt;p&gt;A readiness probe answers the question: "Is this Pod ready to receive user traffic?"&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;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
  &lt;span class="na"&gt;initialDelaySeconds&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;periodSeconds&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;failureThreshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without a readiness probe, Kubernetes sends traffic to a Pod the moment it starts, even if the application inside is still warming up. That causes 502 errors during deployments.&lt;/p&gt;

&lt;p&gt;With a readiness probe, the Pod only enters the Service's endpoint list after passing the health check. You get clean deployments with no cold-start errors reaching users.&lt;/p&gt;




&lt;h3&gt;
  
  
  Lab 6: Liveness Probes — the Self-Healing Mechanism
&lt;/h3&gt;

&lt;p&gt;A liveness probe answers a different question: "Is this running container still healthy?"&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;livenessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
  &lt;span class="na"&gt;initialDelaySeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
  &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
  &lt;span class="na"&gt;failureThreshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To see it in action, I deleted the nginx index.html file inside a running Pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &amp;lt;pod-name&amp;gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; /usr/share/nginx/html/index.html
kubectl get pods &lt;span class="nt"&gt;--watch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After 3 consecutive failed probes (30 seconds), Kubernetes restarted the container. The RESTARTS counter went from 0 to 1. No manual action required.&lt;/p&gt;

&lt;p&gt;The real-world case for this: imagine an app that hits a deadlock or runs out of memory and becomes unresponsive but the process is still technically running. Without a liveness probe, that Pod sits there broken forever. With one, Kubernetes restarts it within minutes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Assignment 3: Services and Networking
&lt;/h2&gt;

&lt;p&gt;This assignment was the one that changed how I think about Kubernetes networking.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lab 7: ClusterIP — Stable Internal Communication
&lt;/h3&gt;

&lt;p&gt;Pod IPs are ephemeral. Every time a Pod restarts or is replaced, it gets a new IP. If another service is hard-coding that IP, it breaks.&lt;/p&gt;

&lt;p&gt;A ClusterIP Service solves this by providing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A stable virtual IP that never changes&lt;/li&gt;
&lt;li&gt;A stable DNS name (e.g. &lt;code&gt;nginx-svc.default.svc.cluster.local&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Automatic load balancing across all Ready Pods
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx-svc&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&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="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
      &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterIP&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I proved it works by launching a busybox Pod and making requests from inside the cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; tester &lt;span class="nt"&gt;--&lt;/span&gt; sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"wget -qO- http://nginx-svc | head -5"&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; tester &lt;span class="nt"&gt;--&lt;/span&gt; nslookup nginx-svc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The DNS lookup resolved to the ClusterIP and the request succeeded. That is microservice communication working exactly as it should.&lt;/p&gt;




&lt;h3&gt;
  
  
  Lab 8: NodePort — Opening the Door from Outside
&lt;/h3&gt;

&lt;p&gt;NodePort opens a specific port (between 30000-32767) on every node, making your app reachable from outside the cluster.&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;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NodePort&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="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
      &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
      &lt;span class="na"&gt;nodePort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On KIND, since there is no real node IP accessible from the host, I used port-forward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl port-forward svc/nginx-nodeport 8080:80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Opening &lt;code&gt;http://localhost:8080&lt;/code&gt; in the browser showed the NGINX welcome page. External access confirmed.&lt;/p&gt;




&lt;h3&gt;
  
  
  Lab 9: LoadBalancer — the Cloud-Native Way to Go External
&lt;/h3&gt;

&lt;p&gt;In a real cloud environment (Azure, AWS, GCP), a LoadBalancer Service automatically provisions a public IP address and routes internet traffic to your Pods. In KIND, there is no cloud behind it, so I used MetalLB to simulate this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; https://raw.githubusercontent.com/metallb/metallb/v0.13.12/config/manifests/metallb-native.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After configuring an IP address pool from the Docker bridge subnet, I created a LoadBalancer Service and got an external IP assigned from that pool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get svc nginx-loadbalancer
&lt;span class="c"&gt;# NAME                 TYPE           CLUSTER-IP     EXTERNAL-IP      PORT(S)&lt;/span&gt;
&lt;span class="c"&gt;# nginx-loadbalancer   LoadBalancer   10.96.xxx.xxx  172.18.255.200   80:xxxxx/TCP&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;curl&lt;/code&gt; against that external IP returned the NGINX welcome page. In Azure, that IP would be a real public internet address. MetalLB let me understand the behaviour without spending a penny on cloud resources.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Would Tell Anyone Starting Kubernetes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Start with Pods.&lt;/strong&gt; Not because they are what you use in production (you use Deployments), but because understanding what a Pod is makes every other concept click faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Break things on purpose.&lt;/strong&gt; Delete a Pod. Break a readiness probe. Kill a container. The fastest way to understand Kubernetes is to watch it recover from failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Health probes are not optional.&lt;/strong&gt; Every production Deployment needs both a readiness probe and a liveness probe. Not having them is like deploying blind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Services exist because Pod IPs are unreliable.&lt;/strong&gt; Once you understand that one sentence, all three service types make sense immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;KIND is excellent for local learning.&lt;/strong&gt; You get a real Kubernetes cluster with no cloud bill. The only gotchas are the metrics-server TLS flag and needing MetalLB for LoadBalancer behaviour.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Is Next
&lt;/h2&gt;

&lt;p&gt;Next up: Kubernetes Ingress, ConfigMaps, Secrets, and persistent storage. Each week I am writing up what I actually did, what broke, and what I learned from it.&lt;/p&gt;

&lt;p&gt;If you are on a similar path, follow along. And if you are already deep into Kubernetes, I would love to know what concept took the longest to click for you.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Vivian Chiamaka Okose&lt;/strong&gt;&lt;br&gt;
DevOps Engineer &lt;br&gt;
&lt;a href="https://linkedin.com/in/okosechiamaka/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://github.com/vivianokose" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>cloudnative</category>
      <category>containers</category>
    </item>
    <item>
      <title>How I Deployed a Full-Stack Bookstore with Docker Compose on Azure (Capstone Project)</title>
      <dc:creator>Vivian Chiamaka Okose</dc:creator>
      <pubDate>Tue, 21 Apr 2026 20:37:28 +0000</pubDate>
      <link>https://dev.to/vivian_okose/how-i-deployed-a-full-stack-bookstore-with-docker-compose-on-azure-capstone-project-11dc</link>
      <guid>https://dev.to/vivian_okose/how-i-deployed-a-full-stack-bookstore-with-docker-compose-on-azure-capstone-project-11dc</guid>
      <description>&lt;h2&gt;
  
  
  What I Deployed
&lt;/h2&gt;

&lt;p&gt;The EpicBook is an online bookstore with a Node.js + Express backend, MySQL database, Handlebars frontend, and Nginx reverse proxy. It has real data: 54 books and 53 authors.&lt;/p&gt;

&lt;p&gt;The architecture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Internet (port 80)
        |
        v
Nginx (reverse proxy)
        |  frontend_network
        v
Node.js App (port 8080, internal)
        |  backend_network
        v
MySQL (port 3306, internal only)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only Nginx faces the internet. MySQL is never reachable from outside the VM.&lt;/p&gt;




&lt;h2&gt;
  
  
  The .env File (Secrets First)
&lt;/h2&gt;

&lt;p&gt;Before writing a single Dockerfile, I created a .env file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MYSQL_ROOT_PASSWORD=&amp;lt;strong-password&amp;gt;
MYSQL_DATABASE=bookstore
MYSQL_USER=epicbook
MYSQL_PASSWORD=&amp;lt;strong-password&amp;gt;
NODE_ENV=production
PORT=8080
DB_HOST=db
DB_USER=epicbook
DB_PASSWORD=&amp;lt;strong-password&amp;gt;
DB_NAME=bookstore
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The original config.json had hardcoded credentials pointing to a Heroku database URL. I updated it to use direct credentials pointing to the db Docker Compose service name. Secrets stay in .env, not in code.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Multi-Stage Dockerfile
&lt;/h2&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 - install production dependencies&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:18-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="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="c"&gt;# Stage 2 - production runtime&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:18-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="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /app/node_modules ./node_modules&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;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 8080&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The builder stage installs only production dependencies. The runtime stage gets clean node_modules and source code. No dev tools in the final image.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Nginx Config
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;epicbook_app&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8080&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;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;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://epicbook_app&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="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;code&gt;app&lt;/code&gt; is the Docker Compose service name. Docker's internal DNS resolves it automatically. No IP addresses needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  The docker-compose.yml
&lt;/h2&gt;

&lt;p&gt;Three services, two networks, two volumes:&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;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;frontend_network&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bridge&lt;/span&gt;
  &lt;span class="na"&gt;backend_network&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bridge&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nginx_logs&lt;/span&gt;&lt;span class="pi"&gt;:&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;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;backend_network&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;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;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;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;

  &lt;span class="na"&gt;app&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;.&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;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;frontend_network&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;backend_network&lt;/span&gt;

  &lt;span class="na"&gt;nginx&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;nginx:alpine&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="na"&gt;app&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;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;frontend_network&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The depends_on with condition: service_healthy is the key. Nothing starts before the thing it depends on is ready.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Went Wrong (And How I Fixed It)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Problem 1: Books not loading after deployment&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The site came up but showed "no books available." The seed SQL files ran without errors but inserted no data.&lt;/p&gt;

&lt;p&gt;The issue was that docker exec with stdin redirection reads the file from the VM host but executes inside the container. The file was not inside the container.&lt;/p&gt;

&lt;p&gt;Fix: Use docker cp to copy the files into the container first, then execute them with source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;cp &lt;/span&gt;db/author_seed.sql epicbook_db:/tmp/author_seed.sql
docker &lt;span class="nb"&gt;exec &lt;/span&gt;epicbook_db mysql &lt;span class="nt"&gt;-u&lt;/span&gt; epicbook &lt;span class="nt"&gt;-pPassword&lt;/span&gt; bookstore &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"source /tmp/author_seed.sql"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Problem 2: Nginx healthcheck showing unhealthy&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The healthcheck used wget which is not in nginx:alpine by default.&lt;/p&gt;

&lt;p&gt;Fix: Removed the Nginx healthcheck entirely. The site was serving traffic correctly and the app healthcheck was already ensuring startup order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem 3: docker-compose.yml corrupted by heredoc paste&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Special characters in the YAML caused the heredoc to break mid-paste.&lt;/p&gt;

&lt;p&gt;Fix: Used a Python script to write the file content instead of shell heredoc:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
content = '''..yaml content..'''
with open('docker-compose.yml', 'w') as f:
    f.write(content)
"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Persistence Test
&lt;/h2&gt;

&lt;p&gt;Before restart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SELECT COUNT(*) FROM Book; --&amp;gt; 54
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;/div&gt;



&lt;p&gt;After restart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SELECT COUNT(*) FROM Book; --&amp;gt; 54
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every container was destroyed and recreated. The data survived because it lives in the db_data named volume, not in the container.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/vivianokose/the-epicbook_capstone.git" rel="noopener noreferrer"&gt;https://github.com/vivianokose/the-epicbook_capstone.git&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%2F6p5x81m4dhtb6vhl8il7.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%2F6p5x81m4dhtb6vhl8il7.png" alt="1" width="800" height="517"&gt;&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%2Fj4p8y336r5drw05qoqau.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%2Fj4p8y336r5drw05qoqau.png" alt="2" width="800" height="513"&gt;&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%2F1od8eev70n98pckcj2ga.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%2F1od8eev70n98pckcj2ga.png" alt="3" width="800" height="764"&gt;&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%2Fetmm9z6lqzm4l1qyaz5p.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%2Fetmm9z6lqzm4l1qyaz5p.png" alt="4" width="800" height="395"&gt;&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%2Fg1stftgko98x8659dp3g.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%2Fg1stftgko98x8659dp3g.png" alt="5" width="800" height="446"&gt;&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%2Fkw0gyw8snvm2c1ytzgjk.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%2Fkw0gyw8snvm2c1ytzgjk.png" alt="6" width="748" height="157"&gt;&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%2Fwryvy6fan9pje67kx4tb.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%2Fwryvy6fan9pje67kx4tb.png" alt="7" width="743" height="270"&gt;&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%2Fh03ep4jen6iicmpwaoat.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%2Fh03ep4jen6iicmpwaoat.png" alt="8" width="627" height="272"&gt;&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%2Fn6nqf28v437k9nkal4ei.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%2Fn6nqf28v437k9nkal4ei.png" alt="9" width="567" height="185"&gt;&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%2Fnmgz9p7jvcf5ckeo7zm8.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%2Fnmgz9p7jvcf5ckeo7zm8.png" alt="10" width="641" height="272"&gt;&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%2Fqku1ni9fid3nwifewn99.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%2Fqku1ni9fid3nwifewn99.png" alt="11" width="747" height="426"&gt;&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%2F8mv2jdsplq6rnl9c0s7u.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%2F8mv2jdsplq6rnl9c0s7u.png" alt="12" width="750" height="982"&gt;&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%2F7rozpfqnvxwt1jyp25hl.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%2F7rozpfqnvxwt1jyp25hl.png" alt="13" width="736" height="451"&gt;&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%2Fhf3943ishyw3xmln4nvz.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%2Fhf3943ishyw3xmln4nvz.png" alt="14 a" width="742" height="436"&gt;&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%2F49uucdssbjtuo25kmh0l.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%2F49uucdssbjtuo25kmh0l.png" alt="14 b" width="725" height="327"&gt;&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%2F2t3a7c75lpjpcqza5jrs.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%2F2t3a7c75lpjpcqza5jrs.png" alt="14 c" width="732" height="146"&gt;&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%2Fwqouaghjwnqmz12ahbkx.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%2Fwqouaghjwnqmz12ahbkx.png" alt="15" width="800" height="94"&gt;&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%2F51fcqkgyzvb2fq5h2p8f.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%2F51fcqkgyzvb2fq5h2p8f.png" alt="16" width="800" height="597"&gt;&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%2Fhx4o4qnp2xfg1urvloqc.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%2Fhx4o4qnp2xfg1urvloqc.png" alt="17" width="800" height="601"&gt;&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%2F2lkxg13026crad5fxhxk.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%2F2lkxg13026crad5fxhxk.png" alt="18" width="800" height="597"&gt;&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%2Fefoq1erbuxoxsghuecdr.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%2Fefoq1erbuxoxsghuecdr.png" alt="19" width="800" height="603"&gt;&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%2Fp3wxqw1xo5ixxsgrvoqm.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%2Fp3wxqw1xo5ixxsgrvoqm.png" alt="20" width="800" height="602"&gt;&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%2Fcjcxu7bbms14sg8p9mwr.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%2Fcjcxu7bbms14sg8p9mwr.png" alt="21" width="800" height="81"&gt;&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%2Fpmgoxte99bv72iwf105a.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%2Fpmgoxte99bv72iwf105a.png" alt="22" width="800" height="258"&gt;&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%2Fn9qugzptcz6ok1r3o70s.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%2Fn9qugzptcz6ok1r3o70s.png" alt="23" width="800" height="234"&gt;&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%2F3vfnbujv3j4r90cjl63l.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%2F3vfnbujv3j4r90cjl63l.png" alt="24" width="800" height="52"&gt;&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%2F2qp3bqpu5cyeunhg709t.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%2F2qp3bqpu5cyeunhg709t.png" alt="25" width="787" height="255"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&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%2Fx2wgc4zz091v3m1mzst6.png" alt="26" width="800" height="64"&gt;
&lt;/h2&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>azure</category>
      <category>cloudcomputing</category>
    </item>
    <item>
      <title>Multi-Stage Docker Builds: How I Cut a React Image from 760MB to 94MB</title>
      <dc:creator>Vivian Chiamaka Okose</dc:creator>
      <pubDate>Fri, 17 Apr 2026 15:07:31 +0000</pubDate>
      <link>https://dev.to/vivian_okose/multi-stage-docker-builds-how-i-cut-a-react-image-from-760mb-to-94mb-29c0</link>
      <guid>https://dev.to/vivian_okose/multi-stage-docker-builds-how-i-cut-a-react-image-from-760mb-to-94mb-29c0</guid>
      <description>

&lt;p&gt;I built two Docker images for the same React app this week.&lt;/p&gt;

&lt;p&gt;One was 760MB. The other was 94MB. Both loaded the exact same website in the browser.&lt;/p&gt;

&lt;p&gt;That 87.6% difference is the story of this post.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;This is Week 14 of my DevOps Micro Internship. The project: containerize a React app two ways, compare the results, and explain what changed and why it matters.&lt;/p&gt;

&lt;p&gt;I am running everything on an Azure VM (Ubuntu 24.04 LTS) with Docker auto-installed via cloud-init.&lt;/p&gt;

&lt;p&gt;The React app: &lt;a href="https://github.com/pravinmishraaws/my-react-app" rel="noopener noreferrer"&gt;https://github.com/pravinmishraaws/my-react-app&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  First: The .dockerignore
&lt;/h2&gt;

&lt;p&gt;Before writing a single Dockerfile, I created a .dockerignore to keep things that should never be in an image out of the build context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node_modules
build
.dockerignore
.git
.gitignore
*.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is especially important for node_modules. If you do not exclude it, Docker copies your entire local node_modules into the build context, which defeats the whole purpose of running npm ci inside the container.&lt;/p&gt;




&lt;h2&gt;
  
  
  Approach 1: Single-Stage Baseline (Dockerfile.single)
&lt;/h2&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:18-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="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="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="k"&gt;RUN &lt;/span&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; serve
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3000&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["serve", "-s", "build", "-l", "3000"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This image does everything in one go. Install dependencies, build the app, serve it. Simple.&lt;/p&gt;

&lt;p&gt;The problem is that everything stays. Node.js, npm, all 1,342 packages, build tools. None of that is needed to serve a built React app. But it is all sitting there in the image.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result: 760MB&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Build command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;-f&lt;/span&gt; Dockerfile.single &lt;span class="nt"&gt;-t&lt;/span&gt; react-single:latest &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; react-single &lt;span class="nt"&gt;-p&lt;/span&gt; 3000:3000 &lt;span class="nt"&gt;--restart&lt;/span&gt; unless-stopped react-single:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Approach 2: Multi-Stage Build (Dockerfile)
&lt;/h2&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 React app&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:18-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="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="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 with nginx&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; nginx:alpine&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /app/build /usr/share/nginx/html&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 80&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["nginx", "-g", "daemon off;"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two stages. Stage 1 builds the app. Stage 2 starts completely fresh with nginx:alpine and picks up only the finished build/ folder from Stage 1.&lt;/p&gt;

&lt;p&gt;Node.js never makes it into the final image. Neither do npm or any of those 1,342 packages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result: 94MB&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Build command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; react-multi:latest &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; react-multi &lt;span class="nt"&gt;-p&lt;/span&gt; 80:80 &lt;span class="nt"&gt;--restart&lt;/span&gt; unless-stopped react-multi:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Comparison
&lt;/h2&gt;



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

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Image&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;react-single:latest&lt;/td&gt;
&lt;td&gt;760 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;react-multi:latest&lt;/td&gt;
&lt;td&gt;94 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reduction&lt;/td&gt;
&lt;td&gt;87.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both containers ran simultaneously. Both loaded the same React app. The only difference was what was inside each image.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Matters Beyond the Numbers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Security&lt;/strong&gt;: Every package not in the final image cannot be exploited. The multi-stage image has no Node.js, no npm, no build tools. An attacker who somehow gets into that container finds a bare Nginx server. Nothing else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CI/CD Speed&lt;/strong&gt;: Smaller images push and pull faster. If your pipeline deploys 10 times a day and each deployment pulls a 760MB image instead of a 94MB one, that is a significant amount of wasted time over weeks and months.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer Caching&lt;/strong&gt;: Notice that both Dockerfiles copy package.json before the rest of the source code. This is intentional. Docker caches each layer. If your dependencies have not changed, Docker skips the npm ci step entirely on the next build and jumps straight to copying your source. This alone can shave minutes off build times.&lt;/p&gt;




&lt;h2&gt;
  
  
  Running Both Simultaneously
&lt;/h2&gt;

&lt;p&gt;One of the most satisfying parts of this project was running both containers at the same time on the same VM:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;CONTAINER ID   IMAGE                 PORTS                    NAMES
&lt;/span&gt;&lt;span class="gp"&gt;837fb6d5def2   react-multi:latest    0.0.0.0:80-&amp;gt;&lt;/span&gt;80/tcp       react-multi
&lt;span class="gp"&gt;66efa6b350bf   react-single:latest   0.0.0.0:3000-&amp;gt;&lt;/span&gt;3000/tcp   react-single
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Opening both in the browser showed the same React app on two different ports, proving the multi-stage approach produces an identical result in a fraction of the space.&lt;/p&gt;




&lt;h2&gt;
  
  
  Full Project
&lt;/h2&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/vivianokose/cloud-vm-docker-deploy" rel="noopener noreferrer"&gt;https://github.com/vivianokose/cloud-vm-docker-deploy&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;See you in the next one.&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%2Ft28qcjlb3w8wuvm8tu2g.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%2Ft28qcjlb3w8wuvm8tu2g.png" alt="1" width="800" height="368"&gt;&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%2F4gd9u7pf492jtzzgsvnr.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%2F4gd9u7pf492jtzzgsvnr.png" alt="2" width="800" height="516"&gt;&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%2Fm56qbq3u7h4dxorjsk1r.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%2Fm56qbq3u7h4dxorjsk1r.png" alt="3" width="800" height="521"&gt;&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%2Fcm3kreood2m33r4bzw96.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%2Fcm3kreood2m33r4bzw96.png" alt="4" width="800" height="365"&gt;&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%2Fktu3lasv9voz77nhwwiz.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%2Fktu3lasv9voz77nhwwiz.png" alt="5" width="800" height="312"&gt;&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%2F7zhfyfllzg1ked64ks21.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%2F7zhfyfllzg1ked64ks21.png" alt="6" width="800" height="526"&gt;&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%2Fdvvewnbogykl87m6lkhs.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%2Fdvvewnbogykl87m6lkhs.png" alt="7" width="692" height="116"&gt;&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%2Fnfn7zn9h123apuzotpzh.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%2Fnfn7zn9h123apuzotpzh.png" alt="8" width="746" height="252"&gt;&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%2Fj0vd0d0g2o3152yb7z6i.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%2Fj0vd0d0g2o3152yb7z6i.png" alt="9" width="742" height="307"&gt;&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%2Fmbi4unefjcnw25t3clvb.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%2Fmbi4unefjcnw25t3clvb.png" alt="10" width="692" height="208"&gt;&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%2Ftzelsd98l8g0ouei5ou9.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%2Ftzelsd98l8g0ouei5ou9.png" alt="11" width="800" height="500"&gt;&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%2Ffhy9zxrvcq54h40dzp64.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%2Ffhy9zxrvcq54h40dzp64.png" alt="12" width="800" height="472"&gt;&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%2Fo2jl11ehyf8o1scjh2jq.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%2Fo2jl11ehyf8o1scjh2jq.png" alt="13" width="800" height="166"&gt;&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%2Fnveelfq91fakbdkwaw6j.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%2Fnveelfq91fakbdkwaw6j.png" alt="14" width="800" height="67"&gt;&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%2Fdf88st4kw1o11ewizdu4.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%2Fdf88st4kw1o11ewizdu4.png" alt="15" width="800" height="361"&gt;&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%2F12sceynxp7w1ly7bimw8.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%2F12sceynxp7w1ly7bimw8.png" alt="16" width="800" height="361"&gt;&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%2F9gsu8k687vzumlqk5hd9.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%2F9gsu8k687vzumlqk5hd9.png" alt="17" width="731" height="166"&gt;&lt;/a&gt;&lt;/p&gt;




</description>
      <category>docker</category>
      <category>react</category>
      <category>devops</category>
      <category>cloudcomputing</category>
    </item>
    <item>
      <title>How I Deployed a Live Website Using Docker on Azure (And Let Cloud-Init Do the Heavy Lifting)</title>
      <dc:creator>Vivian Chiamaka Okose</dc:creator>
      <pubDate>Thu, 16 Apr 2026 20:39:44 +0000</pubDate>
      <link>https://dev.to/vivian_okose/how-i-deployed-a-live-website-using-docker-on-azure-and-let-cloud-init-do-the-heavy-lifting-184m</link>
      <guid>https://dev.to/vivian_okose/how-i-deployed-a-live-website-using-docker-on-azure-and-let-cloud-init-do-the-heavy-lifting-184m</guid>
      <description>&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;1 Linux VM on Azure (Ubuntu 24.04 LTS, Standard D2lds v6)&lt;/li&gt;
&lt;li&gt;A cloud-init script that installed Docker automatically on first boot&lt;/li&gt;
&lt;li&gt;A Dockerized static website served by Nginx&lt;/li&gt;
&lt;li&gt;A live URL accessible from anywhere in the world&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 1: Provisioning the Azure VM
&lt;/h2&gt;

&lt;p&gt;I created the VM through the Azure Portal with these settings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Resource Group&lt;/td&gt;
&lt;td&gt;docker-project-rg&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VM Name&lt;/td&gt;
&lt;td&gt;docker-vm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Region&lt;/td&gt;
&lt;td&gt;UK South&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image&lt;/td&gt;
&lt;td&gt;Ubuntu 24.04 LTS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Size&lt;/td&gt;
&lt;td&gt;Standard D2lds v6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authentication&lt;/td&gt;
&lt;td&gt;SSH public key&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Under the Networking tab, I opened port 22 for SSH and port 80 for HTTP traffic by adding inbound rules to the Network Security Group.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: The Cloud-Init Script (This Is the Magic Part)
&lt;/h2&gt;

&lt;p&gt;Before launching the VM, I pasted this script under the Advanced tab in the Custom Data field:&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="c1"&gt;#cloud-config&lt;/span&gt;
&lt;span class="na"&gt;package_update&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;package_upgrade&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;apt-transport-https&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ca-certificates&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;curl&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;gnupg&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;lsb-release&lt;/span&gt;

&lt;span class="na"&gt;runcmd&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;apt-get update -y&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;apt-get install -y docker.io&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;systemctl enable docker&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;systemctl start docker&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;usermod -aG docker azureuser&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This script runs automatically the moment the VM boots. Docker installs itself, starts itself, and adds my user to the docker group. I had not even SSH'd in yet.&lt;/p&gt;

&lt;p&gt;When I later ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo cat&lt;/span&gt; /var/log/cloud-init-output.log | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The log confirmed everything. Docker was installed by the startup script, not by me. That is infrastructure automation doing exactly what it is supposed to do.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: SSH Into the VM
&lt;/h2&gt;

&lt;p&gt;Once deployment completed, I grabbed my public IP from the Azure Portal and connected:&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;chmod &lt;/span&gt;400 docker-vm_key.pem
ssh &lt;span class="nt"&gt;-i&lt;/span&gt; docker-vm_key.pem azureuser@4.234.163.212
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I verified Docker was running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;span class="c"&gt;# Docker version 29.1.3&lt;/span&gt;

docker ps
&lt;span class="c"&gt;# Empty, no containers yet. But Docker is alive.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 4: Clone the Static Website
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/pravinmishraaws/Azure-Static-Website.git
&lt;span class="nb"&gt;cd &lt;/span&gt;Azure-Static-Website
&lt;span class="nb"&gt;ls&lt;/span&gt;
&lt;span class="c"&gt;# README.md  index.html&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple. Just an index.html. That is all we need.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Write the Dockerfile
&lt;/h2&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; nginx:alpine&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /usr/share/nginx/html/&lt;span class="k"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . /usr/share/nginx/html&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let me break this down:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FROM nginx:alpine&lt;/strong&gt; — we are using a lightweight version of Nginx as our base image. Alpine Linux is tiny, which keeps our container small and fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RUN rm -rf /usr/share/nginx/html/&lt;/strong&gt;* — wipes out the default Nginx welcome page so our site shows instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;COPY . /usr/share/nginx/html&lt;/strong&gt; — copies everything in our current folder (including index.html) into the Nginx web root.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EXPOSE 80&lt;/strong&gt; — tells Docker this container will accept traffic on port 80.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Build the Image
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; static-site:latest &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docker pulls nginx:alpine, runs each step in the Dockerfile, and produces an image called static-site:latest. The whole process takes about a minute.&lt;/p&gt;

&lt;p&gt;After building, I checked the image sizes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Image&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;nginx:alpine&lt;/td&gt;
&lt;td&gt;93.5 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;static-site:latest&lt;/td&gt;
&lt;td&gt;92.9 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Our image is actually slightly smaller than the base because we replaced Nginx's default content with our own lighter files.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 7: Run the Container
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; static-site &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 80:80 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--restart&lt;/span&gt; unless-stopped &lt;span class="se"&gt;\&lt;/span&gt;
  static-site:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Breaking down the flags:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-d&lt;/code&gt; runs the container in the background&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--name static-site&lt;/code&gt; gives it a friendly name&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-p 80:80&lt;/code&gt; maps port 80 on the VM to port 80 inside the container&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--restart unless-stopped&lt;/code&gt; means it comes back automatically after a VM reboot&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 8: Verify and Open in Browser
&lt;/h2&gt;



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

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;CONTAINER ID   IMAGE                PORTS                    NAMES
&lt;/span&gt;&lt;span class="gp"&gt;83cb16a6cb44   static-site:latest   0.0.0.0:80-&amp;gt;&lt;/span&gt;80/tcp       static-site
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I opened &lt;code&gt;http://4.234.163.212&lt;/code&gt; in my browser.&lt;/p&gt;

&lt;p&gt;The site loaded.&lt;/p&gt;

&lt;p&gt;I cannot fully explain how good that felt. Three months into learning DevOps, and I just served a live website from inside a Docker container running on a cloud VM I spun up myself.&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;Cloud-init is a game changer.&lt;/strong&gt; The idea that a VM can configure itself on boot, without any human touching it, is what separates manual setups from real infrastructure automation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Containers are not complicated.&lt;/strong&gt; A Dockerfile is just a recipe. Build the recipe, get an image. Run the image, get a container. That is it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;nginx:alpine is perfect for static sites.&lt;/strong&gt; Small, fast, and it just works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The --restart flag matters.&lt;/strong&gt; In production, you want your containers to survive reboots. Always add &lt;code&gt;--restart unless-stopped&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Full Project
&lt;/h2&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/vivianokose/cloud-vm-docker-deploy" rel="noopener noreferrer"&gt;https://github.com/vivianokose/cloud-vm-docker-deploy&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;See you in the next one.&lt;/p&gt;




</description>
      <category>docker</category>
      <category>azure</category>
      <category>devops</category>
      <category>cloudcomputing</category>
    </item>
  </channel>
</rss>
