<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Precious Okpor</title>
    <description>The latest articles on DEV Community by Precious Okpor (@devpops).</description>
    <link>https://dev.to/devpops</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3845312%2Fb62122dd-31f7-47b9-b31b-233c9dde6a7a.png</url>
      <title>DEV Community: Precious Okpor</title>
      <link>https://dev.to/devpops</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/devpops"/>
    <language>en</language>
    <item>
      <title>Your AI agent skills are probably out of date. Mine were. Here's how I handled it.</title>
      <dc:creator>Precious Okpor</dc:creator>
      <pubDate>Tue, 03 Nov 2026 17:55:34 +0000</pubDate>
      <link>https://dev.to/devpops/your-ai-agent-skills-are-probably-out-of-date-mine-were-heres-how-i-handled-it-30mc</link>
      <guid>https://dev.to/devpops/your-ai-agent-skills-are-probably-out-of-date-mine-were-heres-how-i-handled-it-30mc</guid>
      <description>&lt;p&gt;My old agent skill was one long file and it told every agent I used to run MinIO for local S3. Then the &lt;code&gt;minio/minio&lt;/code&gt; repository on GitHub was archived, and the skill went on recommending it. The agents had no way to know. They just did what the file said.&lt;/p&gt;

&lt;p&gt;Instructions for AI agents go stale the same way documentation does, except an agent acts on them without asking whether they're still true. So I rebuilt mine as &lt;a href="https://github.com/poppyszn/dev-pops-skills" rel="noopener noreferrer"&gt;dev-pops-skills&lt;/a&gt;, a set of agent skills built around one idea: every fact in them carries the date it was last checked, and something notices when that date gets old.&lt;/p&gt;

&lt;p&gt;In this post:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;what the skills are&lt;/li&gt;
&lt;li&gt;how they stay fresh, which is most of the post&lt;/li&gt;
&lt;li&gt;what happened when I measured whether agents actually use the skills (20 out of 31)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Seven small skills instead of one long file
&lt;/h2&gt;

&lt;p&gt;dev-pops-skills is my DevOps defaults written down as seven small skills: Docker, CI/CD, Terraform, security scanning, shell scripting, whole-project setup, and one for writing and auditing the skills themselves. They work in Claude Code, Antigravity and any agent that supports the open Agent Skills format. An agent loads only the skill that matches the request, which matters because my old single file was so long the agents rarely followed it.&lt;/p&gt;

&lt;p&gt;Each skill lists the defaults it applies without asking, such as pinned image tags and GitHub OIDC roles in place of stored AWS keys. It lists what needs a yes first: deleting volumes, pushing images, running &lt;code&gt;terraform apply&lt;/code&gt;. And it ends with checks whose output the agent has to paste back, so "done" means something I can read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every fact has a date on it
&lt;/h2&gt;

&lt;p&gt;The repo notices when something goes stale, then an agent updates it with me.&lt;/p&gt;

&lt;p&gt;Every external fact a skill relies on lives in a &lt;code&gt;sources.md&lt;/code&gt; file next to it, with the link it came from and the date it was checked. That covers image tags, action versions, install paths and whether a project still exists. Facts are looked up online when they're written, never from the model's memory. The MinIO fix, for example, is one row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;| MinIO's GitHub repo `minio/minio` is archived. | https://github.com/minio/minio | 2026-09-26 |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four things watch those facts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Skills expire. Each skill records when it was last fully re-checked, and the validator that runs on every pull request warns once that's more than 90 days ago.&lt;/li&gt;
&lt;li&gt;A weekly job checks every pin. Every Monday a GitHub Action confirms that each pinned image tag still exists on Docker Hub, and that each pinned GitHub Action exists, isn't archived and isn't a major version behind. On any drift it opens an issue.&lt;/li&gt;
&lt;li&gt;Dependabot opens pull requests for minor and patch updates to base images. It ignores major versions on purpose: moving the templates to a new Node major changes a default, and that needs a decision from me, not an automatic PR.&lt;/li&gt;
&lt;li&gt;Real projects report back. When an agent hits a broken or missing template while working in one of my projects, it fixes the project with the smallest change it can, then files a finding in the repo's &lt;code&gt;contributions/&lt;/code&gt; folder with a reproduction. It never names the client, and a CI check fails the build if a name from my private list slips through.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When the weekly issue opens, or findings pile up, I ask an agent to "audit the skills". The skill-authoring skill takes over. It re-checks each fact online and shows me a table of what changed before it edits anything. It applies only the fixes I approve, then bumps the skill's version, adds a changelog entry, updates the dates and opens a pull request. Nothing changes without my approval, and &lt;code&gt;main&lt;/code&gt; only accepts a PR that passes five CI checks.&lt;/p&gt;

