<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Devops articles - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Devops articles - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Sat, 30 May 2026 16:31:12 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/devops-articles/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Local DevOps HomeLab with Docker, Kubernetes, and Ansible ]]>
                </title>
                <description>
                    <![CDATA[ The first time I tried to follow a DevOps tutorial, it told me to sign up for AWS. I did. I spun up an EC2 instance, followed along for an hour, and then forgot to shut it down. A week later I had a $ ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-local-devops-homelab-with-docker-kubernetes-and-ansible/</link>
                <guid isPermaLink="false">69dd667c217f5dfcbd55b7b4</guid>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Homelab ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops articles ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Osomudeya Zudonu ]]>
                </dc:creator>
                <pubDate>Mon, 13 Apr 2026 21:56:12 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/1e970f8b-eb52-4582-9c98-13cbce867c89.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>The first time I tried to follow a DevOps tutorial, it told me to sign up for AWS.</p>
<p>I did. I spun up an EC2 instance, followed along for an hour, and then forgot to shut it down. A week later I had a $34 bill for a machine running nothing.</p>
<p>That was the last time I practiced on someone else's infrastructure.</p>
<p>Everything in this guide runs on your laptop. No cloud account, no credit card, no bill at the end of the month. By the end, you'll be able to spin up a multi-server environment from scratch, configure it automatically with Ansible, serve a site you wrote yourself, and diagnose what breaks when you intentionally destroy it.</p>
<p>That last part is where the actual learning happens.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you start, make sure you have:</p>
<ul>
<li><p>A laptop with at least 8GB of RAM (16GB is better)</p>
</li>
<li><p>At least 20GB of free disk space</p>
</li>
<li><p>Windows, macOS, or Linux operating system</p>
</li>
<li><p>Administrator access to your computer</p>
</li>
<li><p>Virtualization enabled in your BIOS/UEFI settings</p>
</li>
<li><p>A stable internet connection for the initial downloads</p>
</li>
</ul>
<p>Knowledge and comfort level:</p>
<ul>
<li><p>You should be comfortable using a terminal (running commands, changing directories, and editing small text files with whatever editor you like).</p>
</li>
<li><p>Basic familiarity with concepts like “a server,” “SSH,” and “a port” helps, but you don't need prior experience with Docker, Kubernetes, Vagrant, or Ansible. This guide introduces them as you go.</p>
</li>
</ul>
<p>If you can follow step-by-step instructions and read error output without panicking, you're ready.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-what-is-devops">What is DevOps?</a></p>
</li>
<li><p><a href="#heading-why-build-a-local-lab">Why Build a Local Lab?</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-docker">How to Set Up Docker</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-kubernetes">How to Set Up Kubernetes</a></p>
</li>
<li><p><a href="#heading-how-to-install-kubectl">How to Install kubectl</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-vagrant">How to Set Up Vagrant</a></p>
</li>
<li><p><a href="#heading-how-to-install-ansible">How to Install Ansible</a></p>
</li>
<li><p><a href="#heading-how-to-build-your-first-devops-project">How to Build Your First DevOps Project</a></p>
</li>
<li><p><a href="#heading-how-to-break-your-lab-on-purpose">How to Break Your Lab on Purpose</a></p>
</li>
<li><p><a href="#heading-what-you-can-now-do">What You Can Now Do</a></p>
</li>
</ol>
<h2 id="heading-what-is-devops">What is DevOps?</h2>
<p>DevOps is the practice of breaking down the wall between software development and IT operations teams.</p>
<p>Traditionally, developers write code and hand it off to operations teams to deploy and maintain. That handoff causes delays, misunderstandings, and outages. DevOps is what happens when both teams work together from the start.</p>
<p>The tools you'll install in this guide each solve a specific part of that process:</p>
<ul>
<li><p><strong>Docker</strong> packages your application and everything it needs into a portable container that runs the same way on any machine.</p>
</li>
<li><p><strong>Kubernetes</strong> manages multiple containers at scale, handling restarts, networking, and load balancing automatically.</p>
</li>
<li><p><strong>Vagrant</strong> creates and manages virtual machine environments so your whole team always works on identical setups.</p>
</li>
<li><p><strong>Ansible</strong> automates repetitive configuration tasks across many servers without writing a script for each one.</p>
</li>
</ul>
<h2 id="heading-why-build-a-local-lab">Why Build a Local Lab?</h2>
<p>A local lab gives you a safe place to break things, fix them, and learn from that process without any cost or risk.</p>
<p>Here's what you get with a local setup:</p>
<ul>
<li><p><strong>Zero cost.</strong> No cloud bills, no surprise charges, and no credit card required.</p>
</li>
<li><p><strong>Works offline.</strong> Practice anywhere, even without internet after the initial setup.</p>
</li>
<li><p><strong>Full control.</strong> You manage every layer from the OS up to the application.</p>
</li>
<li><p><strong>Safe experimentation.</strong> Break things freely. Nothing here affects production.</p>
</li>
<li><p><strong>Fast feedback.</strong> No waiting for cloud resources to spin up. Everything runs on your machine.</p>
</li>
</ul>
<p>The tradeoff is resource limits. Your laptop's CPU and RAM are the ceiling. You can't simulate large-scale deployments, and some cloud-native services like AWS Lambda or S3 have no direct local equivalent. But for learning core DevOps workflows, none of that matters.</p>
<h2 id="heading-how-to-set-up-docker">How to Set Up Docker</h2>
<p>Docker is the foundation of this lab. Every other tool in this guide either runs inside Docker containers or works alongside them.</p>
<h3 id="heading-how-to-install-docker-on-windows">How to Install Docker on Windows</h3>
<p>First, enable virtualization in your BIOS:</p>
<ol>
<li><p>Restart your computer and enter BIOS/UEFI setup. The key is usually F2, F10, Del, or Esc during boot.</p>
</li>
<li><p>Find the virtualization setting. It's usually listed as Intel VT-x, AMD-V, SVM, or Virtualization Technology.</p>
</li>
<li><p>Enable it, save your changes, and exit.</p>
</li>
</ol>
<p>Then install Docker Desktop:</p>
<ol>
<li><p>Download Docker Desktop from <a href="https://www.docker.com/products/docker-desktop/">Docker's official website</a>.</p>
</li>
<li><p>Run the installer and follow the prompts.</p>
</li>
<li><p>Enable WSL 2 (Windows Subsystem for Linux) when asked.</p>
</li>
<li><p>Restart your computer.</p>
</li>
<li><p>Open Docker Desktop from the Start menu and wait for the whale icon in the taskbar to stop animating.</p>
</li>
</ol>
<p><strong>Troubleshooting:</strong> If Docker fails to start, run this in PowerShell as Administrator to verify virtualization is active:</p>
<pre><code class="language-powershell">systeminfo | findstr "Hyper-V Requirements"
</code></pre>
<p>All items should show "Yes". If they don't, revisit your BIOS settings.</p>
<h3 id="heading-how-to-install-docker-on-mac">How to Install Docker on Mac</h3>
<ol>
<li><p>Download Docker Desktop for Mac from <a href="https://www.docker.com/products/docker-desktop/">Docker's website</a>.</p>
</li>
<li><p>Open the downloaded <code>.dmg</code> file and drag Docker to your Applications folder.</p>
</li>
<li><p>Open Docker from Applications.</p>
</li>
<li><p>Enter your password when prompted.</p>
</li>
<li><p>Wait for the whale icon in the menu bar to stop animating.</p>
</li>
</ol>
<h3 id="heading-how-to-install-docker-on-linux">How to Install Docker on Linux</h3>
<p>Run these commands in order:</p>
<pre><code class="language-bash"># Update your package lists
sudo apt-get update

# Install prerequisites
sudo apt-get install apt-transport-https ca-certificates curl software-properties-common

# Add Docker's official GPG key
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -

# Add the Docker repository
sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"

# Update and install Docker
sudo apt-get update
sudo apt-get install docker-ce

# Start and enable Docker
sudo systemctl start docker
sudo systemctl enable docker

# Add your user to the docker group
sudo usermod -aG docker $USER
</code></pre>
<p>Log out and back in for the group change to take effect.</p>
<h3 id="heading-how-to-test-docker">How to Test Docker</h3>
<p>Run this command:</p>
<pre><code class="language-bash">docker run hello-world
</code></pre>
<p>If you see "Hello from Docker!" then Docker is working correctly.</p>
<p>Docker is set up. Next, you'll install Kubernetes to manage containers at scale.</p>
<h2 id="heading-how-to-set-up-kubernetes">How to Set Up Kubernetes</h2>
<p>Kubernetes manages containers at scale. For a local lab, you have four options. Here's how to choose:</p>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Best for</th>
<th>RAM needed</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Minikube</strong></td>
<td>Beginners. Easiest setup, built-in dashboard</td>
<td>2GB+</td>
</tr>
<tr>
<td><strong>Kind</strong></td>
<td>Faster startup, works well inside CI pipelines</td>
<td>1GB+</td>
</tr>
<tr>
<td><strong>k3s</strong></td>
<td>Low-resource machines. Lightweight but production-like</td>
<td>512MB+</td>
</tr>
<tr>
<td><strong>kubeadm</strong></td>
<td>Learning how clusters are actually bootstrapped in production</td>
<td>2GB+ per node</td>
</tr>
</tbody></table>
<p>If you're just starting out, use Minikube. It has the simplest setup and a visual dashboard that helps you understand what's happening inside the cluster.</p>
<p>If your laptop has 8GB RAM or less, use k3s. It runs lean and behaves closer to a real cluster than Minikube does.</p>
<p>Use kubeadm only if you want to understand how Kubernetes nodes join a cluster — it requires more manual steps and isn't beginner-friendly.</p>
<h3 id="heading-how-to-install-minikube-recommended-for-beginners">How to Install Minikube (Recommended for Beginners)</h3>
<p>Minikube creates a single-node Kubernetes cluster on your laptop.</p>
<p>On Windows:</p>
<ol>
<li><p>Download the Minikube installer from <a href="https://github.com/kubernetes/minikube/releases">Minikube's GitHub releases page</a>.</p>
</li>
<li><p>Run the <code>.exe</code> installer.</p>
</li>
<li><p>Open Command Prompt as Administrator and start Minikube:</p>
</li>
</ol>
<pre><code class="language-cmd">minikube start --driver=docker
</code></pre>
<p>On Mac:</p>
<pre><code class="language-bash">brew install minikube
minikube start --driver=docker
</code></pre>
<p>On Linux:</p>
<pre><code class="language-bash">curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64
chmod +x minikube-linux-amd64
sudo mv minikube-linux-amd64 /usr/local/bin/minikube
minikube start --driver=docker
</code></pre>
<p>Test your cluster:</p>
<pre><code class="language-bash">minikube status
minikube dashboard
</code></pre>
<h3 id="heading-how-to-install-k3s-recommended-for-low-ram-machines">How to Install k3s (Recommended for Low-RAM Machines)</h3>
<p>k3s is a lightweight version of Kubernetes that installs in under a minute. It runs lean and behaves like a real cluster — not a simplified demo version.</p>
<p>On Linux (and Mac via Multipass):</p>
<pre><code class="language-bash">curl -sfL https://get.k3s.io | sh -
</code></pre>
<p>That single command installs k3s and runs it automatically in the background. Check that it is running:</p>
<pre><code class="language-bash">sudo k3s kubectl get nodes
</code></pre>
<p>You should see one node with status <code>Ready</code>.</p>
<p>On Mac directly — k3s doesn't run natively on macOS. Use <a href="https://multipass.run">Multipass</a> to spin up a lightweight Ubuntu VM first, then run the install command inside it.</p>
<p>On Windows — use WSL2 (Ubuntu), then run the install command inside your WSL2 terminal.</p>
<h3 id="heading-how-to-install-kind-kubernetes-in-docker">How to Install Kind (Kubernetes IN Docker)</h3>
<p>Kind runs a full Kubernetes cluster inside Docker containers. It starts faster than Minikube and is useful if you want to run multiple clusters simultaneously.</p>
<pre><code class="language-bash"># Mac or Linux
brew install kind

# Windows
choco install kind
</code></pre>
<p>Create a cluster:</p>
<pre><code class="language-bash">kind create cluster --name my-local-lab
</code></pre>
<h3 id="heading-how-to-install-kubeadm-for-understanding-cluster-bootstrap">How to Install kubeadm (For Understanding Cluster Bootstrap)</h3>
<p>kubeadm is the tool Kubernetes uses to initialize and join nodes in a real cluster. Use this when you want to understand what happens under the hood — not as your daily driver.</p>
<p>It requires at least two machines (or VMs). The setup is more involved than the options above. Follow the <a href="https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/install-kubeadm/">official kubeadm installation guide</a> for your OS, then initialize your cluster:</p>
<pre><code class="language-bash">sudo kubeadm init --pod-network-cidr=10.244.0.0/16
</code></pre>
<p>After init, join worker nodes using the command kubeadm prints at the end of the output.</p>
<h3 id="heading-how-to-install-kubectl">How to Install kubectl</h3>
<p>kubectl is the command-line tool you use to interact with any Kubernetes cluster.</p>
<p>On Windows:</p>
<p>Download <code>kubectl.exe</code> from <a href="https://kubernetes.io/docs/tasks/tools/install-kubectl-windows/">Kubernetes' website</a> and place it in a directory that is in your PATH. Or install with Chocolatey:</p>
<pre><code class="language-cmd">choco install kubernetes-cli
</code></pre>
<p>On Mac:</p>
<pre><code class="language-bash">brew install kubectl
</code></pre>
<p>On Linux:</p>
<pre><code class="language-bash">curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x kubectl
sudo mv kubectl /usr/local/bin/kubectl
</code></pre>
<p>Test it:</p>
<pre><code class="language-bash">kubectl get pods --all-namespaces
</code></pre>
<p>On a fresh cluster, you'll see system pods running in the <code>kube-system</code> namespace — things like <code>coredns</code> and <code>storage-provisioner</code>. That's the expected output. It means your cluster is up and kubectl can talk to it.</p>
<p>Kubernetes is running. Next is Vagrant. But before that, there's one important distinction worth making.</p>
<h4 id="heading-docker-vs-vagrant-they-arent-the-same-thing">Docker vs Vagrant — they aren't the same thing</h4>
<p>Docker creates containers: lightweight processes that share your operating system's kernel. Vagrant creates full virtual machines: isolated computers with their own OS running inside your laptop.</p>
<p>Containers are fast and small. VMs are heavier but behave exactly like real servers. You'll use both in this lab for different reasons.</p>
<h2 id="heading-how-to-set-up-vagrant">How to Set Up Vagrant</h2>
<p>Vagrant lets you create and manage reproducible virtual machine environments. It is ideal for simulating multi-server setups on a single laptop.</p>
<h3 id="heading-how-to-install-vagrant-on-windows">How to Install Vagrant on Windows</h3>
<ol>
<li><p>Download and install <a href="https://www.virtualbox.org/wiki/Downloads">VirtualBox</a> with default options.</p>
</li>
<li><p>Download and install <a href="https://developer.hashicorp.com/vagrant/downloads">Vagrant</a>.</p>
</li>
<li><p>Restart your computer if prompted.</p>
</li>
</ol>
<p><strong>Note:</strong> VirtualBox and Hyper-V can't run at the same time on Windows. Check if Hyper-V is active:</p>
<pre><code class="language-cmd">systeminfo | findstr "Hyper-V"
</code></pre>
<p>If it's enabled, you have two options: switch to the Hyper-V Vagrant provider, or disable Hyper-V with:</p>
<pre><code class="language-powershell">Disable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All
</code></pre>
<p>Restart after disabling.</p>
<h3 id="heading-how-to-install-vagrant-on-mac-and-linux">How to Install Vagrant on Mac and Linux</h3>
<p>On Mac:</p>
<ol>
<li><p>Download and install <a href="https://www.virtualbox.org/wiki/Downloads">VirtualBox</a>.</p>
</li>
<li><p>After installation, open <strong>System Preferences &gt; Security &amp; Privacy &gt; General</strong>. You will see a message saying system software from Oracle was blocked. Click <strong>Allow</strong> and restart your Mac. Without this step, VirtualBox will not run.</p>
</li>
<li><p>Download and install <a href="https://developer.hashicorp.com/vagrant/downloads">Vagrant</a>.</p>
</li>
</ol>
<p><strong>Note for Apple Silicon (M1/M2/M3) Macs:</strong> VirtualBox support on Apple Silicon is still limited. If you're on an M-series Mac, use <a href="https://mac.getutm.app/">UTM</a> as your VM provider instead, or use Multipass which works natively on Apple Silicon.</p>
<p>On Linux:</p>
<ol>
<li><p>Download and install <a href="https://www.virtualbox.org/wiki/Downloads">VirtualBox</a>.</p>
</li>
<li><p>Download and install <a href="https://developer.hashicorp.com/vagrant/downloads">Vagrant</a>.</p>
</li>
</ol>
<p>Verify both are installed:</p>
<pre><code class="language-bash">vboxmanage --version
vagrant --version
</code></pre>
<h3 id="heading-how-to-create-your-first-vagrant-environment">How to Create Your First Vagrant Environment</h3>
<p>Create a new directory for your project. Inside it, create a file named <code>Vagrantfile</code> with this content:</p>
<pre><code class="language-ruby">Vagrant.configure("2") do |config|
  config.vm.box = "ubuntu/focal64"

  # Create a private network between VMs
  config.vm.network "private_network", type: "dhcp"

  # Forward port 8080 on your laptop to port 80 on the VM
  config.vm.network "forwarded_port", guest: 80, host: 8080

  # Install Nginx when the VM starts
  config.vm.provision "shell", inline: &lt;&lt;-SHELL
    apt-get update
    apt-get install -y nginx
    echo "Hello from Vagrant!" &gt; /var/www/html/index.html
  SHELL