&lt;p&gt;The same loop caught my own tooling: when I moved from Gemini CLI to Antigravity, the install paths changed, and that went in with a source and a date like everything else.&lt;/p&gt;

&lt;p&gt;The checks need checking too. I once added two linters set to warn without blocking, so I could fix the existing files first, and they showed a green tick on every run. The logs said both had been failing all along, because GitHub shows a non-blocking failure as a pass. So once in a while I open the logs of the checks themselves instead of trusting the tick.&lt;/p&gt;

&lt;h2&gt;
  
  
  I checked whether the agents actually use them
&lt;/h2&gt;

&lt;p&gt;A skill only helps if the agent decides to load it, and it decides from a one-paragraph description. So I wrote 31 requests the way I'd phrase them, each paired with the skill that should load, plus four that should load none. A script sends each one to a headless Claude Code that has only these skills and records which skill it loads first.&lt;/p&gt;

&lt;p&gt;I didn't want to install the CLI on my laptop for this, so the whole test runs in a throwaway Docker container. The image installs Claude Code from Anthropic's signed apt repository, and the build fails if the signing key's fingerprint doesn't match the one in their docs. I logged in once inside the container, the login lived in a Docker volume, and the repo was mounted read-only so nothing in there could change it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; dev-pops-eval-home:/home/eval &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;:/repo:ro"&lt;/span&gt; dev-pops-eval ./scripts/eval-triggers.sh &lt;span class="nt"&gt;--run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I was done, I deleted the volume and the image, and my machine was back where it started.&lt;/p&gt;

&lt;p&gt;The first full run, on Haiku, got 20 of 31. No request loaded the wrong skill, and all four unrelated ones ("write a React component for a sortable table") correctly loaded nothing. Every miss loaded no skill at all.&lt;/p&gt;

&lt;p&gt;Most of those misses were my test's fault. I had asked the agent to "tidy this Makefile" in a project with no Makefile, and it said so, which is the right answer. Each request now runs against a small stand-in project that has the files it mentions. One miss was real: "write a shell script that waits for Postgres" never loaded the scripting skill, and that description is next on my list.&lt;/p&gt;

&lt;p&gt;The test also drained my Claude session. I wanted one fact from each run, which skill loaded first, but every request started a full agent session that kept going after it had loaded the skill. It explored the project, read the skill's reference files, and several runs wrote me careful plans for work I would never read. After one full run, I queued two more to measure variance, and eleven prompts into the first of them my five-hour usage window ran dry. A test about whether agents follow instructions had been spending my limit on agents following instructions very thoroughly. Now each run stops the moment a skill loads, gets read-only tools, and is capped at three turns and three cents. By default it runs a sample of nine prompts.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to try it
&lt;/h2&gt;

&lt;p&gt;These are my defaults: Node and TypeScript, Docker Compose, GitHub Actions, and AWS with Terraform. If yours differ, fork the repo and change them; the skill-authoring skill walks an agent through it, and the freshness checks come with it once you enable Actions on your fork (GitHub turns scheduled workflows off on forks).&lt;/p&gt;

&lt;p&gt;The repo is &lt;a href="https://github.com/poppyszn/dev-pops-skills" rel="noopener noreferrer"&gt;poppyszn/dev-pops-skills&lt;/a&gt;, MIT licensed. If you found this useful, star it so the next updates show up in your feed, and share the post with whoever on your team keeps the agent instructions. I'd like to hear how other people keep theirs from going stale.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>How to Properly Set Up K3s on Your Homelab or Server (2026 Edition)</title>
      <dc:creator>Precious Okpor</dc:creator>
      <pubDate>Sat, 13 Jun 2026 12:46:59 +0000</pubDate>
      <link>https://dev.to/devpops/how-to-properly-set-up-k3s-on-your-homelab-or-server-2026-edition-595</link>
      <guid>https://dev.to/devpops/how-to-properly-set-up-k3s-on-your-homelab-or-server-2026-edition-595</guid>
      <description>&lt;p&gt;I recently migrated my homelab from Docker + Jenkins to K3S because it was my production infrastructure that runs K3S on AWS, and my homelab should mirror that same flow.&lt;/p&gt;

&lt;p&gt;This guide documents exactly how I set up K3S, MetalLB, Traefik, cert-manager with Let's Encrypt, and ArgoCD. All on a single Ubuntu 26.04 VM.&lt;/p&gt;