end
</code></pre>
<p>Start the VM:</p>
<pre><code class="language-bash">vagrant up
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/342f11ad-7c7d-40d2-a810-113b8c71edac.png" alt="screnshot showing VB server and terminal installation processes" style="display:block;margin:0 auto" width="1848" height="323" loading="lazy">

<p>Visit <code>http://localhost:8080</code> in your browser. You should see "Hello from Vagrant!"</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/bcd66a76-4a5b-4f26-bb7e-e203672968d8.png" alt="screenshot showing &quot;Hello from Vagrant!&quot; in browser" style="display:block;margin:0 auto" width="643" height="483" loading="lazy">

<h4 id="heading-troubleshooting-ssh-on-windows">Troubleshooting SSH on Windows</h4>
<p>If <code>vagrant ssh</code> fails, try:</p>
<pre><code class="language-bash">vagrant ssh -- -v
</code></pre>
<p>Or connect manually:</p>
<pre><code class="language-bash">ssh -i .vagrant/machines/default/virtualbox/private_key vagrant@127.0.0.1 -p 2222
</code></pre>
<h3 id="heading-how-to-create-a-local-vagrant-box-without-internet">How to Create a Local Vagrant Box Without Internet</h3>
<p><strong>Note:</strong> Most readers can skip this. Only do this if you want to work fully offline after the initial setup.</p>
<ol>
<li><p>Download <a href="https://ubuntu.com/download/server">Ubuntu 20.04 LTS</a> and save the <code>.iso</code> file locally.</p>
</li>
<li><p>Open VirtualBox and create a new VM: Name it <code>ubuntu-devops</code>, Type: Linux, Version: Ubuntu (64-bit).</p>
</li>
<li><p>Assign 2048MB RAM and a 20GB VDI disk.</p>
</li>
<li><p>Attach the <code>.iso</code> under Storage &gt; Optical Drive.</p>
</li>
<li><p>Start the VM and complete the Ubuntu installation.</p>
</li>
<li><p>Once installed, shut down the VM and run:</p>
</li>
</ol>
<pre><code class="language-bash">VBoxManage list vms
vagrant package --base "ubuntu-devops" --output ubuntu2004.box
vagrant box add ubuntu2004 ubuntu2004.box
</code></pre>
<p>You now have a reusable local box that works without internet.</p>
<p>You can spin up virtual machines. Next is Ansible, which automates what goes inside them.</p>
<h2 id="heading-how-to-install-ansible">How to Install Ansible</h2>
<p>Ansible automates configuration and software installation across multiple servers. Instead of SSH-ing into ten machines and running the same commands manually, you write a playbook once and Ansible handles the rest.</p>
<h3 id="heading-how-to-install-ansible-on-windows">How to Install Ansible on Windows</h3>
<p>Ansible doesn't run natively on Windows. You need to use it through WSL (Windows Subsystem for Linux).</p>
<ol>
<li>Open PowerShell as Administrator and enable WSL:</li>
</ol>
<pre><code class="language-powershell">dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart
dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart
</code></pre>
<ol>
<li><p>Restart your computer.</p>
</li>
<li><p>Install Ubuntu from the Microsoft Store.</p>
</li>
<li><p>Open Ubuntu and install Ansible:</p>
</li>
</ol>
<pre><code class="language-bash">sudo apt update
sudo apt install software-properties-common
sudo apt-add-repository --yes --update ppa:ansible/ansible
sudo apt install ansible
</code></pre>
<h3 id="heading-how-to-install-ansible-on-mac">How to Install Ansible on Mac</h3>
<pre><code class="language-bash">brew install ansible
</code></pre>
<h3 id="heading-how-to-install-ansible-on-linux">How to Install Ansible on Linux</h3>
<pre><code class="language-bash"># Ubuntu/Debian
sudo apt update
sudo apt install software-properties-common
sudo apt-add-repository --yes --update ppa:ansible/ansible
sudo apt install ansible

# Red Hat/CentOS
sudo yum install ansible
</code></pre>
<h3 id="heading-how-to-test-ansible">How to Test Ansible</h3>
<p>Create a file called <code>hosts</code> in your current directory:</p>
<pre><code class="language-ini">[local]
localhost ansible_connection=local
</code></pre>
<p>Create a file called <code>playbook.yml</code> in the same directory:</p>
<pre><code class="language-yaml">---
- name: Test playbook
  hosts: local
  tasks:
    - name: Print a message
      debug:
        msg: "Ansible is working!"
</code></pre>
<p>Run the playbook, passing the local <code>hosts</code> file with <code>-i</code>:</p>
<pre><code class="language-bash">ansible-playbook -i hosts playbook.yml
</code></pre>
<p>You should see the message "Ansible is working!" in the output.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/081e6ff3-b983-42a0-960e-5340bbd24e3b.png" alt="screenshot showing ansible playbook complete terminal installation" style="display:block;margin:0 auto" width="849" height="287" loading="lazy">

<p>Alright, all your tools are installed. Now you'll use them together to build something real.</p>
<h2 id="heading-how-to-build-your-first-devops-project">How to Build Your First DevOps Project</h2>
<p>You can find the entire code for this lab in this repo: <a href="https://github.com/Osomudeya/homelab-demo-article">https://github.com/Osomudeya/homelab-demo-article</a></p>
<p>Now you'll put these tools together in one project. Each tool will perform its actual job, and nothing is forced.</p>
<p><strong>Before you start,</strong> create a fresh directory for this project. Don't run it inside the directory you used to test Vagrant earlier, as the Vagrantfile here is different and will conflict.</p>
<p>You'll be building a two-VM environment: one machine serves a web page you write yourself inside a Docker container, and the other runs a MariaDB database. Vagrant creates the machines and Ansible configures them. The page you see at the end is yours.</p>
<h3 id="heading-step-1-create-the-project-directory">Step 1: Create the Project Directory</h3>
<pre><code class="language-bash">mkdir devops-lab-project &amp;&amp; cd devops-lab-project
</code></pre>
<h3 id="heading-step-2-write-your-site-content">Step 2: Write Your Site Content</h3>
<p>Create a file called <code>index.html</code> in the project directory. Write whatever you want on this page — it's what you'll see in your browser at the end:</p>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;&lt;title&gt;My DevOps Lab&lt;/title&gt;&lt;/head&gt;
  &lt;body&gt;
    &lt;h1&gt;My DevOps Lab&lt;/h1&gt;
    &lt;p&gt;Provisioned by Vagrant. Configured by Ansible. Served by Docker.&lt;/p&gt;
    &lt;p&gt;Built on a laptop. No cloud account needed.&lt;/p&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre>
<p>Change the text to whatever you like. This is your page.</p>
<h3 id="heading-step-3-write-the-vagrantfile">Step 3: Write the Vagrantfile</h3>
<p>Create a file called <code>Vagrantfile</code> in the same directory:</p>
<pre><code class="language-ruby">Vagrant.configure("2") do |config|
  config.vm.box = "ubuntu/focal64"

  config.vm.define "web" do |web|
    web.vm.network "private_network", ip: "192.168.33.10"
    web.vm.network "forwarded_port", guest: 80, host: 8080
  end

  config.vm.define "db" do |db|
    db.vm.network "private_network", ip: "192.168.33.11"
  end
end
</code></pre>
<h3 id="heading-step-4-start-the-virtual-machines">Step 4: Start the Virtual Machines</h3>
<pre><code class="language-bash">vagrant up
</code></pre>
<p>The first run downloads the <code>ubuntu/focal64</code> box, which is around 500MB.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/264866b0-9977-490e-96a3-69b3070be589.png" alt="screenshot showing virtualbox installation processes in terminal" style="display:block;margin:0 auto" width="867" height="377" loading="lazy">

<p>Expect this to take 10–30 minutes depending on your connection. Subsequent runs will be much faster since the box is cached locally.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/118d2fb2-70f6-41e8-afb2-6f45fb895e98.png" alt="screenshot showing 2 virtualbox servers &quot;running&quot; in VB manager" style="display:block;margin:0 auto" width="926" height="396" loading="lazy">

<h3 id="heading-step-5-create-the-ansible-inventory">Step 5: Create the Ansible Inventory</h3>
<p>Create a file called <code>inventory</code> in the same directory:</p>
<pre><code class="language-ini">[webservers]
192.168.33.10 ansible_user=vagrant ansible_ssh_private_key_file=.vagrant/machines/web/virtualbox/private_key

[dbservers]
192.168.33.11 ansible_user=vagrant ansible_ssh_private_key_file=.vagrant/machines/db/virtualbox/private_key
</code></pre>
<p>Ansible uses the Vagrant-generated private keys so it can SSH in as the <code>vagrant</code> user. Host key checking for this lab is turned off in <code>ansible.cfg</code> (next step), not in the inventory.</p>
<h3 id="heading-step-6-create-the-ansible-config-file">Step 6: Create the Ansible Config File</h3>
<p>Before running the playbook, create a file called <code>ansible.cfg</code> in the same directory:</p>
<pre><code class="language-ini">[defaults]
inventory = inventory
host_key_checking = False
</code></pre>
<p>The inventory line tells Ansible to use the inventory file in this folder by default. host_key_checking = False tells Ansible not to verify SSH host keys when connecting to your Vagrant VMs. Without it, Ansible will fail with a Host key verification failed error on first connection because the VM's key is not yet in your known_hosts file.</p>
<p>These settings are for a local lab only. Do not use host_key_checking = False for production systems.</p>
<h3 id="heading-step-7-create-the-ansible-playbook">Step 7: Create the Ansible Playbook</h3>
<p>Create a file called <code>playbook.yml</code>:</p>
<pre><code class="language-yaml">---
- name: Configure web server
  hosts: webservers
  become: yes
  tasks:

    - name: Install Docker
      apt:
        name: docker.io
        state: present
        update_cache: yes

    - name: Start Docker service
      service:
        name: docker
        state: started
        enabled: yes

    # Create the directory that will hold your site content
    - name: Create web content directory
      file:
        path: /var/www/html
        state: directory
        mode: '0755'

    # This copies your index.html from your laptop into the VM
    - name: Copy site content to web server
      copy:
        src: index.html
        dest: /var/www/html/index.html

    # This mounts that file into the Nginx container so it serves your page
    # The -v flag connects /var/www/html on the VM to /usr/share/nginx/html inside the container
    - name: Run Nginx serving your content
      shell: |
        docker rm -f webapp 2&gt;/dev/null || true
        docker run -d --name webapp --restart always -p 80:80 \
          -v /var/www/html:/usr/share/nginx/html:ro nginx

- name: Configure database server
  hosts: dbservers
  become: yes
  tasks:

    # Hash sum mismatch on .deb downloads is often stale lists, a flaky mirror, or apt pipelining
    # behind NAT; fresh indices + Pipeline-Depth 0 usually fixes it on lab VMs.
    - name: Disable apt HTTP pipelining (mirror/proxy hash mismatch workaround)
      copy:
        dest: /etc/apt/apt.conf.d/99disable-pipelining
        content: 'Acquire::http::Pipeline-Depth "0";'
        mode: "0644"

    - name: Clear apt package index cache
      shell: apt-get clean &amp;&amp; rm -rf /var/lib/apt/lists/* /var/lib/apt/lists/auxfiles/*
      changed_when: true

    - name: Update apt cache after reset
      apt:
        update_cache: yes

    - name: Install MariaDB
      apt:
        name: mariadb-server
        state: present
        update_cache: no

    - name: Start MariaDB service
      service:
        name: mariadb
        state: started
        enabled: yes
</code></pre>
<p>Two lines worth paying attention to:</p>
<ul>
<li><p><code>src: index.html</code> — Ansible looks for this file in the same directory as the playbook. That is the file you wrote in Step 2.</p>
</li>
<li><p><code>-v /var/www/html:/usr/share/nginx/html:ro</code> — this mounts the directory from the VM into the Nginx container. The <code>:ro</code> means read-only. Nginx serves whatever is in that folder.</p>
</li>
</ul>
<h3 id="heading-step-8-run-the-playbook">Step 8: Run the Playbook</h3>
<pre><code class="language-bash">ansible-playbook -i inventory playbook.yml
</code></pre>
<p>You'll see task-by-task output as Ansible connects to each VM over SSH and configures it. A green <code>ok</code> or yellow <code>changed</code> next to each task means it worked. Red <code>fatal</code> means something failed.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/91241b41-981c-4e23-9dc4-8531e551c39e.png" alt="terminal screenshot of A green ok or yellow changed next to each task means it worked. Red fatal means something failed." style="display:block;margin:0 auto" width="875" height="267" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/c02db252-8aff-42e5-b937-d812d070a75b.png" alt="terminal screenshot of playbook run completion" style="display:block;margin:0 auto" width="867" height="425" loading="lazy">

<h3 id="heading-step-9-verify-the-setup">Step 9: Verify the Setup</h3>
<p>Open <code>http://localhost:8080</code> in your browser. You should see the page you wrote in Step 2 served from inside a Docker container, running on a Vagrant VM, configured automatically by Ansible.</p>
<p>If you see the page, every tool in this lab is working together.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/0d3d897b-3f51-46fb-b548-832cc5ec3272.png" alt="Browser showing localhost:8082 with the heading &quot;My DevOps Lab&quot; and the text &quot;Provisioned by Vagrant. Configured by Ansible. Served by Docker.&quot;" style="display:block;margin:0 auto" width="746" height="418" loading="lazy">

<h3 id="heading-step-9-clean-up-optional">Step 9: Clean Up (Optional)</h3>
<p>When you're done:</p>
<pre><code class="language-bash">vagrant destroy -f
</code></pre>
<p>This shuts down and deletes both VMs. Your <code>Vagrantfile</code>, <code>inventory</code>, <code>playbook.yml</code>, and <code>index.html</code> stay on disk — run <code>vagrant up</code> followed by <code>ansible-playbook -i inventory playbook.yml</code> any time to bring it all back.</p>
<p>Now that you have a working lab, let's use it properly.</p>
<h2 id="heading-how-to-break-your-lab-on-purpose">How to Break Your Lab on Purpose</h2>
<p>Following these steps has gotten you a running lab. Breaking things teaches you how everything actually works.</p>
<p>Here are five things to break and what to look for when you do.</p>
<h3 id="heading-break-1-crash-the-main-process-inside-the-container-and-watch-it-come-back">Break 1: Crash the Main Process Inside the Container (and Watch It Come Back)</h3>
<p>Doing this just proves that something inside the container can die (like a real bug or OOM), Docker can restart the container because of <code>--restart always</code>, and your site can come back without re-running Ansible.</p>
<p>After <code>vagrant ssh web</code>, every <code>docker</code> command below runs <strong>on the web VM</strong>. So keep your browser on your laptop at <a href="http://localhost:8080"><code>http://localhost:8080</code></a> (Vagrant forwards your host port to the VM’s port 80).</p>
<h4 id="heading-troubleshooting-if-your-lab-isnt-ready">Troubleshooting: If Your Lab Isn't Ready</h4>
<p>From your project folder on the host (your laptop) – unless the step says to run it on the VM:</p>
<ul>
<li><p>You ran <code>vagrant destroy -f</code>. Run <code>vagrant up</code>, then <code>ansible-playbook -i inventory playbook.yml</code>.</p>
</li>
<li><p><code>docker ps</code> shows <code>webapp</code> but status is Exited. On the web VM, run <code>sudo docker start webapp</code>, then <code>sudo docker ps</code> again.</p>
</li>
<li><p>There's no <code>webapp</code> row in <code>docker ps -a</code><strong>.</strong> Re-run <code>ansible-playbook -i inventory playbook.yml</code> on the host.</p>
</li>
</ul>
<p>If the playbook is already applied and <code>webapp</code> is Up, skip this section and start at step 1 under Steps (happy path) below. (Don't skip SSH or <code>docker ps</code>. You need the VM shell and a quick check before you run <code>docker exec</code>.)</p>
<h4 id="heading-steps-happy-path">Steps (happy path)</h4>
<ol>
<li>SSH into the web VM:</li>
</ol>
<pre><code class="language-plaintext">vagrant ssh web
</code></pre>
<ol>
<li><p>Confirm <code>webapp</code> is <strong>Up</strong>:</p>
<pre><code class="language-plaintext">sudo docker ps
</code></pre>
</li>
<li><p><strong>Break it on purpose:</strong> kill the container’s main process <strong>from inside</strong> (PID 1). That ends the container the same way a crashing app would, not the same as <code>docker stop</code> on the host:</p>
</li>
</ol>
<pre><code class="language-bash">sudo docker exec webapp sh -c 'sleep 5 &amp;&amp; kill 1'
</code></pre>
<p>The <code>sleep</code> 5 gives you a moment to switch to the browser. Right after you run the command, open or refresh <a href="http://localhost:8080"><code>http://localhost:8080</code></a>. You may catch a brief error or blank page while nothing is listening on port 80.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/3ac89703-63f3-45d8-954f-35adbd2c7dec.png" alt="Browser showing ERR_CONNECTION_RESET on localhost:8082 after the Nginx container process was killed" style="display:block;margin:0 auto" width="1242" height="1057" loading="lazy">

<ol>
<li>Watch Docker restart the container:</li>
</ol>
<pre><code class="language-bash">watch sudo docker ps -a
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/5c61d90d-61d6-4023-b3f5-e3eb427e8492.png" alt="Terminal running watch docker ps showing webapp container status as Up 10 seconds after automatic restart" style="display:block;margin:0 auto" width="1011" height="393" loading="lazy">

<p>Within a few seconds you should see <strong>Exited (137)</strong> become <strong>Up</strong> again. (Press Ctrl+C to exit <code>watch</code>.)</p>
<p>5. Refresh the browser. You should see the same HTML as before, because the files live on the VM under <code>/var/www/html</code> and are bind-mounted into the container; restarting only replaced the Nginx process, not those files.</p>
<h4 id="heading-why-not-docker-stop-or-docker-kill-on-the-host-for-this-demo"><strong>Why not</strong> <code>docker stop</code> <strong>or</strong> <code>docker kill</code> <strong>on the host for this demo?</strong></h4>
<p>Those commands go through Docker’s API. On many setups (including recent Docker), Docker treats them as you choosing to stop the container (<code>hasBeenManuallyStopped</code>), and <code>--restart always</code> may not bring the container back until you <code>docker start</code> it or similar.</p>
<p>Killing PID 1 from inside the container is treated more like an internal crash, so the restart policy you set in the playbook is the one you actually get to observe here.</p>
<p><strong>Kubernetes analogy:</strong> A pod whose containers exit can be restarted by the kubelet; a pod you delete does not come back by itself.</p>
<p><strong>What to observe (three separate checks):</strong></p>
<ol>
<li><p><strong>Exit code:</strong> After <code>kill 1</code>, <code>docker ps -a</code> should show the container exited with code 137, meaning the main process was killed by a signal. That confirms the container really died, not that you ran <code>docker stop</code> on the host.</p>
</li>
<li><p><strong>Restart delay vs browser:</strong> Watch how many seconds pass between Exited and Up in <code>docker ps -a</code>; that interval is Docker applying <code>--restart always</code>. That's separate from what you see in the browser: the browser only shows whether something is accepting connections on port 80 on the VM, so it may show an error or blank page during the gap even while Docker is about to restart the container.</p>
</li>
<li><p><strong>Content after recovery:</strong> After status is Up again, refresh the page. You should see the same HTML as before. That shows your content lives on the VM disk (mounted into the container with <code>-v</code>), not inside a file that vanishes when the container process restarts. The process was replaced, not your <code>index.html</code> on the host path.</p>
</li>
</ol>
<h3 id="heading-break-2-cause-a-container-name-conflict">Break 2: Cause a Container Name Conflict</h3>
<p>On a single Docker daemon (here, on your web VM), a container name is a <strong>unique label</strong>. Two running (or stopped) containers can't share the same name. Scripts and playbooks that always use <code>docker run --name webapp</code> without cleaning up first hit this error constantly and recognizing it saves time in real work.</p>
<p><strong>Before you start:</strong> Ansible already created one container named <code>webapp</code>.<br>Stay on the web VM (for example still inside <code>vagrant ssh web</code>) so the commands below run where that container lives.</p>
<p>So now, try to start a second container and also call it <code>webapp</code>. The image is plain <code>nginx</code> here on purpose – the point is the <strong>name clash</strong>, not matching your site’s ports or volume mounts.</p>
<pre><code class="language-plaintext">sudo docker run -d --name webapp nginx
</code></pre>
<p>What actually happens here is that Docker <strong>doesn't</strong> create a second container. It returns an error immediately. Your original <code>webapp</code> is unchanged.</p>
<p>This is because the name <code>webapp</code> is already registered to the existing container (the error shows that container’s ID). Docker refuses to reuse the name until the old container is removed or renamed.</p>
<p>Example error (your ID will differ):</p>
<pre><code class="language-plaintext">docker: Error response from daemon: Conflict. The container name "/webapp" is already in use by container "2e48b81a311c4b71cdc1e25e0df75a22296845c7eb53aab82f9ae739fb6410ec". You have to remove (or rename) that container to be able to reuse that name.
See 'docker run --help'.
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/1fd42c16-c28e-4539-9290-3583206eb8ff.png" alt="container name conflict terminal error screenshot" style="display:block;margin:0 auto" width="914" height="252" loading="lazy">

<p>To fix it, free the name, then create <code>webapp</code> again the same way the playbook does (publish port 80, mount your HTML, restart policy):</p>
<pre><code class="language-plaintext">sudo docker rm -f webapp
sudo docker run -d --name webapp --restart always -p 80:80 \
  -v /var/www/html:/usr/share/nginx/html:ro nginx
</code></pre>
<p>After that, your site should behave as before (refresh <a href="http://localhost:8080"><code>http://localhost:8080</code></a> from your laptop).</p>
<h4 id="heading-what-to-observe">What to observe:</h4>
<p>Read Docker’s Conflict message end to end. You should see that the name <code>/webapp</code> is already in use and a container ID pointing at the existing box. In production, that pattern means “something already claimed this name. Just remove it, rename it, or pick a different name before you run <code>docker run</code> again.”</p>
<h3 id="heading-break-3-make-ansible-fail-to-reach-a-vm">Break 3: Make Ansible Fail to Reach a VM</h3>
<p>Ansible separates “could not connect” from “connected, but a task broke.” The first is <strong>UNREACHABLE</strong>, the second is <strong>FAILED</strong>. Knowing which one you have tells you whether to fix network / SSH or playbook / packages / permissions.</p>
<p>On your laptop, in the project folder, edit <code>inventory</code> and change the web server address from <code>192.168.33.10</code> to an IP <strong>no VM uses</strong>, for example <code>192.168.33.99</code>. Save the file.</p>
<pre><code class="language-ini">[webservers]
192.168.33.99 ansible_user=vagrant ansible_ssh_private_key_file=.vagrant/machines/web/virtualbox/private_key
</code></pre>
<p>What you run (from the same project folder on the host):</p>
<pre><code class="language-bash">ansible-playbook -i inventory playbook.yml
</code></pre>
<p>After this, Ansible tries to SSH to <code>192.168.33.99</code>. Nothing on your lab network answers as that host (or SSH never succeeds), so Ansible <strong>never runs tasks</strong> on the web server. It stops that host with UNREACHABLE:</p>
<pre><code class="language-plaintext">fatal: [192.168.33.99]: UNREACHABLE! =&gt; {"msg": "Failed to connect to the host via ssh"}
</code></pre>
<p>This is realistic because the same message shape appears when the IP is wrong, the VM isn't running, a firewall blocks port 22, or the network is misconfigured. The common thread is <strong>no working SSH session</strong>.</p>
<p>Now it's time to put it back: restore <code>192.168.33.10</code> in <code>inventory</code> and run <code>ansible-playbook -i inventory playbook.yml</code> again. The web play should reach the VM and complete (assuming your lab is up).</p>
<p><strong>UNREACHABLE vs FAILED – what to observe:</strong></p>
<ul>
<li><p>If Ansible prints UNREACHABLE, you should assume it never opened SSH on that host and never ran tasks there. Go ahead and fix the connection (IP, VM up, firewall, key path) before you debug playbook logic.</p>
</li>
<li><p>If Ansible prints FAILED, you should assume SSH worked and a task returned an error. Read the task output for the real cause (package name, permissions, syntax), not the network first.</p>
</li>
</ul>
<p>When you debug later, you should look at the keyword Ansible prints: <strong>UNREACHABLE</strong> points to reachability while <strong>FAILED</strong> points to task output and the first failed task under that host.</p>
<h3 id="heading-break-4-fill-the-vms-disk">Break 4: Fill the VM's Disk</h3>
<p>Databases and other services need free disk for logs, temp files, and data. When the filesystem is full or nearly full, a service may fail to start or fail at runtime. This break walks through the same diagnosis habit you would use on a real server: check space, then read systemd and journal output for the service.</p>
<p>All commands below run <strong>on the db VM</strong> after <code>vagrant ssh db</code>. MariaDB was installed there by your playbook.</p>
<h4 id="heading-what-you-do">What you do:</h4>
<ol>
<li><p>Open a shell on the db VM:</p>
<pre><code class="language-plaintext">vagrant ssh db
</code></pre>
</li>
<li><p>Allocate a large file full of zeros (here 1GB) to simulate something eating disk space:</p>
<pre><code class="language-plaintext">sudo dd if=/dev/zero of=/tmp/bigfile bs=1M count=1024

df -h
</code></pre>
<p>Use <code>df -h</code> to see how full the root filesystem (or relevant mount) is. Your Vagrant disk may be large enough that 1GB only raises usage. If MariaDB still starts, you still practiced the checks. To see a stronger effect, you can repeat with a larger <code>count=</code> <strong>only in a lab</strong> (never fill production disks on purpose without a plan).</p>
</li>
<li><p>Ask systemd to restart MariaDB and show status:</p>
<pre><code class="language-plaintext">sudo systemctl restart mariadb
sudo systemctl status mariadb
</code></pre>
<p>If the disk is critically full, restart may fail or the service may show failed or not running.</p>
</li>
<li><p>If something looks wrong, read recent logs for the MariaDB unit:</p>
<pre><code class="language-plaintext">sudo journalctl -u mariadb --no-pager | tail -20
</code></pre>
<p>Errors often mention disk, space, read-only filesystem, or InnoDB being unable to write.</p>
</li>
<li><p>Clean up so your VM stays usable:</p>
<pre><code class="language-plaintext">sudo rm /tmp/bigfile
</code></pre>
<p>Optionally run <code>sudo systemctl restart mariadb</code> again and confirm it is active (running).</p>
</li>
</ol>
<p><strong>What to observe:</strong></p>
<ul>
<li><p>You should use <code>df -h</code> first to confirm whether the filesystem is actually tight. That avoids blaming the database when disk space is fine.</p>
</li>
<li><p>You should read <code>systemctl status mariadb</code> to see whether systemd thinks the service is active, failed, or flapping.</p>
</li>
<li><p>You should read <code>journalctl -u mariadb</code> when status is bad, so you can tie the failure to concrete errors from MariaDB or the kernel (often mentioning disk, space, or read-only filesystem). <strong>Space + status + logs</strong> is the same order you would use on a production server.</p>
</li>
</ul>
<h3 id="heading-break-5-run-minikube-out-of-resources">Break 5: Run Minikube Out of Resources</h3>
<p>Kubernetes schedules pods onto nodes that have enough CPU and memory. If you ask for more than the cluster can place, some pods stay <strong>Pending</strong> and <strong>Events</strong> explain why (for example <em>Insufficient cpu</em>). That is not the same as a pod that starts and then crashes.</p>
<p>To do this, you'll need a local cluster (we're using <a href="https://minikube.sigs.k8s.io/docs/start/?arch=%2Fmacos%2Fx86-64%2Fstable%2Fbinary+download"><strong>Minikube</strong></a> in this guide) and <code>kubectl</code> on your laptop. This break doesn't use the Vagrant VMs. If you haven't installed Minikube yet, complete the "How to Set Up Kubernetes" section first, or skip this break until you do.</p>
<p>You'll run this on your <strong>Mac, Linux, or Windows terminal</strong> (host), not inside <code>vagrant ssh</code>. If you're still inside a VM, type <code>exit</code> until your prompt is back on the host.</p>
<h4 id="heading-what-you-do">What you do:</h4>
<ol>
<li><p>Check Minikube:</p>
<pre><code class="language-plaintext">minikube status
</code></pre>
<p>If it's stopped, start it (Docker driver matches earlier sections):</p>
<pre><code class="language-plaintext">minikube start --driver=docker
</code></pre>
</li>
<li><p>Create a deployment with many replicas so your single Minikube node can't run them all at once:</p>
<pre><code class="language-plaintext">kubectl create deployment stress --image=nginx --replicas=20

#watch pods start
kubectl get pods -w
</code></pre>
<p>Press Ctrl+C when you're done watching. Some pods may stay <strong>Pending</strong> while others are <strong>Running</strong>.</p>
</li>
<li><p>Pick one Pending pod name from <code>kubectl get pods</code> and inspect it:</p>
<pre><code class="language-plaintext">kubectl describe pod &lt;pod-name&gt;
</code></pre>
<p>Under Events, look for FailedScheduling and a line similar to:</p>
<pre><code class="language-plaintext">Warning  FailedScheduling  0/1 nodes are available: 1 Insufficient cpu.
</code></pre>
<p>You might see <strong>Insufficient memory</strong> instead, depending on your machine.</p>
</li>
<li><p>Fix the lab by scaling back so the cluster can catch up:</p>
<pre><code class="language-plaintext">kubectl scale deployment stress --replicas=2
</code></pre>
<p>You can delete the deployment entirely when finished: <code>kubectl delete deployment stress</code>.</p>
</li>
</ol>
<p><strong>What to observe:</strong></p>
<ul>
<li><p>You should see Pending pods stay unscheduled until capacity frees up. That means the scheduler hasn't placed them on any <strong>node</strong> yet, usually because the node is out of CPU or memory for that workload.</p>
</li>
<li><p>You should read <code>kubectl describe pod &lt;pod-name&gt;</code> and scroll to <strong>Events</strong>. Messages like Insufficient cpu or Insufficient memory mean the cluster ran out of schedulable capacity, not that the container image image is corrupt.</p>
</li>
<li><p>You should contrast that with a pod that reaches Running and then CrashLoopBackOff, which usually means the process inside the container keeps exiting. that is an application or config problem, not a “nowhere to run” problem.</p>
</li>
</ul>
<h2 id="heading-what-you-can-now-do">What You Can Now Do</h2>
<p>You didn't just install tools in this tutorial. You also used them.</p>
<p>You can now spin up two servers from a single file. You can write a playbook that installs software and deploys a container without touching either machine manually.</p>
<p>You can serve a page you wrote from inside a Docker container running on a Vagrant VM, and bring the whole thing back from scratch in one command.</p>
<p>You also broke it. You saw what a container conflict looks like, what Ansible prints when it can't reach a machine, what disk pressure does to a running service, and what a Kubernetes scheduler says when it runs out of resources. Those error messages aren't unfamiliar anymore.</p>
<p>That's the difference between someone who has read about DevOps and someone who has run it.</p>
<p><strong>Here are four free projects you can run in this same lab to go further:</strong></p>
<ul>
<li><p><strong>DevOps Home-Lab 2026</strong> — Build a multi-service app (frontend, API, PostgreSQL, Redis) end-to-end with Docker Compose, Kubernetes, Prometheus/Grafana monitoring, GitOps with ArgoCD, and Cloudflare for global exposure.</p>
</li>
<li><p><strong>KubeLab</strong> — Trigger real Kubernetes failure scenarios, pod crashes, OOMKills, node drains, cascading failures, and watch how the cluster responds using live metrics.</p>
</li>
<li><p><strong>K8s Secrets Lab</strong> — Build a full secret management pipeline from AWS Secrets Manager into your cluster, including rotation behavior and IRSA.</p>
</li>
<li><p><strong>DevOps Troubleshooting Toolkit</strong> — Structured debugging guides across Linux, containers, Kubernetes, cloud, databases, and observability with copy-paste commands for real incidents.</p>
</li>
</ul>
<p>All free and open source: <a href="https://github.com/Osomudeya/List-Of-DevOps-Projects">github.com/Osomudeya/List-Of-DevOps-Projects</a>.</p>
<p>If you want to go deeper, you can find six full chapters covering Terraform, Ansible, monitoring, CI/CD, and a simulated three-VM production environment at <a href="https://osomudeya.gumroad.com/l/BuildYourOwnDevOpsLab">Build Your Own DevOps Lab</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Production-Ready DevOps Pipeline with Free Tools ]]>
                </title>
                <description>
                    <![CDATA[ A few months ago, I dove into DevOps, expecting it to be an expensive journey requiring costly tools and infrastructure. But I discovered you can build professional-grade pipelines using entirely free resources. If DevOps feels out of reach because y... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-production-ready-devops-pipeline-with-free-tools/</link>
                <guid isPermaLink="false">680fe1e69418a1165cb184a2</guid>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops articles ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AWS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Beginner Developers ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GitHub ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Terraform ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ YAML ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Opaluwa Emidowojo ]]>
                </dc:creator>
                <pubDate>Mon, 28 Apr 2025 20:15:34 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1745864420670/f36eb4a7-a24e-4d6e-859f-db7249ae0da0.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A few months ago, I dove into DevOps, expecting it to be an expensive journey requiring costly tools and infrastructure. But I discovered you can build professional-grade pipelines using entirely free resources.</p>
<p>If DevOps feels out of reach because you’re also concerned about the cost, don't worry. I’ll guide you step-by-step through creating a production-ready pipeline without spending a dime. Let's get started!</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a class="post-section-overview" href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a class="post-section-overview" href="#introduction">Introduction</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-set-up-your-source-control-and-project-structure">How to Set Up Your Source Control and Project Structure</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-build-your-ci-pipeline-with-github-actions">How to Build Your CI Pipeline with GitHub Actions</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-optimize-docker-builds-for-ci">How to Optimize Docker Builds for CI</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-infrastructure-as-code-using-terraform-and-free-cloud-providers">Infrastructure as Code Using Terraform and Free Cloud Providers</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-set-up-container-orchestration-on-minimal-resources">How to Set Up Container Orchestration on Minimal Resources</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-create-a-free-deployment-pipeline">How to Create a Free Deployment Pipeline</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-build-a-comprehensive-monitoring-system">How to Build a Comprehensive Monitoring System</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-implement-security-testing-and-scanning">How to Implement Security Testing and Scanning</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-performance-optimization-and-scaling">Performance Optimization and Scaling</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-complete-cicd-pipeline-example">Putting it All Together</a></p>
</li>
<li><p><a class="post-section-overview" href="#conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">🛠 Prerequisites</h2>
<ul>
<li><p><strong>Basic Git knowledge</strong>: Cloning repos, creating branches, committing code, and creating PRs</p>
</li>
<li><p><strong>Familiarity with command line</strong>: For Docker, Terraform, and Kubernetes</p>
</li>
<li><p><strong>Basic understanding of CI/CD</strong>: Continuous integration/delivery concepts and pipelines</p>
</li>
</ul>
<h3 id="heading-accounts-needed">Accounts needed:</h3>
<ul>
<li><p>GitHub account</p>
</li>
<li><p>At least one cloud provider: AWS Free Tier (recommended), Oracle Cloud Free Tier, or Google Cloud/Azure with free credits</p>
</li>
<li><p>Terraform Cloud (free tier) for infrastructure state management</p>
</li>
<li><p>Grafana Cloud (free tier) for monitoring</p>
</li>
<li><p>UptimeRobot (free tier) for external availability checks</p>
</li>
</ul>
<h3 id="heading-tools-to-install-locally">Tools to Install Locally</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td><strong>Tool</strong></td><td><strong>Purpose</strong></td><td><strong>Installation Link</strong></td></tr>
</thead>
<tbody>
<tr>
<td>Git</td><td>Version control</td><td><a target="_blank" href="https://git-scm.com/downloads"><strong>Install Git</strong></a></td></tr>
<tr>
<td>Docker</td><td>Containerization</td><td><a target="_blank" href="https://docs.docker.com/get-docker/"><strong>Install Docker</strong></a></td></tr>
<tr>
<td>Node.js &amp; npm</td><td>Sample app &amp; builds</td><td><a target="_blank" href="https://nodejs.org/"><strong>Install Node.js</strong></a></td></tr>
<tr>
<td>Terraform</td><td>Infrastructure as Code</td><td><a target="_blank" href="https://www.terraform.io/downloads"><strong>Install Terraform</strong></a></td></tr>
<tr>
<td>kubectl</td><td>Kubernetes CLI</td><td><a target="_blank" href="https://kubernetes.io/docs/tasks/tools/"><strong>Install kubectl</strong></a></td></tr>
<tr>
<td>k3d</td><td>Lightweight Kubernetes</td><td><a target="_blank" href="https://k3d.io/"><strong>Install k3d</strong></a></td></tr>
<tr>
<td>Trivy</td><td>Container security scanning</td><td><a target="_blank" href="https://aquasecurity.github.io/trivy/v0.18.3/"><strong>Install Trivy</strong></a></td></tr>
<tr>
<td>OWASP ZAP</td><td>Web security scanning</td><td><a target="_blank" href="https://www.zaproxy.org/download/"><strong>Install ZAP</strong></a></td></tr>
</tbody>
</table>
</div><p><strong>Optional but Helpful:</strong></p>
<ul>
<li><p><a target="_blank" href="https://code.visualstudio.com/"><strong>VS Code</strong></a> or any good code editor</p>
</li>
<li><p>Postman for testing APIs</p>
</li>
<li><p>Understanding of YAML and Dockerfiles</p>
</li>
</ul>
<h2 id="heading-introduction">Introduction</h2>
<p>When people hear "DevOps," they often picture complex enterprise systems powered by pricey tools and premium cloud services. But the truth is, you don't actually need a massive budget to build a solid, professional-grade DevOps pipeline. The foundations of good DevOps – automation, consistency, security, and visibility – can be built entirely with free tools.</p>
<p>In this guide, you will learn how to build a production-ready DevOps pipeline using zero-cost resources. We will use a simple CRUD (Create, Read, Update, Delete) app with frontend, backend API, and database as our example project to demonstrate every step of the process.</p>
<h2 id="heading-how-to-set-up-your-source-control-and-project-structure">How to Set Up Your Source Control and Project Structure</h2>
<h3 id="heading-1-create-a-well-structured-repository">1. Create a Well-Structured Repository</h3>
<p>A clean repo is the foundation of your pipeline. We will set up:</p>
<ul>
<li><p>Separate folders for <code>frontend</code>, <code>backend</code>, and <code>infrastructure</code></p>
</li>
<li><p>A <code>.github</code> folder to hold workflow configurations</p>
</li>
<li><p>Clear naming conventions and a well-written <code>README.md</code></p>
</li>
</ul>
<p>🛠 <strong>Tip</strong>: Use semantic commit messages and consider adopting <a target="_blank" href="https://www.conventionalcommits.org/"><strong>Conventional Commits</strong></a> for clarity in versioning and changelogs.</p>
<h3 id="heading-2-set-up-branch-protection-without-paid-features">2. Set Up Branch Protection Without Paid Features</h3>
<p>While GitHub's more advanced rules require Pro, you can still:</p>
<ul>
<li><p>Require pull requests before merging</p>
</li>
<li><p>Enable status checks to prevent broken code from landing in <code>main</code></p>
</li>
<li><p>Enforce linear history for cleaner version control</p>
</li>
</ul>
<p>💡 This makes your project safer and more collaborative, without needing GitHub Enterprise.</p>
<h3 id="heading-3-implement-pr-templates-and-automated-checks">3. Implement PR Templates and Automated Checks</h3>
<p>Make your reviews smoother:</p>
<ul>
<li><p>Add a <code>PULL_REQUEST_TEMPLATE.md</code> to guide contributors</p>
</li>
<li><p>Use GitHub Actions (which we'll set up in the next part) for linting, tests, and formatting checks</p>
</li>
</ul>
<p>✨ These tiny improvements add polish and professionalism.</p>
<h3 id="heading-4-configure-github-issue-templates-and-project-boards">4. Configure GitHub Issue Templates and Project Boards</h3>
<p>Even solo developers benefit from issue tracking:</p>
<ul>
<li><p>Add issue templates for bugs and features</p>
</li>
<li><p>Use GitHub Projects to manage work with a Kanban board, all free and native to GitHub</p>
</li>
</ul>
<p>📌 <strong>Bonus</strong>: This setup lays the groundwork for GitOps practices later on.</p>
<h3 id="heading-5-advanced-technique-set-up-custom-validation-scripts-as-pre-commit-hooks">5. Advanced Technique: Set Up Custom Validation Scripts as Pre-Commit Hooks</h3>
<p>Before code ever hits GitHub, you can catch issues locally with Git hooks. Using a tool like <a target="_blank" href="https://typicode.github.io/husky/"><strong>Husky</strong></a> or <a target="_blank" href="https://pre-commit.com/"><strong>pre-commit</strong></a>, you can:</p>
<ul>
<li><p>Lint code before it's committed</p>
</li>
<li><p>Run tests or formatters automatically</p>
</li>
<li><p>Prevent secrets from being accidentally committed</p>
</li>
</ul>
<pre><code class="lang-json"><span class="hljs-comment">// Initialize Husky and install needed dependencies</span>
<span class="hljs-comment">// Then add a pre-commit hook that runs tests before allowing the commit</span>
npx husky-init &amp;&amp; npm install
npx husky add .husky/pre-commit <span class="hljs-string">"npm test"</span>
</code></pre>
<h3 id="heading-6-sample-crud-app-setup"><strong>6. Sample CRUD App Setup:</strong></h3>
<p>Our CRUD app manages users (create, read, update, delete). Below is the minimal code with comments to explain each part:</p>
<p><strong>Backend</strong> <code>(backend/)</code>:</p>
<pre><code class="lang-json"><span class="hljs-comment">// backend/package.json</span>
{
  <span class="hljs-attr">"name"</span>: <span class="hljs-string">"crud-backend"</span>, <span class="hljs-comment">// Name of the backend project</span>
  <span class="hljs-attr">"version"</span>: <span class="hljs-string">"1.0.0"</span>, <span class="hljs-comment">// Version for tracking changes</span>
  <span class="hljs-attr">"scripts"</span>: {
    <span class="hljs-attr">"start"</span>: <span class="hljs-string">"node index.js"</span>, <span class="hljs-comment">// Runs the server</span>
    <span class="hljs-attr">"test"</span>: <span class="hljs-string">"echo 'Add tests here'"</span>, <span class="hljs-comment">// Placeholder for tests (update with Jest later)</span>
    <span class="hljs-attr">"lint"</span>: <span class="hljs-string">"eslint ."</span> <span class="hljs-comment">// Checks code style with ESLint</span>
  },
  <span class="hljs-attr">"dependencies"</span>: {
    <span class="hljs-attr">"express"</span>: <span class="hljs-string">"^4.17.1"</span>, <span class="hljs-comment">// Web framework for API endpoints</span>
    <span class="hljs-attr">"pg"</span>: <span class="hljs-string">"^8.7.3"</span> <span class="hljs-comment">// PostgreSQL client to connect to the database</span>
  },
  <span class="hljs-attr">"devDependencies"</span>: {
    <span class="hljs-attr">"eslint"</span>: <span class="hljs-string">"^8.0.0"</span> <span class="hljs-comment">// Linting tool for code quality</span>
  }
}
</code></pre>
<pre><code class="lang-javascript"><span class="hljs-comment">// backend/index.js</span>
<span class="hljs-keyword">const</span> express = <span class="hljs-built_in">require</span>(<span class="hljs-string">'express'</span>); <span class="hljs-comment">// Import Express for building the API</span>
<span class="hljs-keyword">const</span> { Pool } = <span class="hljs-built_in">require</span>(<span class="hljs-string">'pg'</span>); <span class="hljs-comment">// Import PostgreSQL client</span>
<span class="hljs-keyword">const</span> app = express(); <span class="hljs-comment">// Create an Express app</span>
app.use(express.json()); <span class="hljs-comment">// Parse JSON request bodies</span>

<span class="hljs-comment">// Connect to PostgreSQL using DATABASE_URL from environment variables</span>
<span class="hljs-keyword">const</span> pool = <span class="hljs-keyword">new</span> Pool({ <span class="hljs-attr">connectionString</span>: process.env.DATABASE_URL });

<span class="hljs-comment">// Health check endpoint for Kubernetes probes and monitoring</span>
app.get(<span class="hljs-string">'/healthz'</span>, <span class="hljs-function">(<span class="hljs-params">req, res</span>) =&gt;</span> res.json({ <span class="hljs-attr">status</span>: <span class="hljs-string">'ok'</span> }));

<span class="hljs-comment">// Get all users from the database</span>
app.get(<span class="hljs-string">'/users'</span>, <span class="hljs-keyword">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">const</span> { rows } = <span class="hljs-keyword">await</span> pool.query(<span class="hljs-string">'SELECT * FROM users'</span>); <span class="hljs-comment">// Query the users table</span>
  res.json(rows); <span class="hljs-comment">// Send users as JSON</span>
});

<span class="hljs-comment">// Add a new user to the database</span>
app.post(<span class="hljs-string">'/users'</span>, <span class="hljs-keyword">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">const</span> { name } = req.body; <span class="hljs-comment">// Get name from request body</span>
  <span class="hljs-comment">// Insert user and return the new record</span>
  <span class="hljs-keyword">const</span> { rows } = <span class="hljs-keyword">await</span> pool.query(<span class="hljs-string">'INSERT INTO users(name) VALUES($1) RETURNING *'</span>, [name]);
  res.json(rows[<span class="hljs-number">0</span>]); <span class="hljs-comment">// Send the new user as JSON</span>
});

<span class="hljs-comment">// Start the server on port 3000</span>
app.listen(<span class="hljs-number">3000</span>, <span class="hljs-function">() =&gt;</span> <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'Backend running on port 3000'</span>));
</code></pre>
<p><strong>Frontend</strong> <code>(frontend/)</code>:</p>
<pre><code class="lang-javascript"><span class="hljs-comment">// frontend/package.json</span>
{
  <span class="hljs-string">"name"</span>: <span class="hljs-string">"crud-frontend"</span>, <span class="hljs-comment">// Name of the frontend project</span>
  <span class="hljs-string">"version"</span>: <span class="hljs-string">"1.0.0"</span>, <span class="hljs-comment">// Version for tracking changes</span>
  <span class="hljs-string">"scripts"</span>: {
    <span class="hljs-string">"start"</span>: <span class="hljs-string">"react-scripts start"</span>, <span class="hljs-comment">// Runs the dev server</span>
    <span class="hljs-string">"build"</span>: <span class="hljs-string">"react-scripts build"</span>, <span class="hljs-comment">// Builds for production</span>
    <span class="hljs-string">"test"</span>: <span class="hljs-string">"react-scripts test"</span>, <span class="hljs-comment">// Runs tests (placeholder for Jest)</span>
    <span class="hljs-string">"lint"</span>: <span class="hljs-string">"eslint ."</span> <span class="hljs-comment">// Checks code style with ESLint</span>
  },
  <span class="hljs-string">"dependencies"</span>: {
    <span class="hljs-string">"react"</span>: <span class="hljs-string">"^17.0.2"</span>, <span class="hljs-comment">// Core React library</span>
    <span class="hljs-string">"react-dom"</span>: <span class="hljs-string">"^17.0.2"</span>, <span class="hljs-comment">// Renders React to the DOM</span>
    <span class="hljs-string">"react-scripts"</span>: <span class="hljs-string">"^4.0.3"</span>, <span class="hljs-comment">// Scripts for React development</span>
    <span class="hljs-string">"axios"</span>: <span class="hljs-string">"^0.24.0"</span> <span class="hljs-comment">// HTTP client for API calls</span>
  },
  <span class="hljs-string">"devDependencies"</span>: {
    <span class="hljs-string">"eslint"</span>: <span class="hljs-string">"^8.0.0"</span> <span class="hljs-comment">// Linting tool for code quality</span>
  }
}
</code></pre>
<pre><code class="lang-javascript"><span class="hljs-comment">// frontend/src/App.js</span>
<span class="hljs-keyword">import</span> React, { useState, useEffect } <span class="hljs-keyword">from</span> <span class="hljs-string">'react'</span>; <span class="hljs-comment">// Import React and hooks</span>
<span class="hljs-keyword">import</span> axios <span class="hljs-keyword">from</span> <span class="hljs-string">'axios'</span>; <span class="hljs-comment">// Import Axios for API requests</span>

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">App</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-comment">// State for storing users fetched from the backend</span>
  <span class="hljs-keyword">const</span> [users, setUsers] = useState([]);
  <span class="hljs-comment">// State for the input field to add a new user</span>
  <span class="hljs-keyword">const</span> [name, setName] = useState(<span class="hljs-string">''</span>);

  <span class="hljs-comment">// Fetch users when the component mounts</span>
  useEffect(<span class="hljs-function">() =&gt;</span> {
    axios.get(<span class="hljs-string">'http://localhost:3000/users'</span>).then(<span class="hljs-function"><span class="hljs-params">res</span> =&gt;</span> setUsers(res.data));
  }, []); <span class="hljs-comment">// Empty array means run once on mount</span>

  <span class="hljs-comment">// Add a new user via the API</span>
  <span class="hljs-keyword">const</span> addUser = <span class="hljs-keyword">async</span> () =&gt; {
    <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> axios.post(<span class="hljs-string">'http://localhost:3000/users'</span>, { name }); <span class="hljs-comment">// Post new user</span>
    setUsers([...users, res.data]); <span class="hljs-comment">// Update users list</span>
    setName(<span class="hljs-string">''</span>); <span class="hljs-comment">// Clear input field</span>
  };

  <span class="hljs-keyword">return</span> (
    <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">h1</span>&gt;</span>Users<span class="hljs-tag">&lt;/<span class="hljs-name">h1</span>&gt;</span>
      {/* Input for new user name */}
      <span class="hljs-tag">&lt;<span class="hljs-name">input</span> <span class="hljs-attr">value</span>=<span class="hljs-string">{name}</span> <span class="hljs-attr">onChange</span>=<span class="hljs-string">{e</span> =&gt;</span> setName(e.target.value)} /&gt;
      {/* Button to add user */}
      <span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">onClick</span>=<span class="hljs-string">{addUser}</span>&gt;</span>Add User<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>
      {/* List all users */}
      <span class="hljs-tag">&lt;<span class="hljs-name">ul</span>&gt;</span>{users.map(user =&gt; <span class="hljs-tag">&lt;<span class="hljs-name">li</span> <span class="hljs-attr">key</span>=<span class="hljs-string">{user.id}</span>&gt;</span>{user.name}<span class="hljs-tag">&lt;/<span class="hljs-name">li</span>&gt;</span>)}<span class="hljs-tag">&lt;/<span class="hljs-name">ul</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span></span>
  );
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> App; <span class="hljs-comment">// Export the component</span>
</code></pre>
<p><strong>Database Setup</strong>:</p>
<pre><code class="lang-pgsql"><span class="hljs-comment">-- infra/db.sql</span>
<span class="hljs-comment">-- Create a table to store users</span>
<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TABLE</span> users (
  id <span class="hljs-type">SERIAL</span> <span class="hljs-keyword">PRIMARY KEY</span>, <span class="hljs-comment">-- Auto-incrementing ID</span>
  <span class="hljs-type">name</span> <span class="hljs-type">VARCHAR</span>(<span class="hljs-number">100</span>) <span class="hljs-keyword">NOT</span> <span class="hljs-keyword">NULL</span> <span class="hljs-comment">-- User name, required</span>
);
</code></pre>
<pre><code class="lang-javascript">crud-app/
├── backend/
│   ├── package.json
│   └── index.js
├── frontend/
│   ├── package.json
│   └── src/App.js
├── infra/
│   └── db.sql
├── .github/
│   └── workflows/
└── README.md
</code></pre>
<p>This app provides a <code>/users</code> endpoint (GET/POST) and a frontend to list/add users, stored in PostgreSQL. The <code>/healthz</code> endpoint supports monitoring. Save this code in your repo to follow the pipeline steps.</p>
<h2 id="heading-how-to-build-your-ci-pipeline-with-github-actions">How to Build Your CI Pipeline with GitHub Actions</h2>
<h3 id="heading-1-set-up-your-first-github-actions-workflow">1. Set Up Your First GitHub Actions Workflow</h3>
<p>First, let’s create a basic workflow that automatically builds, tests, and lints your app every time you push code or open a pull request. This ensures your app stays healthy and any issues are caught early.</p>
<p>Create a file at <code>.github/workflows/ci.yml</code> and add the following:</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># CI workflow to build, test, and lint the CRUD app on push or pull request</span>
<span class="hljs-attr">name:</span> <span class="hljs-string">CI</span> <span class="hljs-string">Pipeline</span>
<span class="hljs-attr">on:</span>
  <span class="hljs-attr">push:</span>
    <span class="hljs-attr">branches:</span> [<span class="hljs-string">main</span>] <span class="hljs-comment"># Trigger on pushes to main branch</span>
  <span class="hljs-attr">pull_request:</span>
    <span class="hljs-attr">branches:</span> [<span class="hljs-string">main</span>] <span class="hljs-comment"># Trigger on PRs to main branch</span>