&lt;p&gt;One important thing most 2025 tutorials won't tell you is that ingress-nginx was archived on March 24, 2026, which means no more releases or security patches. The good news is K3S ships Traefik v3 as its default ingress controller, so you get a maintained, production-grade ingress out of the box.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We're Building
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;K3s v1.33&lt;/td&gt;
&lt;td&gt;Lightweight Kubernetes runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MetalLB v0.16.1&lt;/td&gt;
&lt;td&gt;LoadBalancer IP assignment for bare metal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Traefik v3&lt;/td&gt;
&lt;td&gt;Ingress controller (bundled with K3s)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cert-manager&lt;/td&gt;
&lt;td&gt;Automated TLS certificate management&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Let's Encrypt (HTTP-01)&lt;/td&gt;
&lt;td&gt;Free, trusted certificates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ArgoCD&lt;/td&gt;
&lt;td&gt;GitOps continuous delivery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Helm 3&lt;/td&gt;
&lt;td&gt;Package manager&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A VM or server running &lt;strong&gt;Ubuntu 24.04 and above&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;public domain&lt;/strong&gt; with an A record pointing to your server's public IP (Optional)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port 80 and 443 open&lt;/strong&gt; on your server/firewall (required for Let's Encrypt HTTP-01 challenge)&lt;/li&gt;
&lt;li&gt;A local IP subnet for MetalLB (e.g. &lt;code&gt;192.168.1.0/24&lt;/code&gt;) — adjust to match your network&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: System Prep
&lt;/h2&gt;

&lt;p&gt;Before installing anything, get the system into the right state.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="c"&gt;# Install dependencies&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; curl wget git open-iscsi nfs-common

&lt;span class="c"&gt;# Disable swap (Kubernetes requires this)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;swapoff &lt;span class="nt"&gt;-a&lt;/span&gt;
&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'/ swap / s/^\(.*\)$/#\1/g'&lt;/span&gt; /etc/fstab

&lt;span class="c"&gt;# Load required kernel modules&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;modprobe overlay
&lt;span class="nb"&gt;sudo &lt;/span&gt;modprobe br_netfilter

&lt;span class="c"&gt;# Persist kernel modules across reboots&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt; | sudo tee /etc/modules-load.d/k3s.conf
overlay
br_netfilter
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# Set required sysctl parameters&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt; | sudo tee /etc/sysctl.d/k3s.conf
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward                 = 1
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: Install K3s
&lt;/h2&gt;

&lt;p&gt;K3s ships with its own built-in ServiceLB (Klipper). We disable it because MetalLB will handle LoadBalancer IP assignment instead. Traefik stays on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sfL&lt;/span&gt; https://get.k3s.io | &lt;span class="nv"&gt;INSTALL_K3S_CHANNEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;stable sh &lt;span class="nt"&gt;-s&lt;/span&gt; - &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--disable&lt;/span&gt; servicelb &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--write-kubeconfig-mode&lt;/span&gt; 644

&lt;span class="c"&gt;# Wait for node to be ready&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;kubectl &lt;span class="nb"&gt;wait&lt;/span&gt; &lt;span class="nt"&gt;--for&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;condition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ready node &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;120s

&lt;span class="c"&gt;# Verify&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;kubectl get nodes
&lt;span class="nb"&gt;sudo &lt;/span&gt;kubectl get pods &lt;span class="nt"&gt;-A&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set up kubeconfig for your non-root user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/.kube
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; /etc/rancher/k3s/k3s.yaml ~/.kube/config
&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nv"&gt;$USER&lt;/span&gt;:&lt;span class="nv"&gt;$USER&lt;/span&gt; ~/.kube/config
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 ~/.kube/config

&lt;span class="c"&gt;# Persist in shell&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'export KUBECONFIG=~/.kube/config'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.bashrc
&lt;span class="nb"&gt;source&lt;/span&gt; ~/.bashrc

&lt;span class="c"&gt;# Confirm it works&lt;/span&gt;
kubectl get nodes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see your node in &lt;code&gt;Ready&lt;/code&gt; state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Install Helm
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash

&lt;span class="c"&gt;# Verify&lt;/span&gt;
helm version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: Install MetalLB
&lt;/h2&gt;

&lt;p&gt;On bare metal and VMs, &lt;code&gt;LoadBalancer&lt;/code&gt; type services stay stuck in &lt;code&gt;&amp;lt;pending&amp;gt;&lt;/code&gt; forever without a load balancer controller. MetalLB fixes this by assigning real IPs from a pool you define.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Add the MetalLB Helm repo&lt;/span&gt;
helm repo add metallb https://metallb.github.io/metallb
helm repo update

&lt;span class="c"&gt;# Install&lt;/span&gt;
helm &lt;span class="nb"&gt;install &lt;/span&gt;metallb metallb/metallb &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; metallb-system &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--wait&lt;/span&gt;

&lt;span class="c"&gt;# Confirm pods are running&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; metallb-system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now configure the IP address pool. Pick a range from your local subnet that is &lt;strong&gt;outside your router's DHCP range&lt;/strong&gt; to avoid conflicts. Adjust &lt;code&gt;192.168.1.200-192.168.1.220&lt;/code&gt; to match your network:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt; | kubectl apply -f -
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: homelab-pool
  namespace: metallb-system
spec:
  addresses:
  - 192.168.1.200-192.168.1.220
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: homelab-l2
  namespace: metallb-system
spec:
  ipAddressPools:
  - homelab-pool
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# Verify&lt;/span&gt;
kubectl get ipaddresspool &lt;span class="nt"&gt;-n&lt;/span&gt; metallb-system
kubectl get l2advertisement &lt;span class="nt"&gt;-n&lt;/span&gt; metallb-system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 5: Verify Traefik
&lt;/h2&gt;

&lt;p&gt;K3S already installed Traefik v3 during setup. You don't need to install it separately. Just confirm it's running and that MetalLB has assigned it an IP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Traefik should be running in kube-system&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system | &lt;span class="nb"&gt;grep &lt;/span&gt;traefik

&lt;span class="c"&gt;# The service should now have an EXTERNAL-IP from your MetalLB pool&lt;/span&gt;
kubectl get svc traefik &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see an IP in the &lt;code&gt;192.168.1.200-220&lt;/code&gt; range (or whichever range you set) in the &lt;code&gt;EXTERNAL-IP&lt;/code&gt; column. That's MetalLB doing its job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Install cert-manager
&lt;/h2&gt;

&lt;p&gt;cert-manager automates the full lifecycle of TLS certificates — requesting, renewing, and storing them as Kubernetes secrets. We use the HTTP-01 challenge, which means Let's Encrypt will verify your domain by making an HTTP request to your server.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Add the Jetstack Helm repo&lt;/span&gt;
helm repo add jetstack https://charts.jetstack.io
helm repo update

&lt;span class="c"&gt;# Install cert-manager with CRDs&lt;/span&gt;
helm &lt;span class="nb"&gt;install &lt;/span&gt;cert-manager jetstack/cert-manager &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; cert-manager &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; crds.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--wait&lt;/span&gt;

&lt;span class="c"&gt;# Verify all 3 pods are running: controller, cainjector, webhook&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; cert-manager
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now create a &lt;code&gt;ClusterIssuer&lt;/code&gt; to tell cert-manager how to obtain certificates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt; | kubectl apply -f -
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: your@email.com        # ← replace with your email
    privateKeySecretRef:
      name: letsencrypt-prod-key
    solvers:
    - http01:
        ingress:
          class: traefik
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# Verify the issuer is ready&lt;/span&gt;
kubectl get clusterissuer letsencrypt-prod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;STATUS&lt;/code&gt; should show &lt;code&gt;True&lt;/code&gt; under &lt;code&gt;READY&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Install ArgoCD
&lt;/h2&gt;

&lt;p&gt;ArgoCD gives you GitOps-based deployments — your cluster state is driven by Git, not manual &lt;code&gt;kubectl apply&lt;/code&gt; commands. It's the right pattern for a homelab that mirrors how production should work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Add the Argo Helm repo&lt;/span&gt;
helm repo add argo https://argoproj.github.io/argo-helm
helm repo update

&lt;span class="c"&gt;# Install ArgoCD&lt;/span&gt;
helm &lt;span class="nb"&gt;install &lt;/span&gt;argocd argo/argo-cd &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; argocd &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; server.service.type&lt;span class="o"&gt;=&lt;/span&gt;LoadBalancer &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--wait&lt;/span&gt;

&lt;span class="c"&gt;# Verify pods are running&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; argocd

&lt;span class="c"&gt;# Get the external IP (assigned by MetalLB)&lt;/span&gt;
kubectl get svc argocd-server &lt;span class="nt"&gt;-n&lt;/span&gt; argocd

&lt;span class="c"&gt;# Get the initial admin password&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd get secret argocd-initial-admin-secret &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"{.data.password}"&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Access the ArgoCD UI at &lt;code&gt;https://&amp;lt;EXTERNAL-IP&amp;gt;&lt;/code&gt; with username &lt;code&gt;admin&lt;/code&gt; and the password above. Change the password after the first login.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8: Smoke Test
&lt;/h2&gt;

&lt;p&gt;Deploy a quick test app to confirm the full stack is working end to end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Deploy a test nginx pod&lt;/span&gt;
kubectl create deployment test-app &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nginx:alpine

&lt;span class="c"&gt;# Expose it as a LoadBalancer service&lt;/span&gt;
kubectl expose deployment test-app &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;80 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;LoadBalancer &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;test-app-svc

&lt;span class="c"&gt;# Watch for MetalLB to assign an external IP&lt;/span&gt;
kubectl get svc test-app-svc &lt;span class="nt"&gt;--watch&lt;/span&gt;