<span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">build:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span> <span class="hljs-comment"># Use GitHub's free Linux runner</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span> <span class="hljs-comment"># Check out the repository code</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Set</span> <span class="hljs-string">up</span> <span class="hljs-string">Node.js</span> <span class="hljs-comment"># Install Node.js environment</span>
        <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/setup-node@v3</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">node-version:</span> <span class="hljs-string">'18'</span> <span class="hljs-comment"># Use Node.js 18 for consistency</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Cache</span> <span class="hljs-string">dependencies</span> <span class="hljs-comment"># Cache node_modules to speed up builds</span>
        <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/cache@v3</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">path:</span> <span class="hljs-string">~/.npm</span> <span class="hljs-comment"># Cache npm’s global cache</span>
          <span class="hljs-attr">key:</span> <span class="hljs-string">${{</span> <span class="hljs-string">runner.os</span> <span class="hljs-string">}}-node-${{</span> <span class="hljs-string">hashFiles('**/package-lock.json')</span> <span class="hljs-string">}}</span> <span class="hljs-comment"># Key based on OS and package-lock.json</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">ci</span> <span class="hljs-comment"># Install dependencies reliably using package-lock.json</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">test</span> <span class="hljs-comment"># Run tests defined in package.json</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">run</span> <span class="hljs-string">lint</span> <span class="hljs-comment"># Run ESLint to ensure code quality</span>
</code></pre>
<p>This workflow automatically runs on every push and pull request to the <code>main</code> branch. It installs dependencies, runs tests, and performs code linting, with dependency caching to make builds faster over time.</p>
<p><strong>Common Issues and Fixes</strong>:</p>
<ul>
<li><p><strong>“Secret not found”</strong>: Ensure <code>AWS_ACCESS_KEY_ID</code> is in repository secrets (Settings → Secrets).</p>
</li>
<li><p><strong>Tests fail</strong>: Check <code>test/users.test.js</code> for database connectivity.</p>
</li>
</ul>
<h4 id="heading-understanding-github-actions-free-tier-limits">Understanding GitHub Actions' Free Tier Limits</h4>
<p>Before building more workflows, it is important to know what GitHub offers for free.</p>
<p>If you are working on private repositories, you get 2,000 free minutes per month. For public repositories, you get unlimited minutes.</p>
<p>To avoid hitting limits quickly:</p>
<ul>
<li><p>Cache your dependencies to cut down install times.</p>
</li>
<li><p>Only trigger workflows on meaningful branches (like <code>main</code> or <code>release</code>).</p>
</li>
<li><p>Skip unnecessary steps when you can.</p>
</li>
</ul>
<h3 id="heading-2-creating-a-multi-stage-build-pipeline">2. Creating a Multi-Stage Build Pipeline</h3>
<p>As your app grows, it is better to split your CI pipeline into clear stages like <strong>install</strong>, <strong>test</strong>, and <strong>lint</strong>. This structure makes workflows easier to maintain and speeds things up, because some jobs can run in parallel.</p>
<p>Here’s how you can split the work into multiple jobs for better clarity:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">install:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">ci</span>  <span class="hljs-comment"># Clean install of dependencies</span>

  <span class="hljs-attr">test:</span>
    <span class="hljs-attr">needs:</span> <span class="hljs-string">install</span>  <span class="hljs-comment"># This job depends on the install job finishing</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">test</span>  <span class="hljs-comment"># Run test suite</span>

  <span class="hljs-attr">lint:</span>
    <span class="hljs-attr">needs:</span> <span class="hljs-string">install</span>  <span class="hljs-comment"># This job also depends on install but runs in parallel with test</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">run</span> <span class="hljs-string">lint</span>  <span class="hljs-comment"># Run linting checks</span>
</code></pre>
<p>By breaking the pipeline into stages, you can quickly spot which step fails, and your test and lint jobs can run at the same time after dependencies are installed.</p>
<h3 id="heading-3-implement-matrix-builds-for-cross-environment-testing">3. Implement Matrix Builds for Cross-Environment Testing</h3>
<p>When you want your app to work across different Node.js versions or databases, matrix builds are your best bet. They let you test across multiple environments in parallel, without duplicating code.</p>
<p>Here’s how you can set up a matrix strategy, to test across multiple environments simultaneously:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">test:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">strategy:</span>
      <span class="hljs-attr">matrix:</span>
        <span class="hljs-attr">node-version:</span> [<span class="hljs-number">14.</span><span class="hljs-string">x</span>, <span class="hljs-number">16.</span><span class="hljs-string">x</span>, <span class="hljs-number">18.</span><span class="hljs-string">x</span>]  <span class="hljs-comment"># Test on multiple Node versions</span>
        <span class="hljs-attr">database:</span> [<span class="hljs-string">postgres</span>, <span class="hljs-string">mysql</span>]        <span class="hljs-comment"># Test against different databases</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Use</span> <span class="hljs-string">Node.js</span> <span class="hljs-string">${{</span> <span class="hljs-string">matrix.node-version</span> <span class="hljs-string">}}</span>
        <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/setup-node@v3</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">node-version:</span> <span class="hljs-string">${{</span> <span class="hljs-string">matrix.node-version</span> <span class="hljs-string">}}</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">install</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">test</span>  <span class="hljs-comment"># This will run 6 different test combinations (3 Node versions × 2 databases)</span>
</code></pre>
<p>Matrix builds save time and help you catch environment-specific bugs early.</p>
<h3 id="heading-4-optimize-workflow-with-dependency-caching">4. Optimize Workflow with Dependency Caching</h3>
<p>Every second counts in CI. Dependency caching can help save minutes in your workflow by reusing previously installed packages instead of reinstalling them from scratch every time.</p>
<p>Here’s how to set up smart caching to speed up your builds:</p>
<pre><code class="lang-yaml"><span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Cache</span> <span class="hljs-string">node</span> <span class="hljs-string">modules</span>
  <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/cache@v3</span>
  <span class="hljs-attr">with:</span>
    <span class="hljs-attr">path:</span> <span class="hljs-string">|</span>  <span class="hljs-comment"># Cache both global npm cache and local node_modules</span>
      <span class="hljs-string">~/.npm</span>
      <span class="hljs-string">node_modules</span>
    <span class="hljs-attr">key:</span> <span class="hljs-string">${{</span> <span class="hljs-string">runner.os</span> <span class="hljs-string">}}-node-${{</span> <span class="hljs-string">hashFiles('**/package-lock.json')</span> <span class="hljs-string">}}</span>  <span class="hljs-comment"># Cache key based on OS and dependencies</span>
    <span class="hljs-attr">restore-keys:</span> <span class="hljs-string">|</span>  <span class="hljs-comment"># Fallback keys if exact match isn't found</span>
      <span class="hljs-string">${{</span> <span class="hljs-string">runner.os</span> <span class="hljs-string">}}-node-</span>
</code></pre>
<p>This cache setup checks if your dependencies have changed. If not, it restores the cache, making builds significantly faster.</p>
<h2 id="heading-how-to-optimize-docker-builds-for-ci">How to Optimize Docker Builds for CI</h2>
<p>When you're building Docker images in CI, build time can quickly become a bottleneck. Especially if your images are large. Optimizing your Docker builds makes your pipelines much faster, saves bandwidth, and produces smaller, more efficient images ready for deployment.</p>
<p>In this section, I’ll walk through creating a basic Dockerfile, using multi-stage builds, caching layers, and enabling BuildKit for even faster builds.</p>
<h3 id="heading-1-create-a-baseline-dockerfile">1. Create a Baseline Dockerfile</h3>
<p>First, start with a simple Dockerfile that installs your app’s dependencies and runs it. This is what you’ll be optimizing later.</p>
<pre><code class="lang-dockerfile"><span class="hljs-comment"># Simple Dockerfile for a Node.js application</span>
<span class="hljs-keyword">FROM</span> node:<span class="hljs-number">18</span>-alpine  <span class="hljs-comment"># Use Alpine for a smaller base image</span>
<span class="hljs-keyword">WORKDIR</span><span class="bash"> /app         <span class="hljs-comment"># Set working directory</span></span>
<span class="hljs-keyword">COPY</span><span class="bash"> . .             <span class="hljs-comment"># Copy all files to container</span></span>
<span class="hljs-keyword">RUN</span><span class="bash"> npm ci           <span class="hljs-comment"># Install dependencies (clean install)</span></span>
<span class="hljs-keyword">CMD</span><span class="bash"> [<span class="hljs-string">"npm"</span>, <span class="hljs-string">"start"</span>] <span class="hljs-comment"># Start the application</span></span>
</code></pre>
<p>Using an Alpine-based Node.js image helps keep your image small from the start.</p>
<h3 id="heading-2-multi-stage-docker-builds">2. Multi-Stage Docker Builds</h3>
<p>Next, let's separate the build process from the production image. Multi-stage builds let you compile or build your app in one stage and only copy over the final product to a clean, smaller image. This keeps production images lean:</p>
<pre><code class="lang-dockerfile"><span class="hljs-comment"># Stage 1: Build the application</span>
<span class="hljs-keyword">FROM</span> node:<span class="hljs-number">18</span>-alpine AS builder
<span class="hljs-keyword">WORKDIR</span><span class="bash"> /app</span>
<span class="hljs-keyword">COPY</span><span class="bash"> package*.json ./  <span class="hljs-comment"># Copy package files first for better caching</span></span>
<span class="hljs-keyword">RUN</span><span class="bash"> npm ci             <span class="hljs-comment"># Install all dependencies</span></span>
<span class="hljs-keyword">COPY</span><span class="bash"> . .               <span class="hljs-comment"># Then copy source code</span></span>
<span class="hljs-keyword">RUN</span><span class="bash"> npm run build      <span class="hljs-comment"># Build the application</span></span>

<span class="hljs-comment"># Stage 2: Production image with minimal footprint</span>
<span class="hljs-keyword">FROM</span> node:<span class="hljs-number">18</span>-alpine
<span class="hljs-keyword">WORKDIR</span><span class="bash"> /app</span>
<span class="hljs-comment"># Only copy built assets and production dependencies</span>
<span class="hljs-keyword">COPY</span><span class="bash"> --from=builder /app/dist ./dist</span>
<span class="hljs-keyword">COPY</span><span class="bash"> --from=builder /app/package*.json ./</span>
<span class="hljs-keyword">RUN</span><span class="bash"> npm ci --production  <span class="hljs-comment"># Install only production dependencies</span></span>
<span class="hljs-keyword">CMD</span><span class="bash"> [<span class="hljs-string">"node"</span>, <span class="hljs-string">"dist/server.js"</span>]  <span class="hljs-comment"># Run the built application</span></span>
</code></pre>
<p>This approach keeps your production images lightweight and secure by excluding unnecessary build tools and dev dependencies.</p>
<h3 id="heading-3-optimizing-layer-caching">3. Optimizing Layer Caching</h3>
<p>For even faster builds, order your <code>Dockerfile</code> instructions to maximize layer caching. Copy and install dependencies <em>before</em> copying your full source code.</p>
<p>This way, Docker reuses the cached npm install step if your dependencies haven't changed, even if you edit your app's code:</p>
<ul>
<li><p>First: <code>COPY package*.json ./</code></p>
</li>
<li><p>Then: <code>RUN npm ci</code></p>
</li>
<li><p>Finally: <code>COPY . .</code></p>
</li>
</ul>
<h3 id="heading-4-enable-buildkit-for-faster-builds">4. Enable BuildKit for Faster Builds</h3>
<p>Docker BuildKit is a newer build engine that enables features like better caching, parallel build steps, and overall faster builds.</p>
<p>To enable BuildKit during your CI, run:</p>
<pre><code class="lang-dockerfile">- name: Build Docker image
  <span class="hljs-keyword">run</span><span class="bash">: |</span>
    <span class="hljs-comment"># Enable BuildKit for parallel and more efficient builds</span>
    DOCKER_BUILDKIT=<span class="hljs-number">1</span> docker build -t myapp:latest .
</code></pre>
<p>Turning on BuildKit can significantly speed up complex Docker builds and is highly recommended for all CI pipelines.</p>
<h2 id="heading-infrastructure-as-code-using-terraform-and-free-cloud-providers">Infrastructure as Code Using Terraform and Free Cloud Providers</h2>
<h3 id="heading-why-infrastructure-as-code-iac-matters">Why Infrastructure as Code (IaC) Matters</h3>
<p>When you manage infrastructure manually – that is, clicking around cloud dashboards or setting things up by hand – it’s easy to lose track of what you did and how to repeat it.</p>
<p>Infrastructure as Code (IaC) solves this by letting you define your infrastructure with code, version it just like application code, and track every change over time. This makes your setups easy to replicate across environments (development, staging, production), ensures changes are declarative and auditable, and reduces human error.</p>
<p>Whether you are spinning up a single server or scaling a complex system, IaC lays the foundation for professional-grade infrastructure from day one, letting you automate, document, and grow your environment systematically.</p>
<h3 id="heading-how-to-provision-infrastructure-with-terraform">How to Provision Infrastructure with Terraform</h3>
<h4 id="heading-initialize-a-terraform-project">Initialize a Terraform Project</h4>
<p>First, define the providers and versions you need. Here, we’re using Render’s free cloud hosting service:</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># Define required providers and versions</span>
<span class="hljs-string">terraform</span> {
  <span class="hljs-string">required_providers</span> {
    <span class="hljs-string">render</span> <span class="hljs-string">=</span> {
      <span class="hljs-string">source</span>  <span class="hljs-string">=</span> <span class="hljs-string">"renderinc/render"</span>  <span class="hljs-comment"># Using Render's free tier</span>
      <span class="hljs-string">version</span> <span class="hljs-string">=</span> <span class="hljs-string">"0.1.0"</span>             <span class="hljs-comment"># Specify provider version for stability</span>
    }
  }
}

<span class="hljs-comment"># Configure the Render provider with authentication</span>
<span class="hljs-string">provider</span> <span class="hljs-string">"render"</span> {
  <span class="hljs-string">api_key</span> <span class="hljs-string">=</span> <span class="hljs-string">var.render_api_key</span>  <span class="hljs-comment"># Store API key as a variable</span>
}
</code></pre>
<p>Then, configure the provider by authenticating with your API key. It is best practice to store secrets like API keys in variables instead of hardcoding them. This setup tells Terraform what platform you’re working with (Render) and how to authenticate to manage resources automatically.</p>
<h4 id="heading-provision-a-web-app-on-render">Provision a Web App on Render</h4>
<p>Next, define the infrastructure you want – in this case, a web service hosted on Render:</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># Define a web service on Render's free tier</span>
<span class="hljs-string">resource</span> <span class="hljs-string">"render_service"</span> <span class="hljs-string">"web_app"</span> {
  <span class="hljs-string">name</span> <span class="hljs-string">=</span> <span class="hljs-string">"ci-demo-app"</span>                                 <span class="hljs-comment"># Service name</span>
  <span class="hljs-string">type</span> <span class="hljs-string">=</span> <span class="hljs-string">"web_service"</span>                                 <span class="hljs-comment"># Type of service</span>
  <span class="hljs-string">repo</span> <span class="hljs-string">=</span> <span class="hljs-string">"https://github.com/YOUR-USERNAME/YOUR-REPO"</span>  <span class="hljs-comment"># Source repo</span>
  <span class="hljs-string">env</span> <span class="hljs-string">=</span> <span class="hljs-string">"docker"</span>                                       <span class="hljs-comment"># Use Docker environment</span>
  <span class="hljs-string">plan</span> <span class="hljs-string">=</span> <span class="hljs-string">"starter"</span>                                     <span class="hljs-comment"># Free tier plan</span>
  <span class="hljs-string">branch</span> <span class="hljs-string">=</span> <span class="hljs-string">"main"</span>                                      <span class="hljs-comment"># Deploy from main branch</span>
  <span class="hljs-string">build_command</span> <span class="hljs-string">=</span> <span class="hljs-string">"docker build -t app ."</span>              <span class="hljs-comment"># Build command</span>
  <span class="hljs-string">start_command</span> <span class="hljs-string">=</span> <span class="hljs-string">"docker run -p 3000:3000 app"</span>        <span class="hljs-comment"># Start command</span>
  <span class="hljs-string">auto_deploy</span> <span class="hljs-string">=</span> <span class="hljs-literal">true</span>                                   <span class="hljs-comment"># Auto-deploy on commits</span>
}
</code></pre>
<p>This resource block describes exactly how your app should be deployed. Whenever you change this file and reapply, Terraform will update the infrastructure to match.</p>
<h4 id="heading-provision-postgresql-for-free">Provision PostgreSQL for Free</h4>
<p>Most applications need a database, but you don't have to pay for one when you're getting started. Platforms like <a target="_blank" href="https://railway.app/">Railway</a> offer free tiers that are perfect for development and small projects.</p>
<p>You can quickly create a free PostgreSQL instance by signing up on the platform and clicking <strong>"Create New Project"</strong>. At the end, you'll get a <code>DATABASE_URL</code> a connection string that your app will use to talk to the database.</p>
<h4 id="heading-connect-app-to-db">Connect App to DB</h4>
<p>In Render (or whatever platform you're using), set an environment variable called <code>DATABASE_URL</code> and paste in the connection string from your PostgreSQL provider. This lets your application securely access the database without hardcoding credentials into your codebase.</p>
<h4 id="heading-make-it-reproducible">Make it Reproducible</h4>
<p>Once everything is defined, use Terraform to create and apply an infrastructure plan:</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># Create execution plan and save it to a file</span>
<span class="hljs-string">terraform</span> <span class="hljs-string">plan</span> <span class="hljs-string">-out=infra.tfplan</span>
<span class="hljs-comment"># Apply the saved plan exactly as planned</span>
<span class="hljs-string">terraform</span> <span class="hljs-string">apply</span> <span class="hljs-string">infra.tfplan</span>
</code></pre>
<p>Saving the plan to a file (<code>infra.tfplan</code>) ensures you’re applying exactly what you reviewed, so there will be no surprises.</p>
<p><strong>Common Issues and Fixes</strong>:</p>
<ul>
<li><p><strong>Provider not found</strong>: Run <code>terraform init</code>.</p>
</li>
<li><p><strong>API key error</strong>: Check <code>render_api_key</code> in Terraform Cloud variables.</p>
</li>
</ul>
<h2 id="heading-how-to-set-up-container-orchestration-on-minimal-resources">How to Set Up Container Orchestration on Minimal Resources</h2>
<p>When you're working with limited resources like a laptop, a small server, or a lightweight cloud VM, setting up full Kubernetes can be overwhelming. Instead, you can use <strong>K3d</strong>, a lightweight Kubernetes distribution that runs inside Docker containers. Here's how to set up a minimal, efficient cluster for local development or testing.</p>
<h3 id="heading-1-install-k3d-for-local-kubernetes">1. Install K3d for Local Kubernetes</h3>
<p>First, install K3d. It's a super lightweight way to run Kubernetes clusters inside Docker without needing a heavy setup like Minikube.</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Download and install K3d - a lightweight K8s distribution</span>
curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash
</code></pre>
<h3 id="heading-2-create-a-lightweight-k3d-cluster">2. Create a Lightweight K3d Cluster</h3>
<p>Once K3d is installed, you can spin up a cluster with minimal nodes to save resources.</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Create a minimal K8s cluster with 1 server and 2 agent nodes</span>
k3d cluster create dev-cluster \
  --servers 1 \                        <span class="hljs-comment"># Single server node to minimize resource usage</span>
  --agents 2 \                         <span class="hljs-comment"># Two worker nodes for pod distribution</span>
  --volume /tmp/k3dvol:/tmp/k3dvol \   <span class="hljs-comment"># Mount local volume for persistence</span>
  --port 8080:80@loadbalancer \        <span class="hljs-comment"># Map port 8080 locally to 80 in the cluster</span>
  --api-port 6443                      <span class="hljs-comment"># Set the API port</span>
</code></pre>
<p>This setup gives you a <strong>tiny but real Kubernetes cluster</strong> that is perfect for experimentation.</p>
<h3 id="heading-3-deploy-with-optimized-kubernetes-manifests">3. Deploy with Optimized Kubernetes Manifests</h3>
<p>Now that your cluster is running, you can deploy your app. It's important to define resource requests and limits carefully so your pods don’t consume too much memory or CPU.</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Resource-optimized deployment manifest</span>
apiVersion: apps/v1
kind: Deployment
metadata:
  name: webapp  <span class="hljs-comment"># Name of the deployment</span>
spec:
  replicas: 1   <span class="hljs-comment"># Single replica to save resources</span>
  selector:
    matchLabels:
      app: webapp
  template:
    metadata:
      labels:
        app: webapp
    spec:
      containers:
        - name: app
          image: myapp:latest
          resources:
            <span class="hljs-comment"># Set minimal resource requests</span>
            requests:
              memory: <span class="hljs-string">"64Mi"</span>   <span class="hljs-comment"># Request only 64MB memory</span>
              cpu: <span class="hljs-string">"50m"</span>       <span class="hljs-comment"># Request only 5% of a CPU core</span>
            <span class="hljs-comment"># Set reasonable limits</span>
            limits:
              memory: <span class="hljs-string">"128Mi"</span>  <span class="hljs-comment"># Limit to 128MB memory</span>
              cpu: <span class="hljs-string">"100m"</span>      <span class="hljs-comment"># Limit to 10% of a CPU core</span>
</code></pre>
<p>This ensures Kubernetes knows how much to allocate and avoid overloading your lightweight environment.</p>
<h3 id="heading-4-set-up-gitops-with-flux">4. Set up GitOps with Flux</h3>
<p>To manage deployments automatically from your GitHub repository, you can set up GitOps using Flux.</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Install Flux CLI</span>
brew install fluxcd/tap/flux

<span class="hljs-comment"># Bootstrap Flux on your cluster connected to your GitHub repository</span>
flux bootstrap github \
  --owner=YOUR_GITHUB_USERNAME \    <span class="hljs-comment"># Your GitHub username</span>
  --repository=YOUR_REPO_NAME \     <span class="hljs-comment"># Repository to store Flux manifests</span>
  --branch=main \                   <span class="hljs-comment"># Branch to use</span>
  --path=clusters/dev-cluster \     <span class="hljs-comment"># Path within repo for cluster configs</span>
  --personal                        <span class="hljs-comment"># Flag for personal account</span>
</code></pre>
<p>Flux watches your repo and applies updates to your cluster, keeping everything declarative and reproducible.</p>
<p><strong>Common Issues and Fixes</strong>:</p>
<ul>
<li><p><strong>Pods crash</strong>: Run <code>kubectl logs pod-name</code> or increase resources.</p>
</li>
<li><p><strong>Flux sync fails</strong>: Check GitHub token permissions.</p>
</li>
</ul>
<h2 id="heading-how-to-create-a-free-deployment-pipeline">How to Create a Free Deployment Pipeline</h2>
<p>Like I said initially, not every project needs expensive infrastructure. If you're just getting started or building side projects, free tiers from cloud providers can cover a lot of ground.</p>
<h3 id="heading-1-understanding-free-tier-limitations">1. Understanding Free Tier Limitations</h3>
<p>Here’s a quick overview of popular cloud free tiers:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Provider</td><td>Free Tier Highlights</td></tr>
</thead>
<tbody>
<tr>
<td>AWS Free Tier</td><td>750 hours/month EC2, 5GB S3, 1M Lambda requests</td></tr>
<tr>
<td>Oracle Cloud Free Tier</td><td>2 always-free compute instances, 30GB storage</td></tr>
<tr>
<td>Google Cloud Free Tier</td><td>1 f1-micro instance, 5GB storage</td></tr>
</tbody>
</table>
</div><p>Knowing these limits helps you stay within budget.</p>
<h3 id="heading-2-set-up-deployment-workflows">2. Set Up Deployment Workflows</h3>
<p>You can automate deployments with GitHub Actions. Here's an example of a deployment workflow to AWS:</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># GitHub Action workflow for deploying to AWS</span>
<span class="hljs-attr">name:</span> <span class="hljs-string">AWS</span> <span class="hljs-string">Deployment</span>

<span class="hljs-attr">on:</span>
  <span class="hljs-attr">push:</span>
    <span class="hljs-attr">branches:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">main</span>  <span class="hljs-comment"># Deploy on push to main branch</span>

<span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">deploy:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>  <span class="hljs-comment"># Check out code</span>

      <span class="hljs-comment"># Set up AWS credentials from GitHub secrets</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Set</span> <span class="hljs-string">up</span> <span class="hljs-string">AWS</span> <span class="hljs-string">credentials</span>
        <span class="hljs-attr">uses:</span> <span class="hljs-string">aws-actions/configure-aws-credentials@v1</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">aws-access-key-id:</span> <span class="hljs-string">${{</span> <span class="hljs-string">secrets.AWS_ACCESS_KEY_ID</span> <span class="hljs-string">}}</span>
          <span class="hljs-attr">aws-secret-access-key:</span> <span class="hljs-string">${{</span> <span class="hljs-string">secrets.AWS_SECRET_ACCESS_KEY</span> <span class="hljs-string">}}</span>
          <span class="hljs-attr">aws-region:</span> <span class="hljs-string">us-east-1</span>

      <span class="hljs-comment"># Build the Docker image</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Build</span> <span class="hljs-string">Docker</span> <span class="hljs-string">Image</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">docker</span> <span class="hljs-string">build</span> <span class="hljs-string">-t</span> <span class="hljs-string">myapp</span> <span class="hljs-string">.</span>

      <span class="hljs-comment"># Push the image to AWS ECR</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Push</span> <span class="hljs-string">Docker</span> <span class="hljs-string">Image</span> <span class="hljs-string">to</span> <span class="hljs-string">ECR</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">|
          # Create repository if it doesn't exist (ignoring errors if it does)
          aws ecr create-repository --repository-name myapp || true
</span>
          <span class="hljs-comment"># Login to ECR</span>
          <span class="hljs-string">aws</span> <span class="hljs-string">ecr</span> <span class="hljs-string">get-login-password</span> <span class="hljs-string">|</span> <span class="hljs-string">docker</span> <span class="hljs-string">login</span> <span class="hljs-string">--username</span> <span class="hljs-string">AWS</span> <span class="hljs-string">--password-stdin</span> <span class="hljs-string">&lt;aws_account_id&gt;.dkr.ecr.us-east-1.amazonaws.com</span>

          <span class="hljs-comment"># Tag and push the image</span>
          <span class="hljs-string">docker</span> <span class="hljs-string">tag</span> <span class="hljs-string">myapp:latest</span> <span class="hljs-string">&lt;aws_account_id&gt;.dkr.ecr.us-east-1.amazonaws.com/myapp:latest</span>
          <span class="hljs-string">docker</span> <span class="hljs-string">push</span> <span class="hljs-string">&lt;aws_account_id&gt;.dkr.ecr.us-east-1.amazonaws.com/myapp:latest</span>
</code></pre>
<h3 id="heading-3-implement-zero-downtime-deployments">3. Implement Zero-Downtime Deployments</h3>
<p>Zero downtime is crucial. Kubernetes makes this easy with rolling updates:</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># Kubernetes deployment configured for zero-downtime updates</span>
<span class="hljs-attr">apiVersion:</span> <span class="hljs-string">apps/v1</span>
<span class="hljs-attr">kind:</span> <span class="hljs-string">Deployment</span>
<span class="hljs-attr">metadata:</span>
  <span class="hljs-attr">name:</span> <span class="hljs-string">crud-app</span>
<span class="hljs-attr">spec:</span>
  <span class="hljs-attr">replicas:</span> <span class="hljs-number">3</span>  <span class="hljs-comment"># Multiple replicas for high availability</span>
  <span class="hljs-attr">selector:</span>
    <span class="hljs-attr">matchLabels:</span>
      <span class="hljs-attr">app:</span> <span class="hljs-string">crud-app</span>
  <span class="hljs-attr">template:</span>
    <span class="hljs-attr">metadata:</span>
      <span class="hljs-attr">labels:</span>
        <span class="hljs-attr">app:</span> <span class="hljs-string">crud-app</span>
    <span class="hljs-attr">spec:</span>
      <span class="hljs-attr">containers:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">app</span>
        <span class="hljs-attr">image:</span> <span class="hljs-string">&lt;docker_registry&gt;/crud-app:latest</span>
        <span class="hljs-attr">ports:</span>
        <span class="hljs-bullet">-</span> <span class="hljs-attr">containerPort:</span> <span class="hljs-number">80</span>  <span class="hljs-comment"># Expose container port</span>
</code></pre>
<p>By having multiple replicas, you ensure that some pods stay live during updates.</p>
<h3 id="heading-4-create-cross-cloud-deployment-for-redundancy">4. Create Cross-Cloud Deployment for Redundancy</h3>
<p>If you want better reliability, you can deploy across different clouds in parallel:</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># Deploy to multiple cloud providers for redundancy</span>
<span class="hljs-attr">name:</span> <span class="hljs-string">Cross-Cloud</span> <span class="hljs-string">Deployment</span>

<span class="hljs-attr">on:</span>
  <span class="hljs-attr">push:</span>
    <span class="hljs-attr">branches:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">main</span>

<span class="hljs-attr">jobs:</span>
  <span class="hljs-comment"># Deploy to AWS</span>
  <span class="hljs-attr">aws-deploy:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">AWS</span> <span class="hljs-string">Setup</span> <span class="hljs-string">&amp;</span> <span class="hljs-string">Deploy</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">|
          # Configure AWS CLI with credentials
          aws configure set aws_access_key_id ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws configure set aws_secret_access_key ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          # AWS deployment commands...
</span>
  <span class="hljs-comment"># Deploy to Oracle Cloud in parallel</span>
  <span class="hljs-attr">oracle-deploy:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Oracle</span> <span class="hljs-string">Setup</span> <span class="hljs-string">&amp;</span> <span class="hljs-string">Deploy</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">|
          # Configure Oracle Cloud CLI
          oci setup config
          # Oracle Cloud deployment commands...</span>
</code></pre>
<p>Now if one cloud goes down, the other is still up.</p>
<h3 id="heading-5-implement-automated-rollbacks-with-health-checks">5. Implement Automated Rollbacks with Health Checks</h3>
<p>Set up health checks so Kubernetes can automatically rollback if something goes wrong:</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># Deployment with health checks for automated rollbacks</span>
<span class="hljs-attr">apiVersion:</span> <span class="hljs-string">apps/v1</span>
<span class="hljs-attr">kind:</span> <span class="hljs-string">Deployment</span>
<span class="hljs-attr">metadata:</span>
  <span class="hljs-attr">name:</span> <span class="hljs-string">crud-app</span>
<span class="hljs-attr">spec:</span>
  <span class="hljs-attr">replicas:</span> <span class="hljs-number">3</span>
  <span class="hljs-attr">selector:</span>
    <span class="hljs-attr">matchLabels:</span>
      <span class="hljs-attr">app:</span> <span class="hljs-string">crud-app</span>
  <span class="hljs-attr">template:</span>
    <span class="hljs-attr">metadata:</span>
      <span class="hljs-attr">labels:</span>
        <span class="hljs-attr">app:</span> <span class="hljs-string">crud-app</span>
    <span class="hljs-attr">spec:</span>
      <span class="hljs-attr">containers:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">crud-app</span>
        <span class="hljs-attr">image:</span> <span class="hljs-string">&lt;docker_registry&gt;/crud-app:latest</span>
        <span class="hljs-attr">ports:</span>
        <span class="hljs-bullet">-</span> <span class="hljs-attr">containerPort:</span> <span class="hljs-number">80</span>
        <span class="hljs-comment"># Check if the container is alive</span>
        <span class="hljs-attr">livenessProbe:</span>
          <span class="hljs-attr">httpGet:</span>
            <span class="hljs-attr">path:</span> <span class="hljs-string">/healthz</span>  <span class="hljs-comment"># Health check endpoint</span>
            <span class="hljs-attr">port:</span> <span class="hljs-number">80</span>
          <span class="hljs-attr">initialDelaySeconds:</span> <span class="hljs-number">5</span>  <span class="hljs-comment"># Wait before first check</span>
          <span class="hljs-attr">periodSeconds:</span> <span class="hljs-number">10</span>       <span class="hljs-comment"># Check every 10 seconds</span>
        <span class="hljs-comment"># Check if the container is ready to receive traffic</span>
        <span class="hljs-attr">readinessProbe:</span>
          <span class="hljs-attr">httpGet:</span>
            <span class="hljs-attr">path:</span> <span class="hljs-string">/readiness</span>  <span class="hljs-comment"># Readiness check endpoint</span>
            <span class="hljs-attr">port:</span> <span class="hljs-number">80</span>
          <span class="hljs-attr">initialDelaySeconds:</span> <span class="hljs-number">5</span>  <span class="hljs-comment"># Wait before first check</span>
          <span class="hljs-attr">periodSeconds:</span> <span class="hljs-number">10</span>       <span class="hljs-comment"># Check every 10 seconds</span>
</code></pre>
<h2 id="heading-how-to-build-a-comprehensive-monitoring-system">How to Build a Comprehensive Monitoring System</h2>
<p>Even with a small deployment, monitoring is key to spotting issues early. So now, I’ll walk through setting up a comprehensive monitoring system for your application.</p>
<p>You'll learn how to integrate Grafana Cloud for visualizing your metrics, use Prometheus for collecting data, and configure custom alerts to monitor your app's performance. I’ll also cover tracking Service Level Objectives (SLOs) and setting up external monitoring with UptimeRobot to make sure that your endpoints are always available.</p>
<h3 id="heading-1-set-up-grafana-clouds-free-tier">1. Set Up Grafana Cloud's Free Tier</h3>
<p>Create a Grafana Cloud account and connect Prometheus as a data source. They offer generous free usage, which is perfect for small teams.</p>
<h3 id="heading-2-configure-prometheus-for-metrics-collection">2. Configure Prometheus for Metrics Collection</h3>
<p>Prometheus collects metrics from your app.</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># prometheus.yml - Basic Prometheus configuration</span>
<span class="hljs-attr">global:</span>
  <span class="hljs-attr">scrape_interval:</span> <span class="hljs-string">15s</span>  <span class="hljs-comment"># Collect metrics every 15 seconds</span>
<span class="hljs-attr">scrape_configs:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">job_name:</span> <span class="hljs-string">'crud-app'</span>  <span class="hljs-comment"># Job name for the crud-app metrics</span>
    <span class="hljs-attr">static_configs:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">targets:</span> [<span class="hljs-string">'localhost:8080'</span>]  <span class="hljs-comment"># Where to collect metrics from</span>
</code></pre>
<p>This scrapes your app every 15 seconds for metrics.</p>
<h3 id="heading-3-create-monitoring-dashboards">3. Create Monitoring Dashboards</h3>
<p>Grafana visualizes Prometheus data. You can create dashboards using queries like:</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># Calculate average CPU usage rate per instance over 1 minute</span>
<span class="hljs-string">avg(rate(cpu_usage_seconds_total[1m]))</span> <span class="hljs-string">by</span> <span class="hljs-string">(instance)</span>
</code></pre>
<p>This calculates average CPU usage over the last minute per instance.</p>
<h3 id="heading-4-write-custom-promql-queries-for-alerts">4. Write Custom PromQL Queries for Alerts</h3>
<p>You can create smart alerts to detect increasing error rates, like the below:</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># Calculate error rate as a percentage of total requests</span>
<span class="hljs-comment"># Alert when error rate exceeds 5%</span>
<span class="hljs-string">sum(rate(http_requests_total{status=~"5.."}[5m]))</span> <span class="hljs-string">by</span> <span class="hljs-string">(service)</span>
  <span class="hljs-string">/</span> 
<span class="hljs-string">sum(rate(http_requests_total[5m]))</span> <span class="hljs-string">by</span> <span class="hljs-string">(service)</span> <span class="hljs-string">&gt;</span> <span class="hljs-number">0.05</span>
</code></pre>
<p>This alerts if more than 5% of your traffic results in errors.</p>
<h3 id="heading-5-implement-slo-tracking-on-a-budget">5. Implement SLO Tracking on a Budget</h3>
<p>You can track Service Level Objectives (SLOs) with Prometheus for free:</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># Calculate percentage of requests completed under 200ms</span>
<span class="hljs-comment"># Alert when it drops below 99%</span>
<span class="hljs-string">rate(http_request_duration_seconds_bucket{le="0.2"}[5m])</span> 
  <span class="hljs-string">/</span> <span class="hljs-string">rate(http_request_duration_seconds_count[5m])</span> 
<span class="hljs-string">&gt;</span> <span class="hljs-number">0.99</span>
</code></pre>
<p>This tracks if 99% of requests complete in under 200ms.</p>
<h3 id="heading-6-set-up-uptimerobot-for-external-monitoring">6. Set Up UptimeRobot for External Monitoring</h3>
<p>Finally, you can use UptimeRobot to check if your endpoints are reachable externally, and get alerts if anything goes down.</p>
<h2 id="heading-how-to-implement-security-testing-and-scanning">How to Implement Security Testing and Scanning</h2>
<p>Security should be integrated into your development pipeline from the start, not added as an afterthought. In this section, I’ll show you how to implement security testing and scanning at various stages of your workflow.</p>
<p>You’ll use GitHub CodeQL for static code analysis, OWASP ZAP for scanning web vulnerabilities, and Trivy for container image scanning. You’ll also learn how to enforce security thresholds directly in your CI pipeline.</p>
<h3 id="heading-1-enable-github-code-scanning-with-codeql">1. Enable GitHub Code Scanning with CodeQL</h3>
<p>GitHub has built-in code scanning with CodeQL<strong>.</strong> Here’s how to set it up:</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># GitHub workflow for CodeQL security scanning</span>
<span class="hljs-attr">name:</span> <span class="hljs-string">CodeQL</span>

<span class="hljs-attr">on:</span>
  <span class="hljs-attr">push:</span>
    <span class="hljs-attr">branches:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">main</span>
  <span class="hljs-attr">pull_request:</span>
    <span class="hljs-attr">branches:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">main</span>

<span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">analyze:</span>
    <span class="hljs-attr">name:</span> <span class="hljs-string">Analyze</span> <span class="hljs-string">code</span> <span class="hljs-string">with</span> <span class="hljs-string">CodeQL</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Checkout</span> <span class="hljs-string">code</span>
        <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>

      <span class="hljs-comment"># Initialize the CodeQL scanning tools</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Set</span> <span class="hljs-string">up</span> <span class="hljs-string">CodeQL</span>
        <span class="hljs-attr">uses:</span> <span class="hljs-string">github/codeql-action/init@v2</span>

      <span class="hljs-comment"># Run the analysis and generate results</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Analyze</span> <span class="hljs-string">code</span>
        <span class="hljs-attr">uses:</span> <span class="hljs-string">github/codeql-action/analyze@v2</span>
</code></pre>
<p>This automatically checks your code for security vulnerabilities.</p>
<h3 id="heading-2-integrate-owasp-zap-into-your-ci-pipeline">2. Integrate OWASP ZAP into Your CI Pipeline</h3>
<p>You can also scan your deployed app with OWASP ZAP like this:</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># Automated security scanning with OWASP ZAP</span>
<span class="hljs-attr">name:</span> <span class="hljs-string">ZAP</span> <span class="hljs-string">Scan</span>

<span class="hljs-attr">on:</span>
  <span class="hljs-attr">push:</span>
    <span class="hljs-attr">branches:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">main</span>

<span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">zap-scan:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Checkout</span> <span class="hljs-string">code</span>
        <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>

      <span class="hljs-comment"># Run the ZAP security scan against deployed application</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Run</span> <span class="hljs-string">ZAP</span> <span class="hljs-string">security</span> <span class="hljs-string">scan</span>
        <span class="hljs-attr">uses:</span> <span class="hljs-string">zaproxy/action-full-scan@v0.3.0</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">target:</span> <span class="hljs-string">'https://yourapp.com'</span>  <span class="hljs-comment"># URL to scan</span>
</code></pre>
<p>This checks for common web vulnerabilities.</p>
<h3 id="heading-3-set-up-trivy-for-container-vulnerability-scanning">3. Set Up Trivy for Container Vulnerability Scanning</h3>
<p>You can also check your container images for vulnerabilities with Trivy<strong>:</strong></p>
<pre><code class="lang-yaml"><span class="hljs-comment"># Scan Docker images for vulnerabilities using Trivy</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Run</span> <span class="hljs-string">Trivy</span> <span class="hljs-string">vulnerability</span> <span class="hljs-string">scanner</span>
  <span class="hljs-attr">uses:</span> <span class="hljs-string">aquasecurity/trivy-action@master</span>
  <span class="hljs-attr">with:</span>
    <span class="hljs-attr">image-ref:</span> <span class="hljs-string">'crud-app:latest'</span>   <span class="hljs-comment"># Image to scan</span>
    <span class="hljs-attr">format:</span> <span class="hljs-string">'table'</span>             <span class="hljs-comment"># Output format</span>
    <span class="hljs-attr">exit-code:</span> <span class="hljs-string">'1'</span>              <span class="hljs-comment"># Fail the build if vulnerabilities found</span>
    <span class="hljs-attr">ignore-unfixed:</span> <span class="hljs-literal">true</span>        <span class="hljs-comment"># Skip vulnerabilities without fixes</span>
    <span class="hljs-attr">severity:</span> <span class="hljs-string">'CRITICAL,HIGH'</span>   <span class="hljs-comment"># Only alert on critical and high severity</span>
</code></pre>
<p>Your builds will fail if serious issues are found, keeping you safe by default.</p>
<h3 id="heading-4-create-threshold-based-pipeline-failures">4. Create Threshold-Based Pipeline Failures</h3>
<p>You can configure your pipelines to fail automatically if vulnerabilities exceed a set threshold, enforcing strong security practices without manual effort. Here’s how that should look:</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># Fail the pipeline if critical or high vulnerabilities are found</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Run</span> <span class="hljs-string">Trivy</span> <span class="hljs-string">vulnerability</span> <span class="hljs-string">scanner</span>
  <span class="hljs-attr">uses:</span> <span class="hljs-string">aquasecurity/trivy-action@master</span>
  <span class="hljs-attr">with:</span>
    <span class="hljs-attr">image-ref:</span> <span class="hljs-string">'crud-app:latest'</span>   <span class="hljs-comment"># Image to scan</span>
    <span class="hljs-attr">format:</span> <span class="hljs-string">'json'</span>              <span class="hljs-comment"># Output as JSON for parsing</span>
    <span class="hljs-attr">exit-code:</span> <span class="hljs-string">'1'</span>              <span class="hljs-comment"># Fail the build if vulnerabilities found</span>
    <span class="hljs-attr">severity:</span> <span class="hljs-string">'CRITICAL,HIGH'</span>   <span class="hljs-comment"># Check for critical and high severity issues</span>
    <span class="hljs-attr">ignore-unfixed:</span> <span class="hljs-literal">true</span>        <span class="hljs-comment"># Skip vulnerabilities without fixes</span>
</code></pre>
<p>This forces a no-compromise security posture – that is, if critical or high vulnerabilities are detected, the build stops immediately.</p>
<h3 id="heading-5-implement-custom-security-checks">5. Implement Custom Security Checks</h3>
<p>Sometimes you need to go beyond automated scanners. Here's a basic example of a custom security check you can add to your pipeline:</p>
<pre><code class="lang-yaml"><span class="hljs-comment">#!/bin/bash</span>

<span class="hljs-comment"># Custom script to check for hard-coded secrets in source code</span>
<span class="hljs-comment"># Check for hard-coded API keys in source files</span>
<span class="hljs-string">if</span> <span class="hljs-string">grep</span> <span class="hljs-string">-r</span> <span class="hljs-string">"API_KEY"</span> <span class="hljs-string">./src;</span> <span class="hljs-string">then</span>
  <span class="hljs-string">echo</span> <span class="hljs-string">"Security issue: Found hard-coded API keys."</span>
  <span class="hljs-string">exit</span> <span class="hljs-number">1</span>  <span class="hljs-comment"># Fail the build</span>
<span class="hljs-string">else</span>
  <span class="hljs-string">echo</span> <span class="hljs-string">"No hard-coded API keys found."</span>
<span class="hljs-string">fi</span>
</code></pre>
<p>You can extend this script to scan for patterns like private keys, passwords, or other sensitive information, helping catch issues before they ever reach production.</p>
<h2 id="heading-performance-optimization-and-scaling">Performance Optimization and Scaling</h2>
<p>Optimizing early saves you pain later. Here’s how to make your pipelines faster, smarter, and more scalable:</p>
<h3 id="heading-1-measure-pipeline-execution-times">1. Measure Pipeline Execution Times</h3>
<p>Understanding how long each step takes is the first step to improving it:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">build:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-comment"># Record the start time</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Start</span> <span class="hljs-string">timer</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">echo</span> <span class="hljs-string">"Start time: $(date)"</span>

      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">install</span>

      <span class="hljs-comment"># Record the end time to calculate duration</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">End</span> <span class="hljs-string">timer</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">echo</span> <span class="hljs-string">"End time: $(date)"</span>
</code></pre>
<p>Later, you can automate time tracking for full reports and alerts.</p>
<h3 id="heading-2-implement-parallelization-strategies">2. Implement Parallelization Strategies</h3>
<p>Split your jobs smartly to save time:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">jobs:</span>
  <span class="hljs-comment"># First job to install dependencies</span>
  <span class="hljs-attr">install:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">ci</span>

  <span class="hljs-comment"># Run tests in parallel with linting</span>
  <span class="hljs-attr">test:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">needs:</span> <span class="hljs-string">install</span>  <span class="hljs-comment"># Depends on install job</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">test</span>

  <span class="hljs-comment"># Run linting in parallel with tests</span>
  <span class="hljs-attr">lint:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">needs:</span> <span class="hljs-string">install</span>  <span class="hljs-comment"># Also depends on install job</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">run</span> <span class="hljs-string">lint</span>
</code></pre>
<p>Result: Testing and linting run in parallel after installing dependencies, cutting pipeline time significantly.</p>
<h3 id="heading-3-set-up-distributed-caching">3. Set Up Distributed Caching</h3>
<p>Caching saves your workflow from repeating expensive tasks:</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># Cache dependencies to speed up builds</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Cache</span> <span class="hljs-string">node</span> <span class="hljs-string">modules</span>
  <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/cache@v3</span>
  <span class="hljs-attr">with:</span>
    <span class="hljs-attr">path:</span> <span class="hljs-string">|
      ~/.npm           # Cache global npm cache
      node_modules     # Cache local dependencies
</span>    <span class="hljs-attr">key:</span> <span class="hljs-string">${{</span> <span class="hljs-string">runner.os</span> <span class="hljs-string">}}-node-${{</span> <span class="hljs-string">hashFiles('**/package-lock.json')</span> <span class="hljs-string">}}</span>  <span class="hljs-comment"># Key based on OS and dependency hash</span>
    <span class="hljs-attr">restore-keys:</span> <span class="hljs-string">|</span>    <span class="hljs-comment"># Fallback keys if exact match isn't found</span>
      <span class="hljs-string">${{</span> <span class="hljs-string">runner.os</span> <span class="hljs-string">}}-node-</span>
</code></pre>
<p><strong>Tip:</strong> Also cache build artifacts, Docker layers, and Terraform plans when possible.</p>
<h3 id="heading-4-create-performance-benchmarks">4. Create Performance Benchmarks</h3>
<p>Track your build times over time with benchmarks:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">build:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-comment"># Store the start time as an environment variable</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Start</span> <span class="hljs-string">timer</span>
        <span class="hljs-attr">id:</span> <span class="hljs-string">start_time</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">echo</span> <span class="hljs-string">"start_time=$(date +%s)"</span> <span class="hljs-string">&gt;&gt;</span> <span class="hljs-string">$GITHUB_ENV</span>

      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">install</span>

      <span class="hljs-comment"># Calculate and display the elapsed time</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">End</span> <span class="hljs-string">timer</span> <span class="hljs-string">and</span> <span class="hljs-string">calculate</span> <span class="hljs-string">elapsed</span> <span class="hljs-string">time</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">|
          end_time=$(date +%s)
          elapsed_time=$((end_time - ${{ env.start_time }}))
          echo "Build time: $elapsed_time seconds"</span>
</code></pre>
<p>With benchmarks in place, you can monitor regressions and trigger optimizations automatically.</p>
<h3 id="heading-5-how-to-plan-for-growth-beyond-free-tiers">5. How to Plan for Growth Beyond Free Tiers</h3>
<ul>
<li><p><strong>Understand cloud pricing structures:</strong> AWS, Azure, GCP all offer generous free tiers, but know the limits to avoid surprise bills. <em>(I have been there and it wasn’t pretty.)</em></p>
</li>
<li><p><strong>Consider scaling to more advanced CI/CD tools:</strong> Jenkins, CircleCI, GitLab can offer better performance or self-hosted control as you grow.</p>
</li>
<li><p><strong>Automate resource provisioning:</strong> Use Infrastructure as Code (IaC) with Terraform, Pulumi, or AWS CDK to dynamically scale your infrastructure when your team or traffic grows.</p>
</li>
</ul>
<h2 id="heading-complete-cicd-pipeline-example">Complete CI/CD Pipeline Example</h2>
<p>Here’s a full example tying everything together:</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># Complete end-to-end CI/CD pipeline</span>
<span class="hljs-attr">name:</span> <span class="hljs-string">CI/CD</span> <span class="hljs-string">Pipeline</span>

<span class="hljs-attr">on:</span>
  <span class="hljs-attr">push:</span>
    <span class="hljs-attr">branches:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">main</span>

<span class="hljs-attr">jobs:</span>
  <span class="hljs-comment"># Initial setup job</span>
  <span class="hljs-attr">setup:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Checkout</span> <span class="hljs-string">code</span>
        <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>

  <span class="hljs-comment"># Build and test job</span>
  <span class="hljs-attr">build:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">needs:</span> <span class="hljs-string">setup</span>  <span class="hljs-comment"># Depends on setup job</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Setup</span> <span class="hljs-string">Node.js</span>
        <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/setup-node@v3</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">node-version:</span> <span class="hljs-string">'16'</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Install</span> <span class="hljs-string">dependencies</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">install</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Run</span> <span class="hljs-string">security</span> <span class="hljs-string">scan</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">npx</span> <span class="hljs-string">eslint</span> <span class="hljs-string">.</span>  <span class="hljs-comment"># Run ESLint for security rules</span>

  <span class="hljs-comment"># Deploy to Kubernetes job</span>
  <span class="hljs-attr">deploy:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">needs:</span> <span class="hljs-string">build</span>  <span class="hljs-comment"># Depends on successful build</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Setup</span> <span class="hljs-string">K3d</span> <span class="hljs-string">cluster</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">k3d</span> <span class="hljs-string">cluster</span> <span class="hljs-string">create</span> <span class="hljs-string">dev-cluster</span> <span class="hljs-string">--servers</span> <span class="hljs-number">1</span> <span class="hljs-string">--agents</span> <span class="hljs-number">2</span> <span class="hljs-string">--port</span> <span class="hljs-number">8080</span><span class="hljs-string">:80@loadbalancer</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Apply</span> <span class="hljs-string">Kubernetes</span> <span class="hljs-string">manifests</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">kubectl</span> <span class="hljs-string">apply</span> <span class="hljs-string">-f</span> <span class="hljs-string">k8s/</span>  <span class="hljs-comment"># Apply all K8s manifests in the k8s directory</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Deploy</span> <span class="hljs-string">app</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">kubectl</span> <span class="hljs-string">rollout</span> <span class="hljs-string">restart</span> <span class="hljs-string">deployment/webapp</span>  <span class="hljs-comment"># Restart deployment for zero-downtime update</span>

  <span class="hljs-comment"># Infrastructure provisioning job</span>
  <span class="hljs-attr">terraform:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">needs:</span> <span class="hljs-string">deploy</span>  <span class="hljs-comment"># Run after deployment</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Setup</span> <span class="hljs-string">Terraform</span>
        <span class="hljs-attr">uses:</span> <span class="hljs-string">hashicorp/setup-terraform@v2</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Terraform</span> <span class="hljs-string">Init</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">terraform</span> <span class="hljs-string">init</span>  <span class="hljs-comment"># Initialize Terraform</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Terraform</span> <span class="hljs-string">Apply</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">terraform</span> <span class="hljs-string">apply</span> <span class="hljs-string">-auto-approve</span>  <span class="hljs-comment"># Apply infrastructure changes automatically</span>
</code></pre>
<h4 id="heading-runbook-failed-deployment"><strong>Runbook: Failed Deployment:</strong></h4>
<p><strong>Issue</strong>: Pods fail due to resource limits (for example, OOMKilled, CrashLoopBackOff).<br><strong>Fix</strong>:</p>
<pre><code class="lang-yaml">  <span class="hljs-string">kubectl</span> <span class="hljs-string">top</span> <span class="hljs-string">pod</span>
  <span class="hljs-string">kubectl</span> <span class="hljs-string">edit</span> <span class="hljs-string">deployment</span> <span class="hljs-string">crud-app</span>
  <span class="hljs-string">kubectl</span> <span class="hljs-string">apply</span> <span class="hljs-string">-f</span> <span class="hljs-string">deployment.yaml</span>
  <span class="hljs-string">kubectl</span> <span class="hljs-string">rollout</span> <span class="hljs-string">status</span> <span class="hljs-string">deployment/crud-app</span>
</code></pre>
<p><strong>Tip:</strong> Set realistic resource requests and limits early, it'll save you debugging time later.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>By following along with this tutorial, you now know how to build a production-ready DevOps pipeline using free tools:</p>
<ul>
<li><p><strong>CI/CD</strong>: GitHub Actions for testing, linting, and building.</p>
</li>
<li><p><strong>Infrastructure</strong>: Terraform for AWS/Render and PostgreSQL setup.</p>
</li>
<li><p><strong>Orchestration</strong>: K3d for local Kubernetes.</p>
</li>
<li><p><strong>Monitoring</strong>: Grafana, Prometheus, UptimeRobot.</p>
</li>
<li><p><strong>Security</strong>: CodeQL, OWASP ZAP, Trivy for vulnerability scanning.</p>
</li>
</ul>
<p>This pipeline is scalable and secure, and it’s perfect for small projects. As your app grows, you might want to consider paid plans for more resources (for example, AWS larger instances, Grafana unlimited metrics). You can check <a target="_blank" href="https://aws.amazon.com/free/">AWS Free Tier</a>, <a target="_blank" href="https://developer.hashicorp.com/terraform/docs">Terraform Docs</a>, and <a target="_blank" href="https://grafana.com/docs/">Grafana Docs</a> for more learning.</p>
<p><strong>PS:</strong> I’d love to see what you build. Share your pipeline on <a target="_blank" href="https://forum.freecodecamp.org/">FreeCodeCamp’s forum</a> or tag me on X <a target="_blank" href="https://x.com/Emidowojo">@Emidowojo</a> with #DevOpsOnABudget, and tell me about the challenges you faced. You can also connect with me on <a target="_blank" href="https://www.linkedin.com/in/emidowojo/">LinkedIn</a> if you’d like to stay in touch. If you made it to the end of this lengthy article, thanks for reading!</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