&lt;span class="c"&gt;# Once an IP appears, curl it&lt;/span&gt;
curl http://&amp;lt;EXTERNAL-IP&amp;gt;

&lt;span class="c"&gt;# Clean up&lt;/span&gt;
kubectl delete deployment test-app
kubectl delete svc test-app-svc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you get an nginx welcome page, everything is wired up correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Deploy an App with TLS
&lt;/h2&gt;

&lt;p&gt;Once you deploy a real app, here's what an Ingress resource looks like using Traefik + cert-manager. Just add the annotation, and cert-manager handles the certificate request automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ingress&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-app&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cert-manager.io/cluster-issuer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;letsencrypt-prod"&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ingressClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp.yourdomain.com&lt;/span&gt;
    &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
        &lt;span class="na"&gt;pathType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prefix&lt;/span&gt;
        &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-app-svc&lt;/span&gt;
            &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
  &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;myapp.yourdomain.com&lt;/span&gt;
    &lt;span class="na"&gt;secretName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp-tls&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply it, and cert-manager will automatically:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Detect the &lt;code&gt;cert-manager.io/cluster-issuer&lt;/code&gt; annotation&lt;/li&gt;
&lt;li&gt;Create a Certificate resource&lt;/li&gt;
&lt;li&gt;Complete the HTTP-01 challenge with Let's Encrypt&lt;/li&gt;
&lt;li&gt;Store the issued cert in the &lt;code&gt;myapp-tls&lt;/code&gt; secret&lt;/li&gt;
&lt;li&gt;Renew it automatically before expiry&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;With this foundation in place, here's what I'd layer on next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prometheus + Grafana&lt;/strong&gt;: observability for the cluster (you already have prom-client experience, this maps directly)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gitea + Woodpecker CI&lt;/strong&gt;: lightweight self-hosted CI that replaces Jenkins&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Harbor&lt;/strong&gt;: private container registry running inside the cluster&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Longhorn&lt;/strong&gt;: persistent storage for stateful workloads&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal is a homelab that serves as a direct rehearsal environment for production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;System prep → K3s → Helm → MetalLB → Traefik (built-in) → cert-manager → ArgoCD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every piece here is actively maintained in 2026. No ingress-nginx, no Klipper ServiceLB conflicts, no Jenkins.&lt;/p&gt;

&lt;p&gt;If this helped you, drop a comment with what you're running on your homelab. Always curious what other engineers are self-hosting.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>k3s</category>
      <category>devops</category>
      <category>homelab</category>
    </item>
    <item>
      <title>How I Containerised 5 Monoliths and Deployed Them to EKS</title>
      <dc:creator>Precious Okpor</dc:creator>
      <pubDate>Wed, 08 Apr 2026 15:18:55 +0000</pubDate>
      <link>https://dev.to/devpops/how-i-containerised-5-monoliths-and-deployed-them-to-eks-3p2</link>
      <guid>https://dev.to/devpops/how-i-containerised-5-monoliths-and-deployed-them-to-eks-3p2</guid>
      <description>&lt;p&gt;There's a blog post I've always found frustrating: the kind that shows you a perfect Dockerfile, a clean &lt;code&gt;terraform apply&lt;/code&gt;, and a screenshot of everything working on the first try. No errors. No wrong turns.&lt;/p&gt;

&lt;p&gt;This isn't that post.&lt;/p&gt;

&lt;p&gt;I'm a DevOps and Cloud Engineer — in practice, I do DevOps and SRE work: Kubernetes clusters, CI/CD pipelines, AWS infrastructure. I containerise things regularly. But I'd never sat down and worked through five different stacks back to back, treating each one as a distinct challenge.&lt;/p&gt;

&lt;p&gt;So I did. Here's what I built, what broke, and what I learned.&lt;/p&gt;




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

&lt;p&gt;Five demo apps. Each represents a different monolith archetype. Each has a &lt;code&gt;/health&lt;/code&gt; endpoint and one meaningful route — simple enough that the app isn't the distraction. AI helped with creating the apps so I could focus on the DevOps aspects of the project.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;App&lt;/th&gt;
&lt;th&gt;Stack&lt;/th&gt;
&lt;th&gt;What it teaches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app-node-api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Node.js + Express&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;node_modules&lt;/code&gt;, &lt;code&gt;.dockerignore&lt;/code&gt; discipline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app-python-api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Python + Flask&lt;/td&gt;
&lt;td&gt;Slim vs Alpine tradeoffs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app-nestjs-api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;NestJS (TypeScript)&lt;/td&gt;
&lt;td&gt;Three-stage builds: deps → compile → runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app-react-spa&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;React + Nginx&lt;/td&gt;
&lt;td&gt;Static asset serving, SPA routing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app-go-service&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;Distroless images, static binaries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The full repo: &lt;code&gt;eks-monolith-migration&lt;/code&gt; — IaC, Dockerfiles, k8s manifests, CI/CD, all of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 1: The Dockerfiles
&lt;/h2&gt;

&lt;h3&gt;
  
  
  App 1 — Node.js: The &lt;code&gt;.dockerignore&lt;/code&gt; Wake-Up Call
&lt;/h3&gt;

&lt;p&gt;Node.js is where most people make the biggest beginner mistake: not using &lt;code&gt;.dockerignore&lt;/code&gt;, so Docker sends your entire &lt;code&gt;node_modules&lt;/code&gt; as build context on every build.&lt;/p&gt;

&lt;p&gt;My &lt;code&gt;.dockerignore&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;My Dockerfile: two-stage. Install deps in stage one, copy only what runs in stage two.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:24-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;deps&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package*.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci &lt;span class="nt"&gt;--only&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;production

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:24-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runtime&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=deps /app/node_modules ./node_modules&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; src/ ./src/&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NODE_ENV=production&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; node&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3001&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "src/index.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;USER node&lt;/code&gt; is easy to forget and almost never done in tutorials. Running as root inside a container means a container escape gives an attacker root on the host. One line fixes it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Image size: 235MB&lt;/strong&gt; vs 1.1GB naive.&lt;/p&gt;




&lt;h3&gt;
  
  
  App 2 — Python/Flask: The Alpine Trap
&lt;/h3&gt;

&lt;p&gt;My first instinct: &lt;code&gt;python:3.12-alpine&lt;/code&gt;. Alpine is tiny. Tiny equals good.&lt;/p&gt;

&lt;p&gt;The problem: Alpine uses &lt;code&gt;musl libc&lt;/code&gt;. Many Python packages with C extensions (numpy, psycopg2, cryptography) either have no Alpine-compatible wheels or compile from source — turning a 30-second build into a 5-minute build. Sometimes it just breaks.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;python:3.12-slim&lt;/code&gt; instead. Debian-based, glibc, pre-compiled wheels.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;python:3.12-slim&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements.txt .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-cache-dir&lt;/span&gt; &lt;span class="nt"&gt;--prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/install &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;python:3.12-slim&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runtime&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /install /usr/local&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; app.py .&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; PYTHONDONTWRITEBYTECODE=1 \&lt;/span&gt;
    PYTHONUNBUFFERED=1
&lt;span class="k"&gt;RUN &lt;/span&gt;useradd &lt;span class="nt"&gt;-m&lt;/span&gt; appuser &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; USER appuser
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3002&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["gunicorn", "--bind", "0.0.0.0:3002", "app:app"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth noting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PYTHONUNBUFFERED=1&lt;/code&gt; — without this, stdout is buffered. Your logs go missing in Kubernetes until the buffer flushes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gunicorn&lt;/code&gt; instead of Flask's dev server — the dev server is single-threaded and literally prints "do not use in production" on startup. Take it at its word.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Image size: 186MB.&lt;/strong&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  App 3 — NestJS: The Three-Stage Build
&lt;/h3&gt;

&lt;p&gt;NestJS is TypeScript. TypeScript compiles to JavaScript. That means your build process is: install deps → compile → run. Three distinct stages.&lt;/p&gt;

&lt;p&gt;The trap: carrying your TypeScript compiler, devDependencies, and &lt;code&gt;.ts&lt;/code&gt; source files into the production image.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:24-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;deps&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package*.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:24-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=deps /app/node_modules ./node_modules&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm run build

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:24-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runtime&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package*.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci &lt;span class="nt"&gt;--only&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;production
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /app/dist ./dist&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NODE_ENV=production&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; node&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3003&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "dist/main.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The runtime stage does its own &lt;code&gt;npm ci --only=production&lt;/code&gt;. It doesn't copy &lt;code&gt;node_modules&lt;/code&gt; from the build stage — those include devDependencies. Fresh install, production only, then just the compiled &lt;code&gt;dist/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Your TypeScript source never touches the production image. Your test runner isn't there. Your type definitions aren't there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Image size: 295MB.&lt;/strong&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  App 4 — React + Nginx: The SPA Routing Gotcha
&lt;/h3&gt;

&lt;p&gt;React apps are static files. After &lt;code&gt;npm run build&lt;/code&gt;, you have an &lt;code&gt;index.html&lt;/code&gt; and some JS bundles. You don't need Node at runtime — you need a web server.&lt;/p&gt;

&lt;p&gt;Everyone knows this in theory. Fewer people get the Nginx config right.&lt;/p&gt;

&lt;p&gt;The issue: React Router. If a user navigates directly to &lt;code&gt;/about&lt;/code&gt; or refreshes on &lt;code&gt;/dashboard&lt;/code&gt;, Nginx looks for a file at that path. There isn't one. 404.&lt;/p&gt;

&lt;p&gt;The fix is one directive: &lt;code&gt;try_files $uri $uri/ /index.html;&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;3004&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/usr/share/nginx/html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;index&lt;/span&gt; &lt;span class="s"&gt;index.html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="n"&gt;/index.html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:24-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package*.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm run build

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;nginx:alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runtime&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /app/dist /usr/share/nginx/html&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; nginx/nginx.conf /etc/nginx/conf.d/default.conf&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3004&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Image size: 74.4MB.&lt;/strong&gt; Smallest of the five — Nginx Alpine is tiny and we're just serving static files.&lt;/p&gt;




&lt;h3&gt;
  
  
  App 5 — Go: The Satisfying One
&lt;/h3&gt;

&lt;p&gt;Go compiles to a single static binary. No runtime, no interpreter, no VM. Just a binary.&lt;/p&gt;

&lt;p&gt;This means you can build in one container and copy the binary into a container that has almost nothing — &lt;code&gt;gcr.io/distroless/static&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;golang:1.22-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; go.mod go.sum ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;go mod download
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nv"&gt;CGO_ENABLED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nv"&gt;GOOS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;linux go build &lt;span class="nt"&gt;-o&lt;/span&gt; server .

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;gcr.io/distroless/static-debian12&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runtime&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /app/server .&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3005&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["/app/server"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CGO_ENABLED=0&lt;/code&gt; disables C bindings. &lt;code&gt;GOOS=linux&lt;/code&gt; targets Linux explicitly. Together they ensure the binary is truly static and will run in distroless.&lt;/p&gt;

&lt;p&gt;No shell in that container. No &lt;code&gt;ls&lt;/code&gt;, no &lt;code&gt;curl&lt;/code&gt;, no package manager. &lt;code&gt;kubectl exec&lt;/code&gt; into it and try to run &lt;code&gt;bash&lt;/code&gt; — nothing. This is the point. Near-zero attack surface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Image size: 8.88MB.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Before vs. After Table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;App&lt;/th&gt;
&lt;th&gt;Naive&lt;/th&gt;
&lt;th&gt;Optimized&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Node.js API&lt;/td&gt;
&lt;td&gt;~1.1GB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;235MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python API&lt;/td&gt;
&lt;td&gt;~920MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;186MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NestJS API&lt;/td&gt;
&lt;td&gt;~1.3GB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;295MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;React SPA&lt;/td&gt;
&lt;td&gt;~400MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;74.4MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Go Service&lt;/td&gt;
&lt;td&gt;~800MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.88MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Go number is not a typo. I was surprised myself.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 2: Infrastructure with Terraform
&lt;/h2&gt;

&lt;p&gt;Three modules: &lt;code&gt;vpc&lt;/code&gt;, &lt;code&gt;eks&lt;/code&gt;, &lt;code&gt;ecr&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The VPC creates public subnets for the load balancer, private subnets for worker nodes, and a NAT gateway so private nodes can pull images. Standard layout.&lt;/p&gt;

&lt;p&gt;The EKS module provisions a managed node group (&lt;code&gt;t3.medium&lt;/code&gt; — the minimum that comfortably runs EKS system pods alongside workloads) and enables OIDC, which is what makes IRSA work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IRSA — IAM Roles for Service Accounts&lt;/strong&gt; — is how you give pods AWS permissions without credentials anywhere. An IAM role attaches to a Kubernetes service account. Pods get temporary credentials via OIDC token exchange. No &lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt; in your manifests.&lt;/p&gt;

&lt;p&gt;The ECR module creates five repos with lifecycle policies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;rules&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
    &lt;span class="nx"&gt;rulePriority&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="nx"&gt;description&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Keep last 10 images"&lt;/span&gt;
    &lt;span class="nx"&gt;selection&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;tagStatus&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"any"&lt;/span&gt;
      &lt;span class="nx"&gt;countType&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"imageCountMoreThan"&lt;/span&gt;
      &lt;span class="nx"&gt;countNumber&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"expire"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without lifecycle policies, ECR accumulates images indefinitely. Ten images covers comfortable rollback. More than that is sentiment, not operations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What broke:&lt;/strong&gt; The AWS Load Balancer Controller IAM policy. The controller needs a specific IAM policy to provision ALBs. If the IRSA annotation on the controller's service account doesn't match the IAM role ARN exactly, the controller runs but your Ingress resources never get an &lt;code&gt;ADDRESS&lt;/code&gt;. I spent an hour on this.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 3: Kubernetes Manifests
&lt;/h2&gt;

&lt;p&gt;Two things I enforced on every deployment that most tutorials skip:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resource requests and limits:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;128Mi"&lt;/span&gt;
    &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;100m"&lt;/span&gt;
  &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;256Mi"&lt;/span&gt;
    &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;500m"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without requests, the scheduler can't place your pod intelligently. Without limits, a misbehaving pod starves everything else on the node.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Minimum two replicas via HPA:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;minReplicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;maxReplicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One replica is a single point of failure. A node rotation takes down your service. Two replicas means you survive a pod restart gracefully.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 4: GitOps with ArgoCD
&lt;/h2&gt;

&lt;p&gt;ArgoCD watches your Git repository for manifest changes and syncs them to the cluster. The cluster pulls its desired state from Git — Git is the source of truth. A rogue &lt;code&gt;kubectl apply&lt;/code&gt; directly on the cluster? ArgoCD reverts it on the next sync cycle.&lt;/p&gt;

&lt;p&gt;I used a single &lt;code&gt;ApplicationSet&lt;/code&gt; instead of five separate &lt;code&gt;Application&lt;/code&gt; resources:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argoproj.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ApplicationSet&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;monolith-apps&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;generators&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;list&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;elements&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-node-api&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-python-api&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-nestjs-api&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-react-spa&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-go-service&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{app}}'&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;k8s/apps/{{app}}'&lt;/span&gt;
      &lt;span class="na"&gt;syncPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;automated&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;prune&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;selfHeal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One manifest. Five apps. Adding a sixth is one line in the &lt;code&gt;elements&lt;/code&gt; list.&lt;/p&gt;

&lt;p&gt;Seeing all five apps show &lt;code&gt;Synced&lt;/code&gt; and &lt;code&gt;Healthy&lt;/code&gt; simultaneously in the ArgoCD dashboard was genuinely satisfying.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 5: GitHub Actions CI/CD
&lt;/h2&gt;

&lt;p&gt;Two workflows:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ci-build-push.yml&lt;/code&gt;&lt;/strong&gt; — triggers on push to &lt;code&gt;main&lt;/code&gt;. Matrix build across all five apps, tags each image with the git commit SHA, pushes to ECR. Authenticates with AWS via OIDC — no static credentials stored anywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;cd-update-manifests.yml&lt;/code&gt;&lt;/strong&gt; — runs after the build. Updates the image tag in &lt;code&gt;k8s/apps/&amp;lt;app&amp;gt;/deployment.yaml&lt;/code&gt;, commits back to the repo. ArgoCD detects the drift and syncs within 30 seconds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Authenticate to AWS&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-ecr&lt;/span&gt;
    &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The commit SHA as the image tag matters. &lt;code&gt;latest&lt;/code&gt; tells you nothing about what's actually running. A commit SHA is immutable and traceable — you can answer "what code is in production?" with a single &lt;code&gt;kubectl get deployment&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Separate the manifests repo.&lt;/strong&gt; When app code and k8s manifests live together, the CI commit that updates image tags triggers another CI run on the same repo. With branch filtering, you avoid a loop, but a dedicated &lt;code&gt;*-k8s&lt;/code&gt; repo is cleaner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secrets management from the start.&lt;/strong&gt; I used hardcoded values for the demo. Retrofitting the External Secrets Operator + AWS Secrets Manager later is painful. Wire it up day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distroless everywhere, not just Go.&lt;/strong&gt; Distroless images exist for Node.js and Python, too. I used Alpine variants for easier debugging — in production, I'd push toward distroless across the board.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd27r3i5kuk8fyjbtmgzl.png" alt="ArgoCD applications all healthy and synced" width="800" height="420"&gt;
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;Five stacks. Five Dockerfiles. One EKS cluster. One ApplicationSet. One pipeline.&lt;/p&gt;

&lt;p&gt;The image optimisation alone — from multi-gigabyte naive images to sub-200MB across the board — is something concrete you can demonstrate. The Go service at 12MB in a distroless container with near-zero attack surface is something worth building just to see it work.&lt;/p&gt;

&lt;p&gt;The deeper lesson is GitOps. Self-healing deployments, Git as the source of truth, no manual &lt;code&gt;kubectl apply&lt;/code&gt; in production — these are what make Kubernetes manageable at scale, not just powerful on a laptop.&lt;/p&gt;

&lt;p&gt;Full repo: &lt;a href="https://github.com/poppyszn/eks-monolith-migration" rel="noopener noreferrer"&gt;github.com/poppyszn/eks-monolith-migration&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Questions on any specific part — the OIDC setup, the ApplicationSet pattern, the ALB Controller IAM issue, the Nginx SPA config — drop them in the comments.&lt;/p&gt;

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