<?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: Ebelechukwu Lucy Okafor</title>
    <description>The latest articles on DEV Community by Ebelechukwu Lucy Okafor (@ebelechukwu_lucyokafor).</description>
    <link>https://dev.to/ebelechukwu_lucyokafor</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%2F3732404%2F50afa157-5497-4134-a93e-f39560973ff1.jpeg</url>
      <title>DEV Community: Ebelechukwu Lucy Okafor</title>
      <link>https://dev.to/ebelechukwu_lucyokafor</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ebelechukwu_lucyokafor"/>
    <language>en</language>
    <item>
      <title>Deploying Nginx with Argo CD on a Local Kubernetes Cluster: A Real-World GitOps Walkthrough kubernetes, Argo CD, gitops, devops</title>
      <dc:creator>Ebelechukwu Lucy Okafor</dc:creator>
      <pubDate>Wed, 09 Sep 2026 07:42:50 +0000</pubDate>
      <link>https://dev.to/ebelechukwu_lucyokafor/deploying-nginx-with-argo-cd-on-a-local-kubernetes-cluster-a-real-world-gitops-walkthrough-592p</link>
      <guid>https://dev.to/ebelechukwu_lucyokafor/deploying-nginx-with-argo-cd-on-a-local-kubernetes-cluster-a-real-world-gitops-walkthrough-592p</guid>
      <description>&lt;p&gt;Deploying Nginx with Argo CD on a Local Kubernetes Cluster — A Real-World GitOps Walkthrough&lt;/p&gt;

&lt;p&gt;Most Argo CD tutorials show you a clean happy path: install, point it at a repo, watch it sync, done. That's useful for understanding the concept — but it doesn't prepare you for what actually happens when you build this on your own machine.&lt;br&gt;
This post walks through a real deployment of Nginx to a local Kubernetes cluster (k3d) using Argo CD — including every infrastructure issue I hit along the way, why it happened, and how I fixed it. If you're learning GitOps, I think the debugging is actually the more valuable part.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why GitOps, and why Argo CD&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Traditional CI/CD is push-based:&lt;/strong&gt; your pipeline authenticates to the cluster and runs kubectl apply or helm upgrade as its last &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;step. GitOps flips this to pull-based:&lt;/strong&gt; a controller running inside the cluster (Argo CD, in this case) continuously watches a Git repository and reconciles the live cluster state to match what's declared there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This has real, practical consequences:&lt;/strong&gt;&lt;br&gt;
Git becomes the source of truth. Want to know what's running in production? Read the repo, not the cluster.&lt;br&gt;
Full audit trail. Every change is a commit — no more "who ran that kubectl command?"&lt;/p&gt;

&lt;p&gt;Easy rollback. git revert is your rollback strategy.&lt;br&gt;
No CI credentials in the cluster. CI only ever touches Git, never the cluster directly, tightening your security boundary considerably.&lt;br&gt;
Drift detection and correction. If someone manually edits a resource, Argo CD notices and can revert it — this is called self-heal, and I'll demonstrate it later in this post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Architecture overview&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Developer → commits code → CI builds &amp;amp; pushes image → CI updates image tag in Git&lt;br&gt;
                                                              │&lt;br&gt;
                                                              ▼&lt;br&gt;
                                                        Config repo (Git)&lt;br&gt;
                                                              │&lt;br&gt;
                                              Argo CD polls / receives webhook&lt;br&gt;
                                                              │&lt;br&gt;
                                                              ▼&lt;br&gt;
                                              Diffs desired state vs live state&lt;br&gt;
                                                              │&lt;br&gt;
                                                              ▼&lt;br&gt;
                                                 Syncs Kubernetes cluster&lt;/p&gt;

&lt;p&gt;Argo CD's core components: the API server (serves the UI/CLI), the repo server (clones and renders manifests), the application controller (the actual reconciliation loop), Redis (caching), and CRDs (Application, AppProject, ApplicationSet) that define what to track.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Setting up the local cluster&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I used k3d — k3s running inside Docker containers — since it spins up fast and doesn't need a Linux host natively.&lt;br&gt;
choco install docker-desktop kubernetes-cli k3d -y&lt;br&gt;
k3d cluster create devcluster --servers 1 --agents 1 -p "8081:80@loadbalancer"&lt;/p&gt;

&lt;p&gt;This should, in theory, "just work." In practice, I hit three separate infrastructure issues before the cluster came up healthy.&lt;br&gt;
&lt;strong&gt;Issue 1&lt;/strong&gt; — Docker couldn't resolve external DNS&lt;br&gt;
docker failed to pull the image 'ghcr.io/k3d-io/k3d-proxy:5.9.0':&lt;br&gt;
dial tcp: lookup ghcr.io: no such host&lt;/p&gt;

&lt;p&gt;Docker Desktop's internal DNS wasn't resolving external registries at all — a common issue caused by network config, VPNs, or a broken default resolver inside the WSL2 VM Docker runs on.&lt;br&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; set an explicit DNS server in Docker Desktop's daemon config (Settings → Docker Engine):&lt;br&gt;
{&lt;br&gt;
  "dns": ["8.8.8.8", "1.1.1.1"]&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Verified with:&lt;br&gt;
docker run --rm busybox nslookup ghcr.io&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Issue 2&lt;/strong&gt; — kubelet refused to start (cgroup v1 vs v2)&lt;br&gt;
Error: failed to validate kubelet configuration, error: kubelet is configured to not run on a&lt;br&gt;
host using cgroup v1. cgroup v1 support is unsupported and will be removed in a future release&lt;/p&gt;

&lt;p&gt;This one was buried under about a hundred repeating "connection refused" log lines — the real error was easy to miss. Modern k3s requires the unified cgroup v2 hierarchy, but my WSL2 environment was still exposing cgroup v1.&lt;br&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; forced cgroup v2 via a WSL2 boot parameter.&lt;br&gt;
wsl --update&lt;br&gt;
wsl --shutdown&lt;/p&gt;

&lt;p&gt;In %USERPROFILE%.wslconfig:&lt;br&gt;
[wsl2]&lt;br&gt;
kernelCommandLine=cgroup_no_v1=all&lt;/p&gt;

&lt;p&gt;After restarting WSL2 and Docker Desktop, I confirmed the fix by checking for the v2 marker file:&lt;br&gt;
docker run --rm busybox ls /sys/fs/cgroup&lt;/p&gt;

&lt;p&gt;The presence of cgroup.controllers in the output confirms cgroup v2 is active (cgroup v1 exposes separate directories like cpu/, memory/, blkio/ instead).&lt;/p&gt;

&lt;p&gt;*&lt;em&gt;Issue 3 *&lt;/em&gt;— kubectl couldn't connect after everything looked fine&lt;br&gt;
Unable to connect to the server: dial tcp 192.168.0.2:xxxxx: connectex:&lt;br&gt;
No connection could be made because the target machine actively refused it.&lt;/p&gt;

&lt;p&gt;The cluster and load balancer were both running and healthy — but kubectl was trying to reach host.docker.internal, which resolved to a LAN IP (192.168.0.2) instead of loopback. A quick Test-NetConnection -ComputerName localhost -Port  confirmed the port was open on localhost — the hostname resolution was just wrong.&lt;br&gt;
**Fix: **rewrote the kubeconfig to use localhost instead:&lt;br&gt;
$config = Get-Content "$env:USERPROFILE.kube\config" -Raw&lt;br&gt;
$config = $config -replace "host.docker.internal", "localhost"&lt;br&gt;
Set-Content "$env:USERPROFILE.kube\config" -Value $config&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After that:&lt;/strong&gt;&lt;br&gt;
kubectl get nodes&lt;/p&gt;

&lt;p&gt;NAME                      STATUS   ROLES           AGE   VERSION&lt;br&gt;
k3d-devcluster-agent-0    Ready              19m   v1.35.5+k3s1&lt;br&gt;
k3d-devcluster-server-0   Ready    control-plane   19m   v1.35.5+k3s1&lt;/p&gt;

&lt;p&gt;Cluster: finally healthy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Installing Argo CD&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;kubectl create namespace argocd&lt;br&gt;
kubectl apply -n argocd -f &lt;a href="https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml" rel="noopener noreferrer"&gt;https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Issue 4&lt;/strong&gt; — a CRD too large for client-side apply&lt;br&gt;
The CustomResourceDefinition "applicationsets.argoproj.io" is invalid: metadata.annotations:&lt;br&gt;
Too long: may not be more than 262144 bytes&lt;/p&gt;

&lt;p&gt;kubectl apply's default client-side apply stores the entire applied configuration as an annotation on the object, and the ApplicationSet CRD is large enough to exceed Kubernetes' 262144-byte annotation limit. This is a known Argo CD installation quirk, not specific to my setup.&lt;/p&gt;

&lt;p&gt;**Fix: **apply that CRD with server-side apply, which has the API server track field ownership instead of stuffing everything into an annotation:&lt;br&gt;
kubectl apply --server-side --force-conflicts --validate=false --request-timeout=120s `&lt;br&gt;
  -f &lt;a href="https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/crds/applicationset-crd.yaml" rel="noopener noreferrer"&gt;https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/crds/applicationset-crd.yaml&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then restarted the dependent controller so it would pick up the newly available CRD:&lt;br&gt;
kubectl rollout restart deployment/argocd-applicationset-controller -n argocd&lt;/p&gt;

&lt;p&gt;All 7 Argo CD pods reached 1/1 Running after that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accessing the UI&lt;/strong&gt;&lt;br&gt;
kubectl port-forward svc/argocd-server -n argocd 8080:443&lt;/p&gt;

&lt;p&gt;The initial admin password is auto-generated and stored as a base64-encoded Kubernetes Secret. Worth noting that kubectl get secret ... -o jsonpath only extracts the raw (still-encoded) value; it doesn't decode it:&lt;br&gt;
$encoded = kubectl get secret argocd-initial-admin-secret -n argocd -o jsonpath="{.data.password}"&lt;/p&gt;

&lt;p&gt;Logged into &lt;a href="https://localhost:8080" rel="noopener noreferrer"&gt;https://localhost:8080&lt;/a&gt; with admin and the decoded password.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Defining Nginx declaratively&lt;/strong&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  nginx/deployment.yaml
&lt;/h1&gt;

&lt;p&gt;apiVersion: apps/v1&lt;br&gt;
kind: Deployment&lt;br&gt;
metadata:&lt;br&gt;
  name: nginx-deployment&lt;br&gt;
  labels:&lt;br&gt;
    app: nginx&lt;br&gt;
spec:&lt;br&gt;
  replicas: 2&lt;br&gt;
  selector:&lt;br&gt;
    matchLabels:&lt;br&gt;
      app: nginx&lt;br&gt;
  template:&lt;br&gt;
    metadata:&lt;br&gt;
      labels:&lt;br&gt;
        app: nginx&lt;br&gt;
    spec:&lt;br&gt;
      containers:&lt;br&gt;
        - name: nginx&lt;br&gt;
          image: nginx:1.27&lt;br&gt;
          ports:&lt;br&gt;
            - containerPort: 80&lt;/p&gt;

&lt;h1&gt;
  
  
  nginx/service.yaml
&lt;/h1&gt;

&lt;p&gt;apiVersion: v1&lt;br&gt;
kind: Service&lt;br&gt;
metadata:&lt;br&gt;
  name: nginx-service&lt;br&gt;
spec:&lt;br&gt;
  type: ClusterIP&lt;br&gt;
  selector:&lt;br&gt;
    app: nginx&lt;br&gt;
  ports:&lt;br&gt;
    - port: 80&lt;br&gt;
      targetPort: 80&lt;/p&gt;

&lt;p&gt;Pushed both to a GitHub repo — this repo is now the only thing Argo CD needs to watch.&lt;br&gt;
git init&lt;br&gt;
git add .&lt;br&gt;
git commit -m "Initial nginx manifests"&lt;br&gt;
git branch -M main&lt;br&gt;
git remote add origin &lt;a href="https://github.com/" rel="noopener noreferrer"&gt;https://github.com/&lt;/a&gt;/.git&lt;br&gt;
git push -u origin main&lt;/p&gt;

&lt;p&gt;Creating the Argo CD Application&lt;br&gt;
Via the UI: + NEW APP, filled in the repo URL, path (nginx), destination (&lt;a href="https://kubernetes.default.svc" rel="noopener noreferrer"&gt;https://kubernetes.default.svc&lt;/a&gt;, namespace default), and set Sync Policy to Automatic with Self Heal enabled.&lt;br&gt;
Result:&lt;br&gt;
Status: Healthy ✅  Synced ✅&lt;/p&gt;

&lt;p&gt;Confirmed at the raw Kubernetes level too:&lt;br&gt;
kubectl get pods -n default&lt;br&gt;
kubectl get svc -n default&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An unrelated but real infrastructure lesson: resource contention&lt;/strong&gt;&lt;br&gt;
Partway through, kubectl started throwing TLS handshake timeout on every command — including basic ones like kubectl get nodes. This turned out to have nothing to do with Argo CD or k3d directly:&lt;br&gt;
docker stats --no-stream&lt;/p&gt;

&lt;p&gt;CONTAINER                    CPU %&lt;br&gt;
k3d-devcluster-server-0      260.04%&lt;br&gt;
dev-cluster-control-plane    341.70%&lt;/p&gt;

&lt;p&gt;An unrelated, unused kind cluster (dev-cluster-control-plane) had been left running from an earlier session, consuming more CPU than my actual working cluster — combined, they exceeded Docker Desktop's allocated CPU ceiling and starved the k3s API server of the resources it needed to respond to requests.&lt;br&gt;
docker rm -f dev-cluster-control-plane&lt;/p&gt;

&lt;p&gt;CPU pressure dropped immediately, and the cluster became responsive again. A good reminder to periodically audit what's actually running in Docker Desktop, especially on a resource-constrained laptop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proving the GitOps loop actually works&lt;/strong&gt;&lt;br&gt;
This is the part that matters most — everything above was just getting the environment ready to demonstrate this.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Automated sync from Git&lt;/strong&gt;
I changed replicas: 2 to replicas: 4 directly in the GitHub file editor and committed to main. Within Argo CD's poll interval, the Application flipped to OutOfSync, then automatically back to Synced as it applied the change — no kubectl command from me at any point.
kubectl get pods -n default&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;NAME                               READY   STATUS    RESTARTS   AGE&lt;br&gt;
nginx-deployment-fd956d49d-8xcs8   1/1     Running   0          40m&lt;br&gt;
nginx-deployment-fd956d49d-8xgcz   1/1     Running   0          46s&lt;br&gt;
nginx-deployment-fd956d49d-dg9lz   1/1     Running   0          40m&lt;br&gt;
nginx-deployment-fd956d49d-qzsf6   1/1     Running   0          46s&lt;/p&gt;

&lt;p&gt;Four pods, matching Git — automatically.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Self-heal from manual drift&lt;/strong&gt;
To prove the reverse direction, I manually scaled the deployment down, bypassing Git entirely:
kubectl scale deployment nginx-deployment -n default --replicas=1
kubectl get pods -n default -w&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Kubernetes terminated three pods, leaving one running. Within seconds, Argo CD's reconciliation loop detected that live state (1 replica) no longer matched Git (replicas: 4), and created three new pods to restore it — automatically:&lt;br&gt;
nginx-deployment-fd956d49d-zn4lh   1/1     Running   0          14s&lt;br&gt;
nginx-deployment-fd956d49d-qgn44   1/1     Running   0          10s&lt;br&gt;
nginx-deployment-fd956d49d-mvfnd   1/1     Running   0          11s&lt;/p&gt;

&lt;p&gt;That's the core GitOps guarantee, demonstrated end-to-end: whatever happens to the live cluster- a bad manual command, a crashed pod, config drift of any kind- the system converges back to what's declared in Git.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why Argo CD is efficient (not just trendy)&lt;/strong&gt;&lt;br&gt;
Having now built this from scratch and broken it a few times along the way, here's what actually stands out about Argo CD versus a traditional push-based pipeline:&lt;br&gt;
&lt;strong&gt;Security boundary.&lt;/strong&gt; CI never needs cluster credentials; it only ever needs write access to Git. That's a meaningfully smaller attack surface.&lt;br&gt;
&lt;strong&gt;Auditability.&lt;/strong&gt; Every deployment is a Git commit with a message and an author. No more digging through CI logs to figure out what changed and when.&lt;br&gt;
&lt;strong&gt;Consistency at scale.&lt;/strong&gt; With the "app of apps" pattern, adding a new microservice to your GitOps setup is just a new file in a repo — not a new pipeline to write and maintain.&lt;br&gt;
&lt;strong&gt;Self-correction.&lt;/strong&gt; Configuration drift — from a manual hotfix someone forgot to codify, or a partial failure during a rollout — gets corrected automatically instead of silently persisting.&lt;br&gt;
&lt;strong&gt;Two clear states per app.&lt;/strong&gt; Sync status (does live match Git?) and Health status (are the resources actually working?) give you a fast, unambiguous signal for every deployed service.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffcgttchqrv1t9d6p3itt.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffcgttchqrv1t9d6p3itt.jpeg" alt=" " width="800" height="421"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh4a9snkyqfyq7tqhqb5u.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh4a9snkyqfyq7tqhqb5u.jpeg" alt=" " width="798" height="204"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F23d3clb5np4t2c5qh9fb.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F23d3clb5np4t2c5qh9fb.jpeg" alt=" " width="800" height="261"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flmnzvigg3fkspqflukqj.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flmnzvigg3fkspqflukqj.jpeg" alt=" " width="799" height="224"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5h07ywm0jtl2iegq0a7c.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5h07ywm0jtl2iegq0a7c.jpeg" alt=" " width="800" height="342"&gt;&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Closing thoughts&lt;/strong&gt;&lt;br&gt;
If you're learning Kubernetes and GitOps, I'd genuinely recommend doing this the "hard way" once — build it locally, and don't route around the errors when they show up. Every issue I hit here (DNS, cgroup versions, hostname resolution, CRD size limits, resource contention) is a real thing that happens in actual infrastructure work, just usually with higher stakes. Debugging it on a local cluster, where nothing is at risk, is the cheapest place to build that instinct.&lt;/p&gt;

&lt;p&gt;If you found this useful, I write about Kubernetes, DevOps tooling, and cloud infrastructure — feel free to follow for more hands-on breakdowns like this one.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I Already Know This Stuff. That is Exactly Why I Am Documenting It.</title>
      <dc:creator>Ebelechukwu Lucy Okafor</dc:creator>
      <pubDate>Tue, 04 Aug 2026 05:45:50 +0000</pubDate>
      <link>https://dev.to/ebelechukwu_lucyokafor/i-already-know-this-stuff-that-is-exactly-why-i-am-documenting-it-53eo</link>
      <guid>https://dev.to/ebelechukwu_lucyokafor/i-already-know-this-stuff-that-is-exactly-why-i-am-documenting-it-53eo</guid>
      <description>&lt;p&gt;&lt;strong&gt;There is a version of Week 0 that would have felt completely foreign to me.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;IP addresses, DNS, HTTP, TCP/IP, application architecture. For someone starting from zero, these concepts are a lot to absorb in one week. I remember what that felt like.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But I am not starting from zero.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I graduated from DMI Cohort 2 in May 2026. I was the App/Docker Lead for an 11-person team that deployed Spring PetClinic Microservices — 8 services, full CI/CD on AWS EKS, Prometheus scraping metrics every 15 seconds, Zipkin tracing requests across 4 services, alerts firing within 30 seconds of a service failure.&lt;/p&gt;

&lt;p&gt;I have deployed on AWS. I have deployed on Azure. I have written Terraform modules, Ansible playbooks, GitHub Actions pipelines, and Kubernetes manifests. I know what a private subnet is because I put a MySQL database in one. I know what an A record is because I used one to point a domain to a VM's public IP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;So why am I doing Week 0 of Cohort 3 self-paced?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because knowing something and documenting it clearly are two different skills. And the second one matters more for your career.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Week 0 Actually Taught Me&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Prompt Engineering is a Real Skill&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Task 1&lt;/strong&gt; was writing a structured ChatGPT prompt to explain networking protocols. The key ingredients: context, specific question, format requirements, length constraints, and relevant background.&lt;/p&gt;

&lt;p&gt;I have been doing this with AI tools — Claude Code and KIMCHI — since Cohort 2. But having a formal framework for it made me realise I was doing it intuitively without being able to explain why it worked. Now I can.&lt;/p&gt;

&lt;p&gt;The discipline of a good AI prompt is identical to the discipline of a good Terraform variable definition. Be explicit. Constrain the output. Define expectations upfront. Garbage in, garbage out, whether you are talking to an LLM or provisioning cloud infrastructure.&lt;/p&gt;

&lt;p&gt;Explaining Things Simply is Harder Than Doing Them&lt;/p&gt;

&lt;p&gt;**Task 2 **asked me to explain packet switching, IP addresses, TCP/IP, and HTTPS in 100 to 150 words using the EpicReads bookstore scenario — a friend in Finland, customers in Nigeria.&lt;/p&gt;

&lt;p&gt;I know all four concepts from production deployments. But fitting them into 150 words that a non-technical person could understand? That took four drafts. Every word had to earn its place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Here is the version I ended up with:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When your friend's EpicReads website goes live in Finland, every device on the internet has a unique IP address — like a postal address. When a Nigerian user types the URL, DNS translates it to that Finnish IP address. The request does not travel in one piece. Packet switching breaks it into small chunks called packets. Each packet finds its own fastest route across the internet and they reassemble at the destination. TCP/IP manages this journey — TCP ensures every packet arrives, IP handles the routing between countries. HTTPS encrypts the conversation so no one between Nigeria and Finland can read the customer's payment details. That is how a click in Lagos reaches a server in Helsinki in under 200 milliseconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Architecture Diagrams Force Clarity&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two-tier versus three-tier. I have built both in production:&lt;/p&gt;

&lt;p&gt;EpicBook on Azure: React frontend + Node.js backend + MySQL RDS in private subnet&lt;br&gt;
Spring PetClinic: 8-service microservices with API Gateway, Config Server, Eureka, and 5 business services&lt;/p&gt;

&lt;p&gt;The two-tier diagram is simple — frontend connects directly to database. The three-tier adds a backend layer that handles business logic between the two. The diagram does not capture the complexity of what I built. But it forces you to articulate the layers clearly — which is the foundation for understanding why microservices exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DNS in Production vs DNS in Theory&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Task 4:&lt;/strong&gt; connect epicreads.com to 52.172.142.222:3000 using the correct DNS record.&lt;/p&gt;

&lt;p&gt;The answer is an A record. CNAME maps domain to domain — you cannot use it to point directly to an IP address. The port does not go in DNS at all. Nginx handles the port — accepting traffic on 80 or 443 and forwarding it internally to 3000. I did exactly this for EpicBook on Azure.&lt;/p&gt;

&lt;p&gt;The theory confirms what I already knew from practice. That is the best kind of learning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why Public Documentation Matters&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every assignment gets pushed to GitHub. Every week adds a commit to my public portfolio. By the end of the self-paced programme alongside Cohort 3, I will have a documented, versioned record of everything I know, not just what I can do.&lt;/p&gt;

&lt;p&gt;That is the difference between a developer who has done the work and one who can prove it, explain it, and teach it to someone else.&lt;/p&gt;

&lt;p&gt;The work I did in Cohort 2 already proved I can build. This is me proving I can articulate.&lt;/p&gt;

&lt;p&gt;If you are just starting DevOps, Cohort 3 is live now. What feels like fundamentals in Week 0 is what makes everything in Weeks 6 through 14 make sense.&lt;/p&gt;

&lt;p&gt;**P.S. **This post is part of the DevOps Micro Internship (DMI) with Agentic AI — Cohort 3 — by Pravin Mishra. My graded progress is public: &lt;a href="https://dmi.pravinmishra.com/s/Lucycloud2024.html" rel="noopener noreferrer"&gt;https://dmi.pravinmishra.com/s/Lucycloud2024.html&lt;/a&gt; · Start your DevOps journey: &lt;a href="https://dmi.pravinmishra.com/?utm_source=student&amp;amp;utm_medium=ps-linkedin&amp;amp;utm_campaign=cohort3" rel="noopener noreferrer"&gt;https://dmi.pravinmishra.com/?utm_source=student&amp;amp;utm_medium=ps-linkedin&amp;amp;utm_campaign=cohort3&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Building and Deploying Spring Petclinic Microservices on AWS: My Experience as Co-Project Lead and App &amp; Docker Lead</title>
      <dc:creator>Ebelechukwu Lucy Okafor</dc:creator>
      <pubDate>Wed, 17 Jun 2026 05:35:48 +0000</pubDate>
      <link>https://dev.to/ebelechukwu_lucyokafor/building-and-deploying-spring-petclinic-microservices-on-aws-my-experience-as-co-project-lead-and-402f</link>
      <guid>https://dev.to/ebelechukwu_lucyokafor/building-and-deploying-spring-petclinic-microservices-on-aws-my-experience-as-co-project-lead-and-402f</guid>
      <description>&lt;p&gt;&lt;strong&gt;Introduction&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;As part of the DMI (DevOps Micro Internship) Cohort 2, our team successfully deployed the Spring Petclinic Microservices application on AWS using modern DevOps practices and cloud technologies.&lt;br&gt;
Spring Petclinic is a microservices-based application that consists of multiple independent services working together through service discovery, centralised configuration, API gateways, monitoring, and observability tools.&lt;br&gt;
This project provided valuable hands-on experience in containerization, cloud deployment, collaboration, troubleshooting, and DevOps workflows.&lt;br&gt;
In this article, I will share my experience as the Co-Project Lead and App &amp;amp; Docker Lead, the tasks I completed, challenges I encountered, and the lessons I learned throughout the project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project Overview&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Our objective was to deploy the Spring Petclinic Microservices application in a cloud environment while implementing DevOps best practices.&lt;/p&gt;

&lt;p&gt;The application architecture included:&lt;br&gt;
Config Server&lt;br&gt;
Discovery Server (Eureka)&lt;br&gt;
API Gateway&lt;br&gt;
Customers Service&lt;br&gt;
Visits Service&lt;br&gt;
Vets Service&lt;br&gt;
GenAI Service&lt;br&gt;
Admin Server&lt;/p&gt;

&lt;p&gt;We also integrated observability tools, including:&lt;br&gt;
Prometheus&lt;br&gt;
Grafana&lt;br&gt;
Zipkin&lt;/p&gt;

&lt;p&gt;These tools helped us monitor system health, application performance, and distributed tracing across services.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My Role in the Team&lt;/strong&gt;&lt;br&gt;
I served as:&lt;br&gt;
Co-Project Lead&lt;br&gt;
and&lt;br&gt;
App &amp;amp; Docker Lead&lt;/p&gt;

&lt;p&gt;My responsibilities included coordinating deployment activities and managing application containerization workflows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tasks I Personally Completed&lt;br&gt;
**&lt;br&gt;
**CONT-1: Validate Docker Compose Stack Locally&lt;/strong&gt;&lt;br&gt;
I validated the complete Docker Compose environment locally to ensure all microservices started correctly and remained healthy before deployment.&lt;/p&gt;

&lt;p&gt;This involved:&lt;br&gt;
Testing service startup order&lt;br&gt;
Checking container health status&lt;br&gt;
Validating network communication between services&lt;br&gt;
Verifying application accessibility&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CONT-2: Build Docker Images for Linux/AMD64&lt;/strong&gt;&lt;br&gt;
I built container images for all services targeting the Linux/AMD64 architecture.&lt;br&gt;
This ensured compatibility with the AWS deployment environment.&lt;br&gt;
Example command:&lt;br&gt;
docker buildx build --platform linux/amd64 .&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CONT-3: Tag Images with Git SHA&lt;/strong&gt;&lt;br&gt;
To improve version tracking and deployment consistency, I tagged container images using Git commit hashes.&lt;br&gt;
Example:&lt;br&gt;
docker tag petclinic-api-gateway:latest \&lt;br&gt;
petclinic-api-gateway:&lt;/p&gt;

&lt;p&gt;This approach provided traceability and easier rollback capability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CONT-4: Authenticate Docker to Amazon ECR&lt;/strong&gt;&lt;br&gt;
I configured Docker authentication with Amazon Elastic Container Registry (ECR).&lt;br&gt;
Example:&lt;br&gt;
aws ecr get-login-password \&lt;br&gt;
| docker login \&lt;br&gt;
--username AWS \&lt;br&gt;
--password-stdin &lt;/p&gt;

&lt;p&gt;This enabled secure image publishing to AWS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CONT-5: Push Images to Amazon ECR&lt;/strong&gt;&lt;br&gt;
After successful image builds and tagging, I pushed all application images to their respective ECR repositories.&lt;br&gt;
This made the images available for deployment within AWS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key Technical Challenge&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One of the biggest challenges was ensuring that all services were built successfully and could be deployed consistently.&lt;br&gt;
Containerization can become complex when dealing with multiple services that depend on one another.&lt;br&gt;
We had to carefully validate Docker images, verify service dependencies, and ensure proper communication between components.&lt;br&gt;
Through collaboration, testing, and troubleshooting, we successfully resolved deployment issues and achieved a stable environment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Technologies Used&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;During the project, we worked with:&lt;br&gt;
Docker&lt;br&gt;
Docker Compose&lt;br&gt;
Amazon ECR&lt;br&gt;
AWS Cloud Services&lt;br&gt;
Spring Boot&lt;br&gt;
GitHub&lt;br&gt;
Prometheus&lt;br&gt;
Grafana&lt;br&gt;
Zipkin&lt;/p&gt;

&lt;p&gt;These tools helped us implement a complete DevOps workflow from development through deployment and monitoring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I Learned&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This project reinforced several important DevOps concepts:&lt;br&gt;
Containerization&lt;br&gt;
I gained deeper experience building, tagging, and managing Docker images for microservices.&lt;br&gt;
Team Collaboration&lt;br&gt;
Working with teammates taught me the importance of communication, task ownership, and coordination.&lt;br&gt;
Cloud Deployment&lt;br&gt;
I improved my understanding of deploying containerised applications within AWS environments.&lt;br&gt;
Observability&lt;br&gt;
Using Prometheus, Grafana, and Zipkin showed me how monitoring and tracing help identify issues and maintain application reliability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I Would Do Differently&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If repeating this project, I would:&lt;br&gt;
Automate more deployment tasks using CI/CD pipelines&lt;br&gt;
Implement Infrastructure as Code more extensively&lt;br&gt;
Enhance monitoring dashboards&lt;br&gt;
Improve deployment documentation for faster onboarding&lt;br&gt;
These improvements would increase scalability and operational efficiency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Final Thoughts&lt;/strong&gt;&lt;br&gt;
Participating in DMI Cohort 2 was a valuable experience that strengthened both my technical and collaboration skills.&lt;br&gt;
As Co-Project Lead and App &amp;amp; Docker Lead, I gained practical experience in Docker, AWS, container registries, cloud deployment, and observability tools.&lt;br&gt;
Most importantly, I learned how effective teamwork contributes to the success of complex DevOps projects.&lt;br&gt;
If you are interested in gaining real-world DevOps experience, DMI Cohort 3 starts on 27 June 2026.&lt;br&gt;
Apply here:&lt;br&gt;
&lt;a href="https://docs.google.com/forms/d/e/1FAIpQLSel7ai7nyb0P1qLW4vEyfB_nEsD4lUF1XG88vmAaFGBOb6hPA/viewform" rel="noopener noreferrer"&gt;https://docs.google.com/forms/d/e/1FAIpQLSel7ai7nyb0P1qLW4vEyfB_nEsD4lUF1XG88vmAaFGBOb6hPA/viewform&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  DMI #DevOps #AWS #Docker #Microservices #CloudComputing #Prometheus #Grafana #Zipkin #TheCloudAdvisory
&lt;/h1&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>docker</category>
      <category>microservices</category>
    </item>
    <item>
      <title>Deploying Spring Petclinic Microservices with Docker Compose: A Complete DevOps Walkthrough</title>
      <dc:creator>Ebelechukwu Lucy Okafor</dc:creator>
      <pubDate>Tue, 16 Jun 2026 05:15:49 +0000</pubDate>
      <link>https://dev.to/ebelechukwu_lucyokafor/deploying-spring-petclinic-microservices-with-docker-compose-a-complete-devops-walkthrough-4jo</link>
      <guid>https://dev.to/ebelechukwu_lucyokafor/deploying-spring-petclinic-microservices-with-docker-compose-a-complete-devops-walkthrough-4jo</guid>
      <description>&lt;p&gt;&lt;strong&gt;Introduction&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;As part of my DevOps learning journey in DMI Cohort 2, I deployed the Spring Petclinic Microservices application locally using Docker Compose.&lt;/p&gt;

&lt;p&gt;Spring Petclinic is a cloud-native microservices application designed to demonstrate modern software architecture patterns. Instead of a single monolithic application, the system consists of multiple independent services that communicate with one another.&lt;/p&gt;

&lt;p&gt;The deployment includes:&lt;/p&gt;

&lt;p&gt;Config Server&lt;br&gt;
Discovery Server (Eureka)&lt;br&gt;
API Gateway&lt;br&gt;
Customers Service&lt;br&gt;
Visits Service&lt;br&gt;
Vets Service&lt;br&gt;
GenAI Service&lt;br&gt;
Admin Server&lt;/p&gt;

&lt;p&gt;In addition, the application includes a complete observability stack:&lt;/p&gt;

&lt;p&gt;Prometheus&lt;br&gt;
Grafana&lt;br&gt;
Zipkin&lt;/p&gt;

&lt;p&gt;The goal of this project was to deploy, verify, monitor, and troubleshoot a production-style microservices environment using Docker Compose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prerequisites&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before deployment, I installed and configured the following tools:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Docker was used to build and run all application containers.&lt;br&gt;
Verify installation:&lt;br&gt;
docker --version&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Git&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Git was used to clone and manage the repository.&lt;br&gt;
Verify installation:&lt;br&gt;
git --version&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Codespaces&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I used GitHub Codespaces as my development environment because my AWS account was unavailable during this project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Clone the Repository&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I cloned the Spring Petclinic Microservices repository:&lt;br&gt;
git clone &lt;a href="https://github.com/PETCLINIC-PROJECT-GROUP-5/spring-petclinic-microservices.git" rel="noopener noreferrer"&gt;https://github.com/PETCLINIC-PROJECT-GROUP-5/spring-petclinic-microservices.git&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Navigate into the project:&lt;br&gt;
cd spring-petclinic-microservices&lt;/p&gt;

&lt;p&gt;Verify the repository contents:&lt;br&gt;
ls&lt;/p&gt;

&lt;p&gt;The repository contained all required microservices, Docker Compose configuration, Kubernetes manifests, and supporting documentation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Start the Application&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The entire application can be deployed using a single command:&lt;br&gt;
docker compose up -d&lt;/p&gt;

&lt;p&gt;This command performs several tasks:&lt;/p&gt;

&lt;p&gt;Pulls required Docker images&lt;br&gt;
Creates containers&lt;br&gt;
Creates a Docker network&lt;br&gt;
Starts services in the background&lt;/p&gt;

&lt;p&gt;After execution, Docker reported that all containers started successfully.&lt;br&gt;
Particularly important were:&lt;br&gt;
config-server&lt;br&gt;
discovery-server&lt;br&gt;
Both services became healthy before the remaining services started.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why Config Server and Discovery Server Start First&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Config Server provides centralised configuration for all microservices.&lt;/p&gt;

&lt;p&gt;The Discovery Server (Eureka) allows services to register themselves and discover other services dynamically.&lt;/p&gt;

&lt;p&gt;Without these services running first:&lt;br&gt;
Application services cannot load the configuration&lt;br&gt;
Services cannot register with Eureka&lt;br&gt;
Inter-service communication may fail&lt;br&gt;
Docker Compose uses startup dependencies and health checks to ensure the correct startup sequence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Verify Running Containers&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To verify deployment status, I ran:&lt;br&gt;
docker compose ps&lt;/p&gt;

&lt;p&gt;This displayed all running containers and their health status.&lt;br&gt;
Services included:&lt;br&gt;
config-server&lt;br&gt;
discovery-server&lt;br&gt;
api-gateway&lt;br&gt;
customers-service&lt;br&gt;
visits-service&lt;br&gt;
vets-service&lt;br&gt;
genai-service&lt;br&gt;
admin-server&lt;br&gt;
prometheus-server&lt;br&gt;
grafana-server&lt;br&gt;
tracing-server&lt;br&gt;
All services reported a healthy or running status.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Application Verification&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;After deployment, I verified each application endpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spring Petclinic&lt;/strong&gt;&lt;br&gt;
&lt;a href="http://localhost:8080" rel="noopener noreferrer"&gt;http://localhost:8080&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The main application loaded successfully.&lt;br&gt;
&lt;strong&gt;Eureka Dashboard&lt;/strong&gt;&lt;br&gt;
&lt;a href="http://localhost:8761" rel="noopener noreferrer"&gt;http://localhost:8761&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Displayed all registered services.&lt;br&gt;
&lt;strong&gt;Spring Boot Admin&lt;/strong&gt;&lt;br&gt;
&lt;a href="http://localhost:9090" rel="noopener noreferrer"&gt;http://localhost:9090&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Provided centralized monitoring of Spring Boot applications.&lt;br&gt;
&lt;strong&gt;Zipkin&lt;/strong&gt;&lt;br&gt;
&lt;a href="http://localhost:9411" rel="noopener noreferrer"&gt;http://localhost:9411&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Displayed distributed traces across services.&lt;br&gt;
&lt;strong&gt;Prometheus&lt;/strong&gt;&lt;br&gt;
&lt;a href="http://localhost:9091" rel="noopener noreferrer"&gt;http://localhost:9091&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Collected and displayed application metrics.&lt;br&gt;
&lt;strong&gt;Grafana&lt;/strong&gt;&lt;br&gt;
&lt;a href="http://localhost:3030" rel="noopener noreferrer"&gt;http://localhost:3030&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Visualised metrics through dashboards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Observability Stack&lt;/strong&gt;&lt;br&gt;
One of the most valuable parts of this project was understanding observability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prometheus&lt;/strong&gt;&lt;br&gt;
Prometheus collected metrics from application services.&lt;br&gt;
I executed queries such as:&lt;br&gt;
http_server_requests_seconds_count&lt;/p&gt;

&lt;p&gt;This provided visibility into application requests and performance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Grafana&lt;/strong&gt;&lt;br&gt;
Grafana converted Prometheus metrics into visual dashboards.&lt;br&gt;
I was able to monitor:&lt;br&gt;
Service health&lt;br&gt;
Request counts&lt;br&gt;
Response times&lt;br&gt;
Application activity&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zipkin&lt;/strong&gt;&lt;br&gt;
Zipkin provided distributed tracing.&lt;br&gt;
This allowed me to follow a request as it travelled through multiple microservices.&lt;br&gt;
It demonstrated how modern cloud-native applications handle service-to-service communication.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Biggest Challenge&lt;/strong&gt;&lt;br&gt;
The most challenging part of this project was troubleshooting Docker in GitHub Codespaces.&lt;br&gt;
Initially, the Codespace entered recovery mode because of container configuration issues.&lt;br&gt;
Although Docker commands were installed, Docker could not connect to the daemon.&lt;/p&gt;

&lt;p&gt;As a result:&lt;br&gt;
Docker image builds failed&lt;br&gt;
Docker Compose could not start containers&lt;br&gt;
After reviewing the devcontainer configuration, rebuilding the Codespace environment, and validating Docker daemon connectivity, I successfully resolved the issue.&lt;br&gt;
This experience improved my troubleshooting skills and deepened my understanding of Docker architecture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stopping and Cleaning Up&lt;/strong&gt;&lt;br&gt;
After completing verification and testing, I stopped the environment using:&lt;br&gt;
docker compose down&lt;/p&gt;

&lt;p&gt;This command:&lt;br&gt;
Stops containers&lt;br&gt;
Removes containers&lt;br&gt;
Removes Docker networks&lt;br&gt;
Free system resources&lt;/p&gt;

&lt;p&gt;Cleaning up environments is an important DevOps practice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key Lessons Learned&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This project taught me several valuable DevOps concepts:&lt;br&gt;
Microservices architecture&lt;br&gt;
Containerization with Docker&lt;br&gt;
Service discovery using Eureka&lt;br&gt;
Centralized configuration management&lt;br&gt;
Observability using Prometheus, Grafana, and Zipkin&lt;br&gt;
Troubleshooting Docker environments&lt;br&gt;
Deploying and managing multi-container applications&lt;/p&gt;

&lt;p&gt;Most importantly, I learned that successful DevOps work involves not only deployment but also monitoring, troubleshooting, and maintaining system reliability.&lt;/p&gt;

&lt;p&gt;DMI Cohort 2 Experience&lt;br&gt;
This project was completed as part of DMI Cohort 2.&lt;br&gt;
The hands-on nature of the program provided practical experience with real-world DevOps tools and deployment workflows.&lt;/p&gt;

&lt;p&gt;If you're interested in learning DevOps through practical projects, DMI Cohort 3 registration is open:&lt;br&gt;
&lt;a href="https://docs.google.com/forms/d/e/1FAIpQLSel7ai7nyb0P1qLW4vEyfB_nEsD4lUF1XG88vmAaFGBOb6hPA/viewform" rel="noopener noreferrer"&gt;https://docs.google.com/forms/d/e/1FAIpQLSel7ai7nyb0P1qLW4vEyfB_nEsD4lUF1XG88vmAaFGBOb6hPA/viewform&lt;/a&gt;&lt;br&gt;
Thank you for reading, and happy learning!&lt;/p&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>microservices</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Kubernetes Services &amp; Networking — My Week 15 Hands-On Journey (ClusterIP, NodePort, LoadBalancer)</title>
      <dc:creator>Ebelechukwu Lucy Okafor</dc:creator>
      <pubDate>Tue, 28 Apr 2026 06:50:08 +0000</pubDate>
      <link>https://dev.to/ebelechukwu_lucyokafor/kubernetes-services-networking-my-week-15-hands-on-journey-clusterip-nodeport-loadbalancer-40o7</link>
      <guid>https://dev.to/ebelechukwu_lucyokafor/kubernetes-services-networking-my-week-15-hands-on-journey-clusterip-nodeport-loadbalancer-40o7</guid>
      <description>&lt;p&gt;&lt;strong&gt;If you’ve ever deployed an app on Kubernetes and thought&lt;/strong&gt;:&lt;br&gt;
“Okay… my Pods are running, but how do I actually reach them?”&lt;br&gt;
Then you’re exactly where I was this week.&lt;br&gt;
In this hands-on lab series, I explored one of the most important&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kubernetes concepts:&lt;/strong&gt;&lt;br&gt;
 Services &amp;amp; Networking&lt;/p&gt;

&lt;p&gt;I didn’t just read about it, I built, broke, tested, and fixed things myself.&lt;br&gt;
Let me walk you through what I learned &lt;br&gt;
Why Kubernetes Services Matter (Simple Explanation)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Think of Pods like people living in apartments:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;They come and go (ephemeral)&lt;br&gt;
Their “room numbers” (IP addresses) change&lt;br&gt;
You can’t rely on reaching them directly&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kubernetes Services act like a reception desk:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One stable address&lt;br&gt;
Routes requests to the right Pods&lt;br&gt;
Only sends traffic to “healthy” Pods&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lab 7 — ClusterIP (Internal Communication)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What I Did&lt;/p&gt;

&lt;p&gt;Created an NGINX Deployment with 2 replicas&lt;br&gt;
Added labels (app: nginx) to connect Pods with Services&lt;br&gt;
Created a ClusterIP Service&lt;/p&gt;

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

&lt;p&gt;Service IP&lt;br&gt;
Endpoints&lt;br&gt;
DNS resolution inside the cluster&lt;br&gt;
Tested communication using a BusyBox test pod&lt;br&gt;
Key Commands I Used&lt;br&gt;
kubectl apply -f nginx-deployment.yaml&lt;br&gt;
kubectl apply -f clusterip-service.yaml&lt;/p&gt;

&lt;p&gt;kubectl get svc&lt;br&gt;
kubectl get endpoints&lt;/p&gt;

&lt;p&gt;kubectl run tester --image=busybox --restart=Never -- sleep 3600&lt;br&gt;
kubectl exec -it tester -- wget -qO- &lt;a href="http://nginx-clusterip" rel="noopener noreferrer"&gt;http://nginx-clusterip&lt;/a&gt;&lt;br&gt;
What I Learned&lt;br&gt;
ClusterIP is internal-only (perfect for microservices)&lt;br&gt;
Services use labels + selectors to find Pods&lt;br&gt;
Only READY Pods receive traffic&lt;br&gt;
Issue I Faced&lt;br&gt;
Initially confused why traffic failed&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How I Solved It&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checked:&lt;/strong&gt;&lt;br&gt;
kubectl describe svc nginx-clusterip&lt;br&gt;
kubectl get endpoints&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Realized:&lt;/strong&gt;&lt;br&gt;
Service works only if labels match Pods&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lab 8 — NodePort (External Access)&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;What I Did&lt;br&gt;
Created a NodePort Service&lt;br&gt;
Exposed my app on port 30080&lt;br&gt;
Checked node IP&lt;br&gt;
Tried accessing from browser&lt;br&gt;
Key Commands&lt;br&gt;
kubectl apply -f nodeport-service.yaml&lt;br&gt;
kubectl get svc nginx-nodeport&lt;br&gt;
kubectl get nodes -o wide&lt;/p&gt;

&lt;p&gt;Issue I Faced&lt;br&gt;
curl &lt;a href="http://localhost:30080" rel="noopener noreferrer"&gt;http://localhost:30080&lt;/a&gt;&lt;br&gt;
❌ Failed to connect&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why It Happened&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I was using:&lt;br&gt;
WSL + kind cluster&lt;br&gt;
NodePort doesn’t always work directly on localhost in this setup&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How I Solved It&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 1:&lt;/strong&gt; Port Forward (Worked instantly)&lt;br&gt;
kubectl port-forward svc/nginx-nodeport 8080:80&lt;/p&gt;

&lt;p&gt;Then opened:&lt;br&gt;
&lt;a href="http://localhost:8080" rel="noopener noreferrer"&gt;http://localhost:8080&lt;/a&gt;&lt;br&gt;
Option 2: Internal Testing (Best Practice)&lt;br&gt;
kubectl run tester --image=busybox --restart=Never -- sleep 3600&lt;br&gt;
kubectl exec -it tester -- wget -qO- &lt;a href="http://nginx-nodeport" rel="noopener noreferrer"&gt;http://nginx-nodeport&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I Learned&lt;/strong&gt;&lt;br&gt;
NodePort exposes app via:&lt;br&gt;
:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Works great for:&lt;/strong&gt;&lt;br&gt;
Local labs&lt;br&gt;
On-prem setups&lt;br&gt;
But depends on the network environment&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lab 9 — LoadBalancer (Cloud Simulation)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What I Did&lt;br&gt;
Created a LoadBalancer Service&lt;br&gt;
Applied it in my local environment&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Observed:&lt;/strong&gt;&lt;br&gt;
EXTERNAL-IP stayed &lt;br&gt;
Key Commands&lt;br&gt;
kubectl apply -f loadbalancer-service.yaml&lt;br&gt;
kubectl get svc&lt;br&gt;
Issue I Faced&lt;br&gt;
EXTERNAL-IP: &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why It Happened&lt;/strong&gt;&lt;br&gt;
I was NOT using cloud (like Microsoft Azure or AWS)&lt;br&gt;
LoadBalancer needs a cloud provider integration&lt;br&gt;
How I Solved It&lt;br&gt;
Used Port Forward to simulate access&lt;br&gt;
kubectl port-forward svc/nginx-loadbalancer 8080:80&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Learned Alternative Options&lt;br&gt;
Use:&lt;/strong&gt;&lt;br&gt;
Minikube tunnel&lt;br&gt;
MetalLB (bare metal)&lt;br&gt;
Or a real cloud like Microsoft Azure (AKS)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I Learned&lt;/strong&gt;&lt;br&gt;
LoadBalancer gives:&lt;br&gt;
Public IP&lt;br&gt;
Internet access&lt;br&gt;
But only works with:&lt;/p&gt;

&lt;p&gt;Cloud providers (AKS, EKS, GKE)&lt;br&gt;
Real DevOps Insight (This Changed My Understanding)&lt;br&gt;
Before this lab, I thought:&lt;br&gt;
“Services are just networking configs…”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Now I understand:&lt;/strong&gt;&lt;br&gt;
 Services are the core of Kubernetes communication&lt;br&gt;
Everything depends on them:&lt;br&gt;
Microservices&lt;br&gt;
APIs&lt;br&gt;
External traffic&lt;br&gt;
Load balancing&lt;br&gt;
Real-World Application&lt;br&gt;
Here’s how this applies in real DevOps work:&lt;br&gt;
ClusterIP&lt;br&gt;
→ Backend services talking internally&lt;br&gt;
NodePort&lt;br&gt;
→ Testing apps before production&lt;br&gt;
LoadBalancer&lt;br&gt;
→ Production apps exposed to users&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My Reflection&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The biggest lesson for me:&lt;br&gt;
Kubernetes is not just about running containers…&lt;br&gt;
It’s about making them reachable, scalable, and reliable&lt;br&gt;
This lab made everything click.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Final Thoughts&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you're learning Kubernetes:&lt;br&gt;
 Don’t just watch tutorials&lt;br&gt;
 Break things. Test things. Fix things.&lt;br&gt;
That’s where real understanding comes from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Let’s Connect&lt;/strong&gt;&lt;br&gt;
If you're also learning DevOps or Kubernetes, I’d love to connect and share ideas!&lt;br&gt;
What part of Kubernetes confused you the most?&lt;br&gt;
Have you tried exposing apps yet?&lt;br&gt;
Drop a comment &lt;/p&gt;

&lt;h1&gt;
  
  
  kubernetes #devops #cloudcomputing #learninginpublic #100daysofcode #azure #containers #docker #beginners
&lt;/h1&gt;

</description>
      <category>beginners</category>
      <category>kubernetes</category>
      <category>networking</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How I Built a Two-Pipeline Enterprise CI/CD System on Azure DevOps EpicBook Deployment</title>
      <dc:creator>Ebelechukwu Lucy Okafor</dc:creator>
      <pubDate>Tue, 21 Apr 2026 06:54:22 +0000</pubDate>
      <link>https://dev.to/ebelechukwu_lucyokafor/how-i-built-a-two-pipeline-enterprise-cicd-system-on-azure-devops-epicbook-deployment-2g8c</link>
      <guid>https://dev.to/ebelechukwu_lucyokafor/how-i-built-a-two-pipeline-enterprise-cicd-system-on-azure-devops-epicbook-deployment-2g8c</guid>
      <description>&lt;p&gt;&lt;strong&gt;Introduction&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This week, I completed what has been the most challenging and rewarding assignment of my DevOps learning journey, deploying the EpicBook online bookstore application using a two-repository, two-pipeline enterprise architecture on Azure DevOps.&lt;br&gt;
No tutorials. No hand-holding. Just a goal, a set of tools, and a lot of errors to fix.&lt;br&gt;
Here is the full story of what I built, what broke, and what I learned.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Is the Two-Pipeline Model?&lt;/strong&gt;&lt;br&gt;
In enterprise DevOps, infrastructure code and application code are kept in separate repositories and deployed by separate pipelines. This mirrors how real teams work; infrastructure engineers and developers operate independently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repository 1:&lt;/strong&gt; infra-epicbook    → Terraform IaC&lt;br&gt;
&lt;strong&gt;Repository 2:&lt;/strong&gt; theepicbook       → Node.js app + Ansible playbooks&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pipeline 1:&lt;/strong&gt; Infra Pipeline      → provisions Azure resources&lt;br&gt;
&lt;strong&gt;Pipeline 2:&lt;/strong&gt; App Pipeline        → configures servers + deploys app&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pipeline 1&lt;/strong&gt; &lt;br&gt;
runs first and produces outputs (VM IP, MySQL FQDN) that Pipeline 2 consumes. This handoff is automated through Azure DevOps pipeline artifacts.&lt;br&gt;
The Full Stack&lt;br&gt;
Layer&lt;br&gt;
Tool&lt;br&gt;
Cloud&lt;br&gt;
Microsoft Azure — South Africa North&lt;br&gt;
IaC&lt;br&gt;
Terraform v1.8.5 (azurerm ~3.100)&lt;br&gt;
Config&lt;br&gt;
Ansible&lt;br&gt;
CI/CD&lt;br&gt;
Azure DevOps — self-hosted agent (WSL)&lt;br&gt;
Authentication&lt;br&gt;
Azure Service Principal (SPN)&lt;br&gt;
App&lt;br&gt;
Node.js 18 + Express + Sequelize ORM&lt;br&gt;
Database&lt;br&gt;
Azure MySQL Flexible Server 8.0&lt;br&gt;
Web server&lt;br&gt;
Nginx — reverse proxy on port 80&lt;br&gt;
Process manager&lt;br&gt;
PM2 — keeps Node.js alive&lt;br&gt;
OS&lt;br&gt;
Ubuntu 22.04 LTS — Standard_D2s_v3&lt;br&gt;
Live URL&lt;br&gt;
&lt;a href="http://20.164.211.94" rel="noopener noreferrer"&gt;http://20.164.211.94&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Architecture Overview&lt;/strong&gt;&lt;br&gt;
Developer → git push to main&lt;br&gt;
         ↓&lt;br&gt;
    Azure DevOps&lt;br&gt;
    ┌─────────────────────────────────────────┐&lt;br&gt;
    │  Pipeline 1 (Terraform)                 │&lt;br&gt;
    │  terraform init → plan → apply          │&lt;br&gt;
    │  → outputs: VM IP + MySQL FQDN          │&lt;br&gt;
    │                                         │&lt;br&gt;
    │  Pipeline 2 (Ansible)                   │&lt;br&gt;
    │  install Node.js + PM2 + Nginx          │&lt;br&gt;
    │  deploy EpicBook → configure MySQL      │&lt;br&gt;
    └─────────────────────────────────────────┘&lt;br&gt;
         ↓&lt;br&gt;
    Azure Virtual Network (10.0.0.0/16)&lt;br&gt;
    ┌─────────────────────┬───────────────────┐&lt;br&gt;
    │  Public Subnet      │  Private Subnet   │&lt;br&gt;
    │  10.0.1.0/24        │  10.0.2.0/24      │&lt;br&gt;
    │                     │                   │&lt;br&gt;
    │  epicbook-prod-vm   │  MySQL Flexible   │&lt;br&gt;
    │  Ubuntu 22.04       │  Server 8.0       │&lt;br&gt;
    │  Nginx :80          │  port 3306        │&lt;br&gt;
    │  Node.js :8080      │  VNet only        │&lt;br&gt;
    │  PM2                │  SSL required     │&lt;br&gt;
    └─────────────────────┴───────────────────┘&lt;br&gt;
         ↓&lt;br&gt;
    Browser → &lt;a href="http://20.164.211.94" rel="noopener noreferrer"&gt;http://20.164.211.94&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1:&lt;/strong&gt; Service Principal (SPN) Setup&lt;br&gt;
Terraform needs permission to create Azure resources. I created an App Registration in Azure AD and assigned it the Contributor role on my subscription.&lt;br&gt;
az ad sp create-for-rbac \&lt;br&gt;
  --name "epicbook-devops-spn" \&lt;br&gt;
  --role Contributor \&lt;br&gt;
  --scopes /subscriptions/$(az account show --query id -o tsv) \&lt;br&gt;
  --output json&lt;/p&gt;

&lt;p&gt;This outputs four credentials: Client ID, Client Secret, Tenant ID, and Subscription ID. These go into an Azure DevOps service connection named azure-devops-connection.&lt;br&gt;
Key lesson: The Client Secret is shown exactly once. Copy it immediately before closing the page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2:&lt;/strong&gt; Terraform Remote State Backend&lt;br&gt;
For pipeline-based Terraform, the state file must live in Azure Blob Storage so the agent can access it between runs.&lt;br&gt;
az storage account create \&lt;br&gt;
  --name epicbooklucytfstate01 \&lt;br&gt;
  --resource-group rg-epicbook \&lt;br&gt;
  --location southafricanorth \&lt;br&gt;
  --sku Standard_LRS&lt;/p&gt;

&lt;p&gt;az storage container create \&lt;br&gt;
  --name tfstate \&lt;br&gt;
  --account-name epicbooklucytfstate01 \&lt;br&gt;
  --auth-mode login&lt;/p&gt;

&lt;p&gt;The backend.tf configuration:&lt;br&gt;
terraform {&lt;br&gt;
  backend "azurerm" {&lt;br&gt;
    resource_group_name  = "rg-epicbook"&lt;br&gt;
    storage_account_name = "epicbooklucytfstate01"&lt;br&gt;
    container_name       = "tfstate"&lt;br&gt;
    key                  = "epicbook.terraform.tfstate"&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Critical lesson:&lt;/strong&gt; You cannot use variables in a Terraform backend block. Pass the storage account key as the ARM_ACCESS_KEY environment variable instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3:&lt;/strong&gt; Infrastructure Pipeline (Terraform)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Infra pipeline provisions 13 Azure resources in one run:&lt;/strong&gt;&lt;br&gt;
Resource Group&lt;br&gt;
Virtual Network (10.0.0.0/16)&lt;br&gt;
Public subnet (10.0.1.0/24) + NSG (ports 22 and 80)&lt;br&gt;
Private subnet (10.0.2.0/24) + NSG (port 3306 from VNet only)&lt;br&gt;
Static Public IP (Standard SKU)&lt;br&gt;
Network Interface&lt;br&gt;
Private DNS Zone (linked to VNet)&lt;br&gt;
Linux VM (Ubuntu 22.04, Standard_D2s_v3)&lt;br&gt;
MySQL Flexible Server 8.0&lt;br&gt;
MySQL database (bookstore)&lt;/p&gt;

&lt;p&gt;The pipeline YAML authenticates using AzureCLI@2 with addSpnToEnvironment: true, which injects the SPN credentials automatically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;task: AzureCLI@2
displayName: 'Terraform Apply'
inputs:
azureSubscription: 'azure-devops-connection'
addSpnToEnvironment: true
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
  export ARM_CLIENT_ID="$servicePrincipalId"
  export ARM_CLIENT_SECRET="$servicePrincipalKey"
  export ARM_TENANT_ID="$tenantId"
  export ARM_SUBSCRIPTION_ID=$(az account show --query id -o tsv | tr -d '\r')
  export ARM_ACCESS_KEY="$STORAGE_KEY"
  terraform -chdir="$(Build.SourcesDirectory)" apply -auto-approve tfplan
** env:**
STORAGE_KEY: $(storage_account_key)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After applying, the pipeline captures and publishes the outputs:&lt;br&gt;
public_ip = 20.164.211.94&lt;br&gt;
db_host   = epicbook-prod-mysql-nrbhh4.mysql.database.azure.com&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4:&lt;/strong&gt; Application Pipeline (Ansible)&lt;br&gt;
The App pipeline connects to the VM over SSH using a private key stored in Azure DevOps Secure Files, then runs an Ansible playbook that:&lt;br&gt;
Installs Node.js 18, PM2, and Nginx&lt;br&gt;
Clones the EpicBook repository&lt;br&gt;
Installs npm dependencies&lt;br&gt;
Creates .env and config/config.json with MySQL connection details&lt;br&gt;
Starts the app with PM2&lt;br&gt;
Configures Nginx as a reverse proxy to port 8080&lt;br&gt;
Verifies the app responds with HTTP 200&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;script: |
ansible-playbook \
  -i inventory.ini \
  ansible/site.yml \
  --extra-vars "mysql_host=$(MYSQL_FQDN) db_name=bookstore db_user=epicbook_user" \
  -v
displayName: 'Run Ansible playbook'&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The Errors I Hit (And Fixed)&lt;/strong&gt;&lt;br&gt;
This deployment took two weeks. Here is what broke and how I fixed it.&lt;br&gt;
&lt;strong&gt;Error 1:&lt;/strong&gt; SPN Cannot Read Subscription (403 Forbidden)&lt;br&gt;
The SPN was missing the Reader role. Fixed by assigning both Contributor and Reader.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error 2:&lt;/strong&gt; Variables Not Allowed in Backend Block&lt;br&gt;
Error: Variables not allowed&lt;br&gt;
on backend.tf line 7: access_key = var.storage_account_key&lt;/p&gt;

&lt;p&gt;Terraform evaluates the backend before loading variables. Removed the access_key line and passed it via ARM_ACCESS_KEY environment variable instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error 3:&lt;/strong&gt; Carriage Return in Subscription ID (400 Bad Request)&lt;br&gt;
The Windows Azure CLI adds \r to command output. This corrupted the subscription ID URL:&lt;br&gt;
parse "&lt;a href="https://management.azure.com/subscriptions/a75e93ba%5Cr/providers" rel="noopener noreferrer"&gt;https://management.azure.com/subscriptions/a75e93ba\r/providers&lt;/a&gt;"&lt;/p&gt;

&lt;p&gt;Fixed by piping through tr -d '\r':&lt;br&gt;
export ARM_SUBSCRIPTION_ID=$(az account show --query id -o tsv | tr -d '\r')&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error 4:&lt;/strong&gt; VM Size Quota Exceeded&lt;br&gt;
Standard_B2ats_v2 has zero quota in South Africa North. Checked available sizes:&lt;br&gt;
az vm list-skus --location southafricanorth --size Standard_D2s --output table&lt;br&gt;
Switched to Standard_D2s_v3, which had no restrictions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error 5:&lt;/strong&gt; App Connects to localhost Instead of MySQL&lt;br&gt;
SequelizeConnectionRefusedError: connect ECONNREFUSED 127.0.0.1:3306&lt;/p&gt;

&lt;p&gt;The EpicBook app reads database config from config/config.json, not .env. The file had host: 127.0.0.1 hardcoded. Updated it with the actual MySQL FQDN and SSL settings.&lt;br&gt;
Error 6: 502 Bad Gateway&lt;br&gt;
The app listens on port 8080 by default (process.env.PORT || 8080), but Nginx was proxying to port 3000. Fixed by checking the actual port in server.js:&lt;br&gt;
grep -n "port|PORT|listen" ~/theepicbook/server.js&lt;/p&gt;

&lt;h1&gt;
  
  
  const PORT = process.env.PORT || 8080;
&lt;/h1&gt;

&lt;p&gt;Then updated both .env and Nginx config to use port 8080.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key Lessons Learned&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Always check what port your app actually uses before configuring Nginx. Never assume that grep the source code.&lt;/li&gt;
&lt;li&gt;Terraform backend blocks are special. They are evaluated before any variable loading. Credentials must come from environment variables, not var. references.&lt;/li&gt;
&lt;li&gt;WSL + Azure CLI adds \r to output. Always pipe through tr -d '\r' when using az CLI output in bash scripts that go into URLs or environment variables.&lt;/li&gt;
&lt;li&gt;Azure VM size quotas vary by region. Have fallback sizes ready. Standard_D2s_v3 is reliably available across most regions.&lt;/li&gt;
&lt;li&gt;Check where your app actually reads config. The EpicBook app ignored .env for database settings and read from config/config.json instead. Always check the source code.&lt;/li&gt;
&lt;li&gt;Private MySQL = no public endpoint. The MySQL Flexible Server in the private subnet has no public IP it is only reachable from within the VNet. This is production-grade security done right.&lt;/li&gt;
&lt;li&gt;PM2 is non-negotiable for Node.js in production. Without it, the app dies the moment your Ansible playbook finishes.
What I Would Do Differently
Add an admin_ssh_key block to Terraform from the start instead of manually pushing keys via az vm run-command
Use Terraform workspaces for environment separation (dev/staging/prod)
Store db_password in Azure Key Vault and reference it from the variable group
Add a health check endpoint to the app for proper pipeline verification
Set up PM2 startup script so the app survives VM reboots automatically&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The Result&lt;/strong&gt;&lt;br&gt;
After two weeks, two pipelines, thirteen Azure resources, and nine documented errors, EpicBook is live.&lt;br&gt;
The full architecture Terraform provisioning infrastructure, Ansible configuring servers, Node.js serving the application, MySQL storing data, Nginx proxying requests, PM2 keeping everything running, is automated end-to-end by Azure DevOps on every commit to main.&lt;br&gt;
That is CI/CD done the enterprise way.&lt;br&gt;
Resources&lt;br&gt;
Azure DevOps documentation docs.microsoft.com/azure/devops&lt;br&gt;
Terraform azurerm provider registry.terraform.io/providers/hashicorp/azurerm&lt;br&gt;
Ansible documentation docs.ansible.com&lt;br&gt;
PM2 documentation pm2.keymetrics.io&lt;br&gt;
EpicBook app github.com/pravinmishraaws/theepicbook&lt;/p&gt;

</description>
      <category>azure</category>
      <category>cicd</category>
      <category>devops</category>
      <category>terraform</category>
    </item>
    <item>
      <title>From 500 Errors to a Fully Working Cloud App: My EpicBook Deployment on Azure</title>
      <dc:creator>Ebelechukwu Lucy Okafor</dc:creator>
      <pubDate>Wed, 08 Apr 2026 11:03:54 +0000</pubDate>
      <link>https://dev.to/ebelechukwu_lucyokafor/from-500-errors-to-a-fully-working-cloud-app-my-epicbook-deployment-on-azure-2mn3</link>
      <guid>https://dev.to/ebelechukwu_lucyokafor/from-500-errors-to-a-fully-working-cloud-app-my-epicbook-deployment-on-azure-2mn3</guid>
      <description>&lt;p&gt;This week, I moved beyond simple deployments and built a real-world, multi-tier application architecture using Terraform + Ansible + Nginx + Node.js + MySQL on Microsoft Azure.&lt;br&gt;
And honestly? It didn’t work at first.&lt;br&gt;
But that’s where the real learning happened.&lt;br&gt;
Let me walk you through the full journey, including the mistakes, fixes, and key DevOps lessons you can apply immediately.&lt;/p&gt;

&lt;p&gt;The Goal&lt;br&gt;
Deploy a production-style web application (EpicBook) with:&lt;br&gt;
🌐 Web Layer → Nginx (Reverse Proxy)&lt;br&gt;
⚙️ App Layer → Node.js (Express app)&lt;br&gt;
🗄️ Database Layer → MySQL (separate VM)&lt;br&gt;
🏗️ Infrastructure → Terraform&lt;br&gt;
🤖 Automation → Ansible&lt;/p&gt;

&lt;p&gt;Step 1: Provisioning Infrastructure with Terraform&lt;br&gt;
Instead of manually creating servers, I used Terraform to spin up:&lt;br&gt;
4 Virtual Machines (web1, web2, app, db)&lt;br&gt;
Virtual Network + Subnets&lt;br&gt;
Public IPs&lt;br&gt;
Network Security Groups&lt;br&gt;
terraform init&lt;br&gt;
terraform plan&lt;br&gt;
terraform apply&lt;/p&gt;

&lt;p&gt;Lesson: Infrastructure should be code. If you can’t recreate it in minutes, it’s not production-ready.&lt;/p&gt;

&lt;p&gt;Step 2: SSH Setup (First Real Problem)&lt;br&gt;
After provisioning, Ansible couldn’t connect:&lt;br&gt;
UNREACHABLE! SSH host key verification failed&lt;/p&gt;

&lt;p&gt;Why does this happen?&lt;br&gt;
Every time Terraform creates new VMs, they get new SSH fingerprints.&lt;br&gt;
Fix:&lt;br&gt;
ssh-keyscan -H  &amp;gt;&amp;gt; ~/.ssh/known_hosts&lt;/p&gt;

&lt;p&gt;Lesson: Always run ssh-keyscan after redeploying infrastructure.&lt;/p&gt;

&lt;p&gt;Step 3: Running Ansible&lt;br&gt;
I executed my playbook:&lt;br&gt;
ansible-playbook -i inventory.ini site.yml&lt;/p&gt;

&lt;p&gt;✔ Everything passed&lt;br&gt;
✔ No errors&lt;br&gt;
But then I opened the browser…&lt;/p&gt;

&lt;p&gt;Step 4: The 500 Internal Server Error&lt;br&gt;
500 Internal Server Error&lt;br&gt;
nginx/1.18.0&lt;/p&gt;

&lt;p&gt;At this point, many people would think:&lt;br&gt;
“But Ansible said SUCCESS…”&lt;br&gt;
This is where DevOps thinking matters.&lt;/p&gt;

&lt;p&gt;Step 5: Debugging the Problem&lt;br&gt;
I inspected the application:&lt;br&gt;
cat /var/www/epicbook/server.js&lt;/p&gt;

&lt;p&gt;Discovery:&lt;br&gt;
It’s a Node.js app&lt;br&gt;
Runs on port 8080&lt;br&gt;
Not a static website&lt;br&gt;
The mistake:&lt;br&gt;
I configured Nginx like this:&lt;br&gt;
root /var/www/epicbook;&lt;/p&gt;

&lt;p&gt;That only works for HTML files, not backend apps.&lt;/p&gt;

&lt;p&gt;Step 6: Fixing the Architecture&lt;br&gt;
This was the turning point.&lt;br&gt;
✅ Fix 1: Nginx Reverse Proxy&lt;br&gt;
location / {&lt;br&gt;
    proxy_pass &lt;a href="http://localhost:8080" rel="noopener noreferrer"&gt;http://localhost:8080&lt;/a&gt;;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Now Nginx forwards traffic to Node.js.&lt;/p&gt;

&lt;p&gt;✅ Fix 2: Install Node.js&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;name: Install Node.js
apt:
name: nodejs
state: present&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;✅ Fix 3: Install Dependencies&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;name: Install npm packages
npm:
path: /var/www/epicbook&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;✅ Fix 4: Run App as a Service&lt;br&gt;
Created a systemd service:&lt;br&gt;
ExecStart=/usr/bin/node server.js&lt;/p&gt;

&lt;p&gt;✅ Fix 5: Database Setup&lt;br&gt;
Installed MySQL on the DB server&lt;br&gt;
Created:&lt;br&gt;
database: bookstore&lt;br&gt;
user: epicbook&lt;/p&gt;

&lt;p&gt;✅ Fix 6: Use Private Network (CRITICAL)&lt;br&gt;
Instead of:&lt;br&gt;
"host": "127.0.0.1"&lt;/p&gt;

&lt;p&gt;I used:&lt;br&gt;
"host": "10.0.1.7"&lt;br&gt;
 Lesson: Databases should NEVER be exposed publicly.&lt;br&gt;
Other Errors I Encountered&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Git “Dubious Ownership”
fatal: detected dubious ownership&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;✔ Fix: &lt;br&gt;
Remove and re-clone the directory&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;SSH Timeout
Connection timed out&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;✔ Fix: &lt;br&gt;
Retry + verify connectivity&lt;/p&gt;

&lt;p&gt;Final Result&lt;br&gt;
After fixing everything:&lt;br&gt;
✅ App running on both web servers&lt;br&gt;
✅ Nginx routing traffic correctly&lt;br&gt;
✅ Node.js processing requests&lt;br&gt;
✅ MySQL storing data&lt;br&gt;
Open in browser:&lt;br&gt;
http://&lt;br&gt;
http://&lt;/p&gt;

&lt;p&gt;Idempotency Check&lt;br&gt;
I ran the playbook again:&lt;br&gt;
ansible-playbook -i inventory.ini site.yml&lt;/p&gt;

&lt;p&gt;✔ changed=0&lt;br&gt;
 Lesson: Good automation should be safe to run multiple times.&lt;/p&gt;

&lt;p&gt;What I Learned (The Real Value)&lt;br&gt;
This assignment wasn’t just about tools it was about thinking like a DevOps engineer.&lt;br&gt;
Key Takeaways:&lt;br&gt;
✔ “Success” in automation doesn’t mean the app works&lt;br&gt;
✔ Always verify in the browser (real user test)&lt;br&gt;
✔ Understand your application (Node.js ≠ static site)&lt;br&gt;
✔ Debugging is more important than writing code&lt;br&gt;
✔ Architecture decisions matter more than commands&lt;/p&gt;

&lt;p&gt;Final Insight&lt;br&gt;
A deployment is only successful when a real user can access the application — not when the playbook says “OK”.&lt;/p&gt;

&lt;p&gt;If You're Learning DevOps…&lt;br&gt;
Start building projects like this:&lt;br&gt;
Multi-tier apps&lt;br&gt;
Real debugging scenarios&lt;br&gt;
Infrastructure + application together&lt;br&gt;
That’s how you move from:&lt;br&gt;
👉 “I know commands”&lt;br&gt;
to&lt;br&gt;
👉 “I can solve real-world problems”&lt;/p&gt;

&lt;p&gt;Let’s Connect&lt;br&gt;
If you’re also learning DevOps or working on similar projects, I’d love to connect and learn together.&lt;/p&gt;

&lt;h1&gt;
  
  
  DevOps #Terraform #Ansible #Azure #NodeJS #CloudComputing #LearningInPublic
&lt;/h1&gt;

</description>
      <category>azure</category>
      <category>devops</category>
      <category>terraform</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How I Deployed a Three-Tier Book Review App on AWS Using Terraform Modules and Agentic AI</title>
      <dc:creator>Ebelechukwu Lucy Okafor</dc:creator>
      <pubDate>Fri, 27 Mar 2026 07:36:59 +0000</pubDate>
      <link>https://dev.to/ebelechukwu_lucyokafor/how-i-deployed-a-three-tier-book-review-app-on-aws-using-terraform-modules-and-agentic-ai-453o</link>
      <guid>https://dev.to/ebelechukwu_lucyokafor/how-i-deployed-a-three-tier-book-review-app-on-aws-using-terraform-modules-and-agentic-ai-453o</guid>
      <description>&lt;p&gt;A complete walkthrough from VPC design to a live Next.js + Node.js + MySQL application running on EC2 and RDS, with every error documented and fixed.&lt;/p&gt;

&lt;p&gt;When I started this assignment, I had already deployed applications on both Azure and AWS in previous weeks. But this was different. This time, there was no step-by-step guide. The assignment said: "Design and execute this project like a professional."&lt;/p&gt;

&lt;p&gt;So that is exactly what I did. I used Terraform modules to organise my infrastructure, Claude Code as my Agentic AI copilot to generate templates and fix errors, and deployed a real full-stack Book Review application, Next.js frontend, Node.js backend, and MySQL database on Amazon RDS.&lt;br&gt;
"Agentic DevOps is not about replacing engineers with AI. It is about changing what engineers spend their time on. The agent executes. The engineer decides."&lt;br&gt;
This post documents everything: the architecture decisions, every command, every error I hit, and every fix that worked. If you follow this guide, you can deploy the same application with zero surprises.&lt;br&gt;
****What We Are Building&lt;/p&gt;

&lt;p&gt;A three-tier web application following production architecture patterns:&lt;/p&gt;

&lt;p&gt;Internet Users │ ▼ port 80 EC2 Ubuntu 22.04 ← Public Subnet 10.0.1.0/24 ├── Nginx (port 80) → proxies to Next.js (port 3000) ├── Next.js 15 Frontend ← PM2 managed, port 3000 └── Node.js Backend ← PM2 managed, port 3001 │ ▼ port 3306 (MySQL) RDS MySQL 8.0 ← Private Subnets — no internet access ├── bookreview-db (primary) └── Security: only EC2 can connect on port 3306 Terraform Modules: networking/ → VPC + 3 subnets + IGW + NAT security/ → EC2 SG + RDS SG compute/ → EC2 t2.micro + Elastic IP database/ → RDS MySQL db.t3.micro&lt;/p&gt;

&lt;p&gt;****Prerequisites&lt;/p&gt;

&lt;p&gt;Install these on your local machine before starting:&lt;/p&gt;

&lt;h1&gt;
  
  
  Check all tools are ready
&lt;/h1&gt;

&lt;p&gt;terraform -v              # Need &amp;gt;= 1.5&lt;br&gt;
aws sts get-caller-identity  # Your AWS account&lt;br&gt;
git --version             # Any recent version&lt;/p&gt;

&lt;p&gt;Terraform &amp;gt;= 1.5 — terraform.io/downloads&lt;br&gt;
AWS CLI configured with aws configure&lt;br&gt;
AWS account with EC2, RDS, and VPC permissions&lt;br&gt;
Git and VS Code&lt;br&gt;
SSH client (built into Mac/Linux; Git Bash on Windows)&lt;/p&gt;

&lt;p&gt;****Why Terraform Modules?&lt;/p&gt;

&lt;p&gt;****Key Concept: Terraform Modules&lt;/p&gt;

&lt;p&gt;Modules are reusable, self-contained packages of Terraform code, instead of one giant main.tf with 300+ lines. Modules separate concerns. Each module has its own inputs (variables.tf), resources (main.tf), and outputs (outputs.tf). The root main.tf calls all modules and wires their outputs together.&lt;br&gt;
For this deployment, I created five modules:&lt;br&gt;
modules/networking/&lt;br&gt;
VPC (10.0.0.0/16), public subnet, 2 private subnets, Internet Gateway, NAT Gateway, route tables&lt;br&gt;
variables.tfmain.tfoutputs.tf&lt;br&gt;
modules/security/&lt;br&gt;
EC2 security group (SSH + HTTP + port 3000) and RDS security group (MySQL from EC2 SG only)&lt;br&gt;
variables.tfmain.tfoutputs.tf&lt;br&gt;
modules/compute/&lt;br&gt;
EC2 t2.micro Ubuntu 22.04, Elastic IP, key pair attachment, user_data for setup&lt;br&gt;
variables.tfmain.tfoutputs.tf&lt;br&gt;
modules/database/&lt;br&gt;
RDS MySQL 8.0 db.t3.micro in private subnets, DB subnet group, password config&lt;br&gt;
variables.tfmain.tfoutputs.tf&lt;br&gt;
The key insight is how modules talk to each other through outputs. The networking module outputs vpc_id. The security module takes that as input. The compute module takes the security group ID from security. No module needs to know the internal details of another, only their outputs.&lt;br&gt;
The Deployment Phase by Phase&lt;/p&gt;

&lt;p&gt;****PHASE 01 Project Setup and SSH Key Generation&lt;/p&gt;

&lt;p&gt;Create the project folder, module structure, and SSH key pair. The key pair must exist before running terraform validate. Terraform reads the public key file during validation.&lt;/p&gt;

&lt;h1&gt;
  
  
  Create project and module folders
&lt;/h1&gt;

&lt;p&gt;mkdir terraform-book-review-CLI&lt;br&gt;
cd terraform-book-review-CLI&lt;br&gt;
mkdir -p modules/networking modules/security&lt;br&gt;
mkdir -p modules/compute modules/database user_data&lt;/p&gt;

&lt;h1&gt;
  
  
  Generate SSH key pair (MUST do this before terraform validate)
&lt;/h1&gt;

&lt;p&gt;ssh-keygen -t rsa -b 4096 -f bookreview-key -N ""&lt;/p&gt;

&lt;h1&gt;
  
  
  Creates: bookreview-key (private) + bookreview-key.pub (public)
&lt;/h1&gt;

&lt;h1&gt;
  
  
  Protect sensitive files from git
&lt;/h1&gt;

&lt;p&gt;echo "bookreview-key" &amp;gt;&amp;gt; .gitignore&lt;br&gt;
echo "*.tfstate" &amp;gt;&amp;gt; .gitignore&lt;br&gt;
echo ".terraform/" &amp;gt;&amp;gt; .gitignore&lt;/p&gt;

&lt;p&gt;****PHASE 02 Write All Terraform Module Files&lt;/p&gt;

&lt;p&gt;This is where I used Claude Code as an Agentic AI copilot. I used /init to create CLAUDE.md, then asked Claude Code to generate the Terraform templates. When validation errors appeared, I pasted them into Claude Code, and it read all module files simultaneously, diagnosed the root cause, and applied fixes automatically. What would take 30 minutes manually took 7 minutes with Agentic AI.&lt;br&gt;
Here is the root main.tf that calls all modules. Notice how module outputs become inputs to the next module:&lt;br&gt;
terraform {&lt;br&gt;
  required_providers {&lt;br&gt;
    aws = { source = "hashicorp/aws", version = "~&amp;gt; 5.0" }&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;provider "aws" { region = var.aws_region }&lt;/p&gt;

&lt;h1&gt;
  
  
  Upload SSH public key to AWS
&lt;/h1&gt;

&lt;p&gt;resource "aws_key_pair" "main" {&lt;br&gt;
  key_name   = "bookreview-key"&lt;br&gt;
  public_key = file("${path.module}/bookreview-key.pub")&lt;br&gt;
}&lt;/p&gt;

&lt;h1&gt;
  
  
  Step 1: Create network foundation
&lt;/h1&gt;

&lt;p&gt;module "networking" {&lt;br&gt;
  source   = "./modules/networking"&lt;br&gt;
  vpc_cidr = var.vpc_cidr&lt;br&gt;
  azs      = var.azs&lt;br&gt;
}&lt;/p&gt;

&lt;h1&gt;
  
  
  Step 2: Create security groups (needs VPC ID from networking)
&lt;/h1&gt;

&lt;p&gt;module "security" {&lt;br&gt;
  source = "./modules/security"&lt;br&gt;
  vpc_id = module.networking.vpc_id  # ← output from networking&lt;br&gt;
}&lt;/p&gt;

&lt;h1&gt;
  
  
  Step 3: Create EC2 (needs subnet + SG from above modules)
&lt;/h1&gt;

&lt;p&gt;module "compute" {&lt;br&gt;
  source    = "./modules/compute"&lt;br&gt;
  subnet_id = module.networking.public_subnet_id&lt;br&gt;
  sg_id     = module.security.ec2_sg&lt;br&gt;
  key_name  = aws_key_pair.main.key_name&lt;br&gt;
}&lt;/p&gt;

&lt;h1&gt;
  
  
  Step 4: Create RDS (needs private subnets + RDS SG)
&lt;/h1&gt;

&lt;p&gt;module "database" {&lt;br&gt;
  source             = "./modules/database"&lt;br&gt;
  private_subnet_ids = [module.networking.private_subnet_id_1,&lt;br&gt;
                        module.networking.private_subnet_id_2]&lt;br&gt;
  rds_sg_id          = module.security.rds_sg&lt;br&gt;
  db_password        = var.db_password&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;****PHASE 03 Terraform Init, Validate, Plan and Apply&lt;/p&gt;

&lt;p&gt;With all module files written, the standard Terraform workflow runs. RDS takes 5–8 minutes to provision. Note your EC2 public IP and RDS endpoint from the outputs you need them for all SSH and database commands.&lt;br&gt;
terraform init      # Downloads AWS provider ~5.0&lt;br&gt;
terraform validate  # Checks all module references&lt;br&gt;
terraform plan      # Preview — confirm no destroy actions&lt;br&gt;
terraform apply     # Type 'yes' — wait ~10 minutes&lt;/p&gt;

&lt;h1&gt;
  
  
  After apply completes, note these outputs:
&lt;/h1&gt;

&lt;p&gt;ec2_public_ip   = "54.227.244.250"&lt;br&gt;
rds_endpoint    = "bookreview-db.cixq88qmex1t.us-east-1.rds.amazonaws.com:3306"&lt;/p&gt;

&lt;p&gt;****PHASE 04 SSH Into EC2 and Install Node.js 18&lt;br&gt;
Ubuntu 22.04 ships with Node.js v12. The app requires v18. This is the most common trap when deploying Next.js on a fresh Ubuntu server: you must install Node.js 18 via the NodeSource repository, not the default apt package.&lt;/p&gt;

&lt;h1&gt;
  
  
  From local terminal
&lt;/h1&gt;

&lt;p&gt;chmod 400 bookreview-key&lt;br&gt;
ssh -i bookreview-key &lt;a href="mailto:ubuntu@54.227.244.250"&gt;ubuntu@54.227.244.250&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Inside EC2 — remove old Node conflict and install v18
&lt;/h1&gt;

&lt;p&gt;sudo apt remove -y libnode-dev&lt;br&gt;
sudo apt autoremove -y &amp;amp;&amp;amp; sudo apt clean&lt;br&gt;
curl -fsSL &lt;a href="https://deb.nodesource.com/setup_18.x" rel="noopener noreferrer"&gt;https://deb.nodesource.com/setup_18.x&lt;/a&gt; | sudo -E bash -&lt;br&gt;
sudo apt install -y nodejs nginx git&lt;/p&gt;

&lt;h1&gt;
  
  
  Verify
&lt;/h1&gt;

&lt;p&gt;node -v   # v18.20.8 ✅&lt;br&gt;
npm -v    # 10.8.2 ✅&lt;/p&gt;

&lt;p&gt;****PHASE 05: Clone App and Configure Backend&lt;/p&gt;

&lt;p&gt;The repository is a monorepo with backend/ (Node.js + Express + Sequelize) and frontend/ (Next.js 15). The backend .env file points to localhost by default. Update it to point to the RDS endpoint. Sequelize auto-creates all database tables on first startup.&lt;br&gt;
git clone &lt;a href="https://github.com/pravinmishraaws/book-review-app" rel="noopener noreferrer"&gt;https://github.com/pravinmishraaws/book-review-app&lt;/a&gt; ~/book-review-app&lt;br&gt;
cd ~/book-review-app/backend&lt;/p&gt;

&lt;h1&gt;
  
  
  Update .env with RDS credentials (use nano — NOT cat heredoc)
&lt;/h1&gt;

&lt;p&gt;nano .env&lt;/p&gt;

&lt;h1&gt;
  
  
  Replace content with:
&lt;/h1&gt;

&lt;p&gt;DB_HOST=bookreview-db.cixq88qmex1t.us-east-1.rds.amazonaws.com&lt;br&gt;
DB_NAME=bookreviewdb&lt;br&gt;
DB_USER=admin&lt;br&gt;
DB_PASS=BookReview123&lt;br&gt;
DB_DIALECT=mysql&lt;br&gt;
PORT=3001&lt;br&gt;
JWT_SECRET=mysecretkey&lt;/p&gt;

&lt;h1&gt;
  
  
  Ctrl+X then Y then Enter to save
&lt;/h1&gt;

&lt;h1&gt;
  
  
  Install PM2 globally and start backend
&lt;/h1&gt;

&lt;p&gt;sudo npm install -g pm2&lt;br&gt;
pm2 start src/server.js --name backend&lt;br&gt;
pm2 save&lt;/p&gt;

&lt;h1&gt;
  
  
  Watch for this in logs:
&lt;/h1&gt;

&lt;p&gt;Database 'bookreviewdb' connected successfully with SSL!&lt;br&gt;
 Database schema updated successfully!&lt;br&gt;
 Server running on port 3001&lt;/p&gt;

&lt;p&gt;****Key Concept: Sequelize Auto-Sync&lt;br&gt;
Sequelize calls sync() on startup, which creates all database tables automatically if they don't exist. You don't need to run SQL migration files manually. When you see "Database schema updated successfully", all tables are ready.&lt;/p&gt;

&lt;p&gt;****PHASE 06 Build and Deploy Frontend + Configure Nginx&lt;/p&gt;

&lt;p&gt;Next.js 15 requires a production build before starting. The build compiles all pages, optimises JavaScript, and pre-renders static pages. Nginx then acts as a reverse proxy; users hit port 80, and Nginx forwards to port 3000.&lt;br&gt;
cd ~/book-review-app/frontend&lt;/p&gt;

&lt;h1&gt;
  
  
  Tell frontend where the backend API is
&lt;/h1&gt;

&lt;p&gt;cat &amp;gt; .env.local &amp;lt;&amp;lt; 'EOF'&lt;br&gt;
NEXT_PUBLIC_API_URL=&lt;a href="http://54.227.244.250:3001" rel="noopener noreferrer"&gt;http://54.227.244.250:3001&lt;/a&gt;&lt;br&gt;
EOF&lt;/p&gt;

&lt;p&gt;npm install           # Installs 327 packages&lt;br&gt;
npm run build         # Compiles production bundle&lt;/p&gt;

&lt;h1&gt;
  
  
  Expected: ✓ Compiled successfully — 7 pages
&lt;/h1&gt;

&lt;p&gt;pm2 start npm --name frontend -- start&lt;br&gt;
pm2 save&lt;br&gt;
pm2 list              # backend + frontend both online ✅&lt;/p&gt;

&lt;h1&gt;
  
  
  Configure Nginx reverse proxy
&lt;/h1&gt;

&lt;p&gt;sudo tee /etc/nginx/sites-available/bookreview &amp;lt;&amp;lt; 'EOF'&lt;br&gt;
server {&lt;br&gt;
    listen 80;&lt;br&gt;
    server_name _;&lt;br&gt;
    location /api {&lt;br&gt;
        proxy_pass &lt;a href="http://localhost:3001" rel="noopener noreferrer"&gt;http://localhost:3001&lt;/a&gt;;&lt;br&gt;
        proxy_set_header Host $host;&lt;br&gt;
    }&lt;br&gt;
    location / {&lt;br&gt;
        proxy_pass &lt;a href="http://localhost:3000" rel="noopener noreferrer"&gt;http://localhost:3000&lt;/a&gt;;&lt;br&gt;
        proxy_http_version 1.1;&lt;br&gt;
        proxy_set_header Upgrade $http_upgrade;&lt;br&gt;
        proxy_set_header Connection 'upgrade';&lt;br&gt;
        proxy_set_header Host $host;&lt;br&gt;
    }&lt;br&gt;
}&lt;br&gt;
EOF&lt;/p&gt;

&lt;p&gt;sudo ln -sf /etc/nginx/sites-available/bookreview /etc/nginx/sites-enabled/default&lt;br&gt;
sudo nginx -t &amp;amp;&amp;amp; sudo systemctl restart nginx&lt;/p&gt;

&lt;h1&gt;
  
  
  Make PM2 survive reboots
&lt;/h1&gt;

&lt;p&gt;pm2 startup    # Run the command it outputs&lt;br&gt;
pm2 save&lt;/p&gt;

&lt;p&gt;PHASE 07 Verify — App is Live!&lt;/p&gt;

&lt;p&gt;Open your browser and navigate to your EC2 public IP. The Book Review App homepage loads. Login and register pages work. The backend is connected to RDS MySQL. The full stack is live.&lt;br&gt;
&lt;a href="http://54.227.244.250:3000" rel="noopener noreferrer"&gt;http://54.227.244.250:3000&lt;/a&gt;          → Book Review App homepage ✅&lt;br&gt;
&lt;a href="http://54.227.244.250:3000/login" rel="noopener noreferrer"&gt;http://54.227.244.250:3000/login&lt;/a&gt;    → Login page with form ✅&lt;br&gt;
&lt;a href="http://54.227.244.250:3000/register" rel="noopener noreferrer"&gt;http://54.227.244.250:3000/register&lt;/a&gt; → Register page ✅&lt;br&gt;
&lt;a href="http://54.227.244.250" rel="noopener noreferrer"&gt;http://54.227.244.250&lt;/a&gt;               → Same app via Nginx port 80 ✅&lt;/p&gt;

&lt;p&gt;****Every Error I Hit and How I Fixed It&lt;/p&gt;

&lt;p&gt;These are all the errors I encountered during the deployment. If you follow this guide, you will hit the same ones; these fixes resolve them completely.&lt;/p&gt;

&lt;p&gt;Error&lt;br&gt;
Root Cause&lt;br&gt;
Fix&lt;br&gt;
Status&lt;br&gt;
1&lt;br&gt;
Duplicate resource aws_db_subnet_group in variables.tf&lt;br&gt;
Resource block accidentally placed in variables.tf instead of main.tf&lt;br&gt;
Removed resource block from variables.tf — kept only variable declarations&lt;br&gt;
FIXED&lt;br&gt;
2&lt;br&gt;
Reference to undeclared resource aws_subnet.private_1&lt;br&gt;
Subnet resource named private but outputs.tf referenced it as private_1&lt;br&gt;
Renamed resource to private_1 and added private_2 in second AZ&lt;br&gt;
FIXED&lt;br&gt;
3&lt;br&gt;
Unsupported attribute vpc_id on module.networking&lt;br&gt;
networking/outputs.tf did not export vpc_id — module created VPC but never exported its ID&lt;br&gt;
Added output "vpc_id" { value = aws_vpc.main.id } to networking/outputs.tf&lt;br&gt;
FIXED&lt;br&gt;
4&lt;br&gt;
CIDR block 10.0.2.0/24 conflicts with existing subnet&lt;br&gt;
Partial apply had already created a subnet — re-apply tried to create same CIDR twice&lt;br&gt;
Changed private_1 subnet CIDR to 10.0.4.0/24 to avoid conflict&lt;br&gt;
FIXED&lt;br&gt;
5&lt;br&gt;
npm ERR! Missing script: "start"&lt;br&gt;
backend/package.json had no start script — npm start had nothing to run&lt;br&gt;
Added "start": "node src/server.js" to scripts in package.json&lt;br&gt;
FIXED&lt;br&gt;
6&lt;br&gt;
dpkg error overwriting /usr/include/node/common.gypi&lt;br&gt;
Old libnode-dev package conflicted with Node.js 18 installer&lt;br&gt;
sudo apt remove -y libnode-dev then re-run NodeSource installer&lt;br&gt;
FIXED&lt;br&gt;
7&lt;br&gt;
Shell stuck at &amp;gt; prompt when using cat heredoc&lt;br&gt;
Leading spaces before EOF marker prevented heredoc from closing&lt;br&gt;
Use nano instead of cat heredoc for all .env file creation&lt;br&gt;
FIXED&lt;/p&gt;

&lt;p&gt;*&lt;strong&gt;&lt;em&gt;Key Concepts Explained&lt;br&gt;
*&lt;/em&gt;&lt;/strong&gt;Three-Tier Architecture&lt;br&gt;
The web tier (EC2 with Nginx + Next.js) is publicly accessible. The app tier (Node.js backend) runs on the same EC2 but is only reachable through the web tier. The database tier (RDS) is in private subnets with no internet route; only the EC2 security group can connect on port 3306. Each tier has a different trust level and a different security boundary.&lt;br&gt;
Security Group Chaining&lt;br&gt;
The RDS security group allows MySQL (port 3306) only from the EC2 security group ID, not from any IP address. This means even with valid RDS credentials, you cannot connect from outside the VPC. Only the EC2 instance can reach the database. This is least-privilege networking applied at the AWS infrastructure level.&lt;br&gt;
Agentic AI in DevOps — Claude Code&lt;br&gt;
I used Claude Code throughout this deployment. For Terraform errors, I would type the error message, and Claude Code would read all module files simultaneously, trace the dependency chain, find the root cause, and apply the fix. This is the future of DevOps AI agents that can hold the entire codebase in context and diagnose cross-file issues instantly.&lt;br&gt;
PM2 — Production Process Manager&lt;br&gt;
If you start a Node.js app with node server.js and close your SSH session, the process dies. PM2 runs apps as background daemons. pm2 startup generates a system service that starts PM2 on boot. pm2 save persists the process list. pm2 list shows CPU, memory, and uptime for all managed processes.&lt;br&gt;
What I Learned — 4 Lessons That Changed How I Think&lt;br&gt;
Modules are a professional infrastructure. Flat Terraform files work for small projects. Modules scale. The separation of networking, security, and compute into independent modules means any engineer on the team can open one folder and understand exactly one thing. That clarity is worth the extra files.&lt;/p&gt;

&lt;p&gt;Agentic AI changes the debugging loop entirely. With Claude Code reading all files simultaneously, what took 30 minutes of manual cross-referencing took 7 minutes. The AI does not replace the engineer; it removes the tedious parts so the engineer can focus on architecture decisions.&lt;/p&gt;

&lt;p&gt;Read the files before trying to fix things. Almost every error I hit was answered by reading the relevant config file. The .env pointed to localhost. The package.json had no start script. The outputs.tf was missing vpc_id. The answer was always in the files read first.&lt;/p&gt;

&lt;p&gt;Infrastructure and deployment are separate concerns. Terraform provisions where the app runs. npm install, npm run build, and pm2 deploy the app itself. Nginx makes it production-ready. Understanding which layer handles which concern prevents debugging in the wrong place.&lt;/p&gt;

&lt;p&gt;****Final Thoughts&lt;/p&gt;

&lt;p&gt;This was the most complex assignment in the DevOps micro internship — and the most rewarding. I designed a real three-tier architecture, wrote production-grade Terraform modules, used Agentic AI as a genuine copilot, and got a full-stack application live on AWS.&lt;br&gt;
The Book Review App is live. The infrastructure is reproducible. Every error is documented. That is what production DevOps looks like.&lt;br&gt;
If you have questions, drop them in the comments. If this helped you, share it with someone learning DevOps. &lt;/p&gt;

&lt;h1&gt;
  
  
  terraform#aws#devops#nextjs#nodejs#mysql#claudecode#agenticai#cloudengineering#womenintech#learninginpublic
&lt;/h1&gt;

</description>
    </item>
    <item>
      <title>Mastering Agentic AI DevOps with Claude Code: My First Full Pipeline Deployment</title>
      <dc:creator>Ebelechukwu Lucy Okafor</dc:creator>
      <pubDate>Tue, 17 Mar 2026 10:45:20 +0000</pubDate>
      <link>https://dev.to/ebelechukwu_lucyokafor/mastering-agentic-ai-devops-with-claude-code-my-first-full-pipeline-deployment-3o6l</link>
      <guid>https://dev.to/ebelechukwu_lucyokafor/mastering-agentic-ai-devops-with-claude-code-my-first-full-pipeline-deployment-3o6l</guid>
      <description>&lt;p&gt;I recently completed the Ultimate Agentic AI DevOps Assignment using Claude Code, and I’m excited to share what I built, what I learned, and how agentic AI is changing the way we think about DevOps automation.&lt;br&gt;
This post walks through each task from setting up Skills to deploying a live site on AWS and includes lessons, screenshots, and a LinkedIn post to prove it all works!&lt;/p&gt;

&lt;p&gt;*&lt;strong&gt;&lt;em&gt;Task 1 — Set Up the Skill Files Correctly&lt;br&gt;
What I did:&lt;br&gt;
Created the .claude/skills/ folder structure with four subfolders: scaffold-terraform, tf-plan, tf-apply, and deploy.&lt;br&gt;
Downloaded and renamed the required files:&lt;br&gt;
scaffold-terraform/SKILL.md&lt;br&gt;
scaffold-terraform/template-spec.md&lt;br&gt;
tf-plan/SKILL.md&lt;br&gt;
tf-apply/SKILL.md&lt;br&gt;
deploy/SKILL.md&lt;br&gt;
Verified everything in VS Code to ensure proper placement and naming.&lt;br&gt;
*&lt;/em&gt;&lt;/strong&gt;What I learned:&lt;br&gt;
Claude Skills require precise folder and file naming to function correctly.&lt;br&gt;
The name: field in each SKILL.md must match the filename — otherwise, the agent won’t load.&lt;br&gt;
Issue faced:&lt;br&gt;
Initially placed template-spec.md outside its folder. Claude failed to scaffold.&lt;br&gt;
Fixed it by moving it into scaffold-terraform/ and re-running the skill.&lt;/p&gt;

&lt;p&gt;****Task 2 — Walk Through and Explain the Four Skills&lt;br&gt;
/scaffold-terraform&lt;br&gt;
What it does: Generates Terraform files based on a template spec.&lt;br&gt;
Tools used: Claude Code + Write access.&lt;br&gt;
Why Write access: It needs to create actual .tf files in the project.&lt;br&gt;
/tf-plan&lt;br&gt;
What it does: Runs terraform plan to preview infrastructure changes.&lt;br&gt;
Tools used: Terminal access only.&lt;br&gt;
Why Read-only: It doesn’t modify anything — just analyzes.&lt;br&gt;
/tf-apply&lt;/p&gt;

&lt;p&gt;****What it does: Applies the Terraform plan to provision resources.&lt;br&gt;
Tools used: Terminal + Write access.&lt;br&gt;
Why Write access: It creates infrastructure on AWS.&lt;br&gt;
/deploy&lt;/p&gt;

&lt;p&gt;****What it does: Syncs site files to S3 and triggers CloudFront invalidation.&lt;/p&gt;

&lt;p&gt;****Tools used: AWS CLI + Write access.&lt;br&gt;
Why Write access: It modifies cloud resources and pushes files.&lt;br&gt;
Key takeaway:&lt;br&gt;
“Needs conversation context? Use a Skill. Self-contained job? Use a Subagent.”&lt;br&gt;
DevOps example (Skill): /tf-plan needs context from previous steps.&lt;br&gt;
DevOps example (Subagent): security-auditor runs independently on Terraform files.&lt;/p&gt;

&lt;p&gt;*&lt;strong&gt;&lt;em&gt;Task 3 — Run the Full Pipeline: Scaffold → Plan → Apply → Deploy&lt;br&gt;
What I did:&lt;br&gt;
Ran /scaffold-terraform to generate Terraform files.&lt;br&gt;
Manually ran terraform init in the terminal.&lt;br&gt;
Executed /tf-plan to preview changes.&lt;br&gt;
Ran /tf-apply to provision AWS resources.&lt;br&gt;
Used /deploy to sync site files to S3 and invalidate CloudFront cache.&lt;br&gt;
*&lt;/em&gt;&lt;/strong&gt;What I learned:&lt;br&gt;
Claude Code can orchestrate a full deployment pipeline with minimal manual steps.&lt;br&gt;
Each skill builds on the previous one — like a relay race.&lt;br&gt;
*&lt;strong&gt;&lt;em&gt;Issue faced:&lt;br&gt;
CloudFront invalidation failed due to missing permissions.&lt;br&gt;
Solved by updating IAM role with CloudFront: CreateInvalidation permission.&lt;br&gt;
Success:&lt;br&gt;
My site is live at: &lt;a href="https://d123abcxyz.cloudfront.net" rel="noopener noreferrer"&gt;https://d123abcxyz.cloudfront.net&lt;/a&gt; &lt;br&gt;
*&lt;/em&gt;&lt;/strong&gt;Task 4 — LinkedIn Post (MANDATORY)&lt;/p&gt;

&lt;p&gt;****What I built:&lt;br&gt;
A live static site hosted on AWS using Terraform and Claude Code.&lt;/p&gt;

&lt;p&gt;****Skills used:&lt;br&gt;
/scaffold-terraform, /tf-plan, /tf-apply, /deploy&lt;/p&gt;

&lt;p&gt;****What I learned:&lt;br&gt;
Agentic AI can automate DevOps pipelines with precision and context-awareness.&lt;br&gt;
Screenshots included:&lt;br&gt;
Claude Code skill execution&lt;br&gt;
Live site in browser&lt;br&gt;
Terraform files in VS Code&lt;/p&gt;

&lt;p&gt;🔗 LinkedIn Post URL: &lt;a href="https://www.linkedin.com/posts/ebelechukwu-okafor_agenticai-mcp-devops-activity-7439480896090537984-PpIl?utm_source=social_share_send&amp;amp;utm_medium=member_desktop_web&amp;amp;rcm=ACoAABglo4IBjif-4VKcp2fiPwev8ImRV7LTaN0" rel="noopener noreferrer"&gt;https://www.linkedin.com/posts/ebelechukwu-okafor_agenticai-mcp-devops-activity-7439480896090537984-PpIl?utm_source=social_share_send&amp;amp;utm_medium=member_desktop_web&amp;amp;rcm=ACoAABglo4IBjif-4VKcp2fiPwev8ImRV7LTaN0&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;✅ Final Thoughts&lt;br&gt;
This assignment taught me how to:&lt;br&gt;
Structure and run Claude Skills correctly&lt;br&gt;
Understand tool permissions and context boundaries&lt;br&gt;
Deploy a full infrastructure pipeline using AI agents&lt;br&gt;
Agentic DevOps is real — and it’s powerful. &lt;/p&gt;

&lt;p&gt;I’m excited to keep building with Claude Code and explore more advanced workflows.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>aws</category>
      <category>devops</category>
      <category>terraform</category>
    </item>
    <item>
      <title>I Deployed a Production-Style Three-Tier Application on Azure (Next.js + Node.js + MySQL)</title>
      <dc:creator>Ebelechukwu Lucy Okafor</dc:creator>
      <pubDate>Sun, 08 Mar 2026 06:54:15 +0000</pubDate>
      <link>https://dev.to/ebelechukwu_lucyokafor/i-deployed-a-production-style-three-tier-application-on-azure-nextjs-nodejs-mysql-6a5</link>
      <guid>https://dev.to/ebelechukwu_lucyokafor/i-deployed-a-production-style-three-tier-application-on-azure-nextjs-nodejs-mysql-6a5</guid>
      <description>&lt;p&gt;A few days ago, I challenged myself to deploy a real-world cloud architecture instead of just running applications locally.&lt;br&gt;
*&lt;strong&gt;&lt;em&gt;The goal was simple:&lt;br&gt;
Deploy a Book Review Web Application on the cloud using a secure three-tier architecture.&lt;br&gt;
But like most cloud projects… it quickly became much more than that.&lt;br&gt;
*&lt;/em&gt;&lt;/strong&gt;I had to deal with:&lt;br&gt;
networking&lt;br&gt;
database authentication issues&lt;br&gt;
SSL enforcement&lt;br&gt;
load balancing&lt;br&gt;
debugging Node.js deployment problems&lt;br&gt;
By the end of the project, I had built a fully working cloud system on Microsoft Azure.&lt;br&gt;
This post walks through what I built, the problems I faced, and how I solved them.&lt;br&gt;
*&lt;strong&gt;&lt;em&gt;What I Built&lt;br&gt;
The application is a Book Review platform built with:&lt;br&gt;
Frontend&lt;br&gt;
Next.js&lt;br&gt;
Backend&lt;br&gt;
Node.js + Express&lt;br&gt;
Database&lt;br&gt;
MySQL&lt;br&gt;
*&lt;/em&gt;&lt;/strong&gt;Instead of deploying everything on one server, I used a ****Three-Tier Architecture.&lt;br&gt;
Internet&lt;br&gt;
   │&lt;br&gt;
Public Load Balancer&lt;br&gt;
   │&lt;br&gt;
Web Tier (Next.js + Nginx)&lt;br&gt;
   │&lt;br&gt;
Internal Load Balancer&lt;br&gt;
   │&lt;br&gt;
App Tier (Node.js API)&lt;br&gt;
   │&lt;br&gt;
Database Tier (Azure MySQL)&lt;/p&gt;

&lt;p&gt;Each tier runs independently, which is how most production systems are designed.&lt;/p&gt;

&lt;p&gt;☁️ ****Azure Services Used&lt;br&gt;
Here are the core services I used:&lt;br&gt;
Azure Virtual Network&lt;br&gt;
Azure Virtual Machines&lt;br&gt;
Azure Network Security Groups&lt;br&gt;
Azure Load Balancer&lt;br&gt;
Azure Database for MySQL Flexible Server&lt;br&gt;
This setup allowed me to design secure network segmentation between application layers.&lt;/p&gt;

&lt;p&gt;****Step 1 — Creating the Network Architecture&lt;br&gt;
First I created a Virtual Network with the CIDR block:&lt;br&gt;
10.0.0.0/16&lt;/p&gt;

&lt;p&gt;Then I divided it into six subnets.&lt;br&gt;
****Web Tier (Public)&lt;br&gt;
10.0.1.0/24&lt;br&gt;
10.0.2.0/24&lt;/p&gt;

&lt;p&gt;****App Tier (Private)&lt;br&gt;
10.0.3.0/24&lt;br&gt;
10.0.4.0/24&lt;/p&gt;

&lt;p&gt;****Database Tier (Private)&lt;br&gt;
10.0.5.0/24&lt;br&gt;
10.0.6.0/24&lt;/p&gt;

&lt;p&gt;****Why?&lt;br&gt;
Because databases should never be exposed to the public internet.&lt;/p&gt;

&lt;p&gt;*&lt;strong&gt;&lt;em&gt;Step 2 — Securing the Network&lt;br&gt;
I configured Network Security Groups.&lt;br&gt;
Rules were strict:&lt;br&gt;
*&lt;/em&gt;&lt;/strong&gt;Web Tier&lt;br&gt;
Allow HTTP (80)&lt;br&gt;
Allow HTTPS (443)&lt;br&gt;
Allow SSH (22)&lt;/p&gt;

&lt;p&gt;****App Tier&lt;br&gt;
Allow port 3001 from Web Tier only&lt;/p&gt;

&lt;p&gt;****Database Tier&lt;br&gt;
Allow port 3306 from App Tier only&lt;/p&gt;

&lt;p&gt;This ensured that:&lt;br&gt;
Users can only access the frontend&lt;br&gt;
backend servers remain private&lt;br&gt;
The database stays fully protected&lt;br&gt;
****Step 3 — Deploying the Backend&lt;br&gt;
The backend runs a Node.js API server.&lt;br&gt;
I installed dependencies:&lt;br&gt;
sudo apt update&lt;br&gt;
sudo apt install nodejs npm git&lt;/p&gt;

&lt;p&gt;Then I installed PM2 to manage the application.&lt;br&gt;
sudo npm install -g pm2&lt;/p&gt;

&lt;p&gt;****Start the backend:&lt;br&gt;
pm2 start server.js --name backend-api&lt;/p&gt;

&lt;p&gt;Save the process:&lt;br&gt;
pm2 save&lt;/p&gt;

&lt;p&gt;Enable startup on reboot:&lt;br&gt;
pm2 startup&lt;/p&gt;

&lt;p&gt;Now the backend runs automatically even if the server restarts.&lt;br&gt;
Step 4 — Setting Up the Database&lt;br&gt;
For the database layer, I used:&lt;br&gt;
Azure Database for MySQL Flexible Server&lt;br&gt;
****Important configurations:&lt;br&gt;
Private network access&lt;br&gt;
SSL required&lt;br&gt;
High availability (Zone redundant)&lt;br&gt;
Read replica&lt;br&gt;
This ensures the database is:&lt;br&gt;
secure&lt;br&gt;
scalable&lt;br&gt;
fault tolerant&lt;/p&gt;

&lt;p&gt;****Testing Database Connectivity&lt;br&gt;
From the App VM, I installed the MySQL client.&lt;br&gt;
sudo apt install mysql-client&lt;/p&gt;

&lt;p&gt;Then connected using SSL.&lt;br&gt;
mysql -h book-review-mysql.mysql.database.azure.com \&lt;br&gt;
-u username \&lt;br&gt;
-p \&lt;br&gt;
--ssl-mode=REQUIRED&lt;/p&gt;

&lt;p&gt;If everything works you should see:&lt;br&gt;
mysql&amp;gt;&lt;/p&gt;

&lt;p&gt;**** Problems I Faced (And How I Solved Them)&lt;br&gt;
❌ Problem 1 — MySQL Login Failed&lt;br&gt;
Error:&lt;br&gt;
ERROR 1045 (28000): Access denied for user&lt;/p&gt;

&lt;p&gt;Cause:&lt;br&gt;
Azure requires the username format:&lt;br&gt;
username@servername&lt;/p&gt;

&lt;p&gt;Solution:&lt;br&gt;
Ebelechukwu@book-review-mysql&lt;/p&gt;

&lt;p&gt;❌ *&lt;strong&gt;&lt;em&gt;Problem 2 — SSL Connection Required&lt;br&gt;
Azure MySQL enforces SSL.&lt;br&gt;
Solution:&lt;br&gt;
--ssl-mode=REQUIRED&lt;br&gt;
❌ *&lt;/em&gt;&lt;/strong&gt;Problem 3 — Node.js Would Not Start&lt;br&gt;
Error:&lt;br&gt;
npm error: Missing script: start&lt;/p&gt;

&lt;p&gt;****Solution:&lt;br&gt;
Instead of npm start, I used PM2.&lt;/p&gt;

&lt;p&gt;*&lt;strong&gt;&lt;em&gt;Final Result&lt;br&gt;
After everything was configured:&lt;br&gt;
✔ Backend connected to MySQL&lt;br&gt;
✔ Database schema created automatically&lt;br&gt;
✔ Sample books and reviews inserted&lt;br&gt;
✔ API running on port 3001&lt;br&gt;
*&lt;/em&gt;&lt;/strong&gt;Testing:&lt;br&gt;
curl &lt;a href="http://localhost:3001" rel="noopener noreferrer"&gt;http://localhost:3001&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;****Response:&lt;br&gt;
Book Review API is running&lt;/p&gt;

&lt;p&gt;****Success &lt;/p&gt;

&lt;p&gt;What This Project Taught Me&lt;br&gt;
This project helped me understand:&lt;br&gt;
real cloud networking&lt;br&gt;
secure infrastructure design&lt;br&gt;
debugging distributed systems&lt;br&gt;
production style deployments&lt;br&gt;
Most importantly…&lt;br&gt;
Cloud engineering is not just about launching servers.&lt;br&gt;
It’s about designing systems that are secure, scalable, and resilient.&lt;/p&gt;

&lt;p&gt;****What I Would Improve Next&lt;br&gt;
Next improvements I plan to add:&lt;br&gt;
Terraform infrastructure&lt;br&gt;
CI/CD pipelines&lt;br&gt;
containerization with Docker&lt;br&gt;
Kubernetes deployment&lt;/p&gt;

&lt;p&gt;****Final Thoughts&lt;br&gt;
If you're learning Cloud Engineering or DevOps, I highly recommend building projects like this.&lt;br&gt;
Nothing teaches cloud architecture faster than breaking things and fixing them.&lt;br&gt;
If you’ve built something similar, I’d love to hear about it.&lt;/p&gt;

&lt;p&gt;Tags for Dev.to&lt;br&gt;
Use these tags:&lt;/p&gt;

&lt;h1&gt;
  
  
  azure
&lt;/h1&gt;

&lt;h1&gt;
  
  
  cloud
&lt;/h1&gt;

&lt;h1&gt;
  
  
  devops
&lt;/h1&gt;

&lt;h1&gt;
  
  
  nodejs
&lt;/h1&gt;

&lt;h1&gt;
  
  
  mysql
&lt;/h1&gt;

&lt;h1&gt;
  
  
  cloudarchitecture
&lt;/h1&gt;

</description>
      <category>architecture</category>
      <category>azure</category>
      <category>nextjs</category>
      <category>node</category>
    </item>
    <item>
      <title>Deploy Book Review App (Three-Tier Architecture) on AWS</title>
      <dc:creator>Ebelechukwu Lucy Okafor</dc:creator>
      <pubDate>Fri, 27 Feb 2026 21:12:43 +0000</pubDate>
      <link>https://dev.to/ebelechukwu_lucyokafor/deploy-book-review-app-three-tier-architecture-on-aws-6ha</link>
      <guid>https://dev.to/ebelechukwu_lucyokafor/deploy-book-review-app-three-tier-architecture-on-aws-6ha</guid>
      <description>&lt;p&gt;&lt;strong&gt;&lt;em&gt;Deploying a full-stack app in the cloud sounds glamorous—until you’re debugging a &lt;code&gt;403 Forbidden&lt;/code&gt; error at 2 a.m. Here’s how I survived my DevOps capstone project—and what you can learn from it.&lt;/em&gt;&lt;br&gt;
**&lt;/strong&gt; What Is This Project?&lt;br&gt;
I recently completed &lt;strong&gt;Assignment 4&lt;/strong&gt; of the &lt;em&gt;DevOps Zero to Hero&lt;/em&gt; course by Pravin Mishra, a hands-on challenge to deploy the &lt;strong&gt;Book Review App&lt;/strong&gt; on AWS using &lt;strong&gt;real-world, production-grade architecture&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The app is simple in concept:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Browse books&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Read reviews&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Log in and write your own&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But under the hood? It’s a &lt;strong&gt;three-tier masterpiece&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontend&lt;/strong&gt;: Next.js (React with server-side rendering)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend&lt;/strong&gt;: Node.js + Express (REST API)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database&lt;/strong&gt;: MySQL (via Amazon RDS)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the goal wasn’t just to “make it work.” It was to build it &lt;strong&gt;the way real companies do&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ High availability&lt;/li&gt;
&lt;li&gt;✅ Network isolation&lt;/li&gt;
&lt;li&gt;✅ Auto-scaling&lt;/li&gt;
&lt;li&gt;✅ Secure traffic flow
No pressure, right?
*&lt;strong&gt;&lt;em&gt;The Architecture: Like Building a Castle
Here’s what I built:
Internet
↓
Public Application Load Balancer (ALB)
↓
Web Tier (Next.js on EC2 in public subnets)
↓
Internal ALB (private, not internet-facing)
↓
App Tier (Node.js on EC2 in private subnets)
↓
Database Tier (MySQL RDS Multi-AZ + Read Replica in private subnets)
*&lt;/em&gt;&lt;/strong&gt;Key Security Principles:&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No public access to backend or database&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traffic flows only one way&lt;/strong&gt;: Internet → Web → App → DB&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security Groups act like bouncers&lt;/strong&gt;:

&lt;ul&gt;
&lt;li&gt;ALB can talk to Web EC2
&lt;/li&gt;
&lt;li&gt;Web EC2 can talk to Internal ALB
&lt;/li&gt;
&lt;li&gt;Internal ALB can talk to App EC2
&lt;/li&gt;
&lt;li&gt;App EC2 can talk to RDS
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing else gets in.&lt;/strong&gt;
This isn’t just “best practice”—it’s how you prevent breaches.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;****Step-by-Step: How I Did It&lt;/p&gt;

&lt;h3&gt;
  
  
  1. &lt;strong&gt;Lay the Foundation: VPC &amp;amp; Subnets&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;I created a custom VPC (&lt;code&gt;10.0.0.0/16&lt;/code&gt;) with &lt;strong&gt;6 subnets across 2 Availability Zones&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2 public&lt;/strong&gt; (for Web EC2 + Public ALB)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2 private&lt;/strong&gt; (for App EC2 + Internal ALB)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2 private&lt;/strong&gt; (for RDS)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I ****added:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An &lt;strong&gt;Internet Gateway&lt;/strong&gt; (for public access)&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;NAT Gateway&lt;/strong&gt; (so private instances can download packages)&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Pro tip: Always enable &lt;strong&gt;DNS hostnames&lt;/strong&gt; in your VPC—Next.js and Node.js need it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ol&gt;
&lt;li&gt;*&lt;em&gt;**Lock It Down: Security Groups&lt;/em&gt;*
I created 5 security groups that &lt;strong&gt;only trust each other&lt;/strong&gt;:

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;public-alb-sg&lt;/code&gt; → allows HTTP from the world&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;web-ec2-sg&lt;/code&gt; → allows HTTP &lt;strong&gt;only from &lt;code&gt;public-alb-sg&lt;/code&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;internal-alb-sg&lt;/code&gt; → allows HTTP &lt;strong&gt;only from &lt;code&gt;web-ec2-sg&lt;/code&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;app-ec2-sg&lt;/code&gt; → allows port 3001 &lt;strong&gt;only from &lt;code&gt;internal-alb-sg&lt;/code&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;db-sg&lt;/code&gt; → allows MySQL &lt;strong&gt;only from &lt;code&gt;app-ec2-sg&lt;/code&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This “chain of trust” is the backbone of secure cloud architecture.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;*&lt;em&gt;**Deploy the Database: RDS Done Right&lt;/em&gt;*
I launched an &lt;strong&gt;RDS MySQL instance&lt;/strong&gt; with:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Multi-AZ&lt;/strong&gt; (automatic failover if one zone dies)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read Replica&lt;/strong&gt; (for scaling reads later)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No public access&lt;/strong&gt; (critical!)&lt;/li&gt;
&lt;li&gt;Initial database name: &lt;code&gt;bookreview&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;*&lt;strong&gt;*Mistake I made: I forgot to actually **create the &lt;code&gt;bookreview&lt;/code&gt; database&lt;/strong&gt; during setup. The app crashed with &lt;code&gt;Unknown database 'bookreview'&lt;/code&gt;.&lt;br&gt;&lt;br&gt;
*&lt;em&gt;**Fix&lt;/em&gt;*: Connect via &lt;code&gt;mysql&lt;/code&gt; CLI and run &lt;code&gt;CREATE DATABASE bookreview;&lt;/code&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;*&lt;em&gt;**Deploy the Backend (Node.js)&lt;/em&gt;*
On a private EC2 instance:
&lt;/li&gt;
&lt;/ol&gt;

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

&lt;/span&gt;Then I created &lt;span class="sb"&gt;`&lt;/span&gt;.env&lt;span class="sb"&gt;`&lt;/span&gt;:
&lt;span class="nb"&gt;env
&lt;/span&gt;&lt;span class="nv"&gt;DB_HOST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-rds-endpoint.us-east-1.rds.amazonaws.com
&lt;span class="nv"&gt;DB_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;bookreview
&lt;span class="nv"&gt;DB_USER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;admin
&lt;span class="nv"&gt;DB_PASS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-password
&lt;span class="nv"&gt;PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3001
&lt;span class="nv"&gt;ALLOWED_ORIGINS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://your-public-alb-dns

&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;  Critical: &lt;span class="sb"&gt;`&lt;/span&gt;ALLOWED_ORIGINS&lt;span class="sb"&gt;`&lt;/span&gt; must match your &lt;span class="k"&gt;**&lt;/span&gt;Public ALB DNS exactly&lt;span class="k"&gt;**&lt;/span&gt;—no typos, no truncation.

I used &lt;span class="sb"&gt;`&lt;/span&gt;pm2&lt;span class="sb"&gt;`&lt;/span&gt; to keep the app running:
bash
&lt;span class="nb"&gt;sudo &lt;/span&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; pm2
pm2 start src/server.js &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"backend"&lt;/span&gt;
pm2 startup  &lt;span class="c"&gt;# auto-start on reboot&lt;/span&gt;

5. &lt;span class="k"&gt;****&lt;/span&gt;Deploy the Frontend &lt;span class="o"&gt;(&lt;/span&gt;Next.js&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="k"&gt;**&lt;/span&gt;
On a public EC2 instance:
bash
&lt;span class="nb"&gt;cd &lt;/span&gt;frontend
npm &lt;span class="nb"&gt;install
&lt;/span&gt;npm run build

Then I created &lt;span class="sb"&gt;`&lt;/span&gt;.env&lt;span class="sb"&gt;`&lt;/span&gt;:
&lt;span class="nb"&gt;env
&lt;/span&gt;&lt;span class="nv"&gt;NEXT_PUBLIC_API_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/api

This tells the frontend to send API requests to &lt;span class="sb"&gt;`&lt;/span&gt;/api&lt;span class="sb"&gt;`&lt;/span&gt;, which Nginx will proxy to the backend.

I ran it with:
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
bash&lt;br&gt;
pm2 start npm --name "frontend" -- run start&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;*&lt;em&gt;**Configure Nginx: The Glue That Holds It Together&lt;/em&gt;*
My &lt;code&gt;/etc/nginx/sites-available/default&lt;/code&gt;:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://localhost:3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;# Next.js&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;rewrite&lt;/span&gt; &lt;span class="s"&gt;^/api/(.*)&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt; &lt;span class="s"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://internal-alb-dns.us-east-1.elb.amazonaws.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;The magic:  &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;User visits &lt;code&gt;http://public-alb&lt;/code&gt; → sees Next.js
&lt;/li&gt;
&lt;li&gt;When they click “Login,” the frontend calls &lt;code&gt;/api/login&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Nginx forwards to &lt;strong&gt;Internal ALB&lt;/strong&gt; → &lt;strong&gt;App EC2&lt;/strong&gt; → &lt;strong&gt;RDS&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;ol&gt;
&lt;li&gt;*&lt;em&gt;**Add Load Balancers &amp;amp; Auto Scaling&lt;/em&gt;*

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Public ALB&lt;/strong&gt;: Routes traffic to Web EC2 instances&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal ALB&lt;/strong&gt;: Routes &lt;code&gt;/api&lt;/code&gt; to App EC2 instances&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto Scaling Groups&lt;/strong&gt;: 2+ instances per tier, across 2 AZs&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If one instance dies? AWS replaces it automatically.&lt;/p&gt;

&lt;p&gt;**** The Struggle Is Real: Debugging Nightmares&lt;/p&gt;

&lt;p&gt;*&lt;strong&gt;*Problem 1: &lt;code&gt;403 Forbidden&lt;/code&gt; on ALB Health Checks&lt;br&gt;
**Symptom&lt;/strong&gt;: ALB shows “Unhealthy” targets.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Root cause&lt;/strong&gt;:  &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;My &lt;code&gt;.env&lt;/code&gt; had &lt;code&gt;ALLOWED_ORIGINS=http://alb...amazonaws&amp;gt;&lt;/code&gt; (truncated!)
&lt;/li&gt;
&lt;li&gt;Backend blocked all requests due to CORS mismatch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;*&lt;em&gt;**Fix&lt;/em&gt;*:  &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Corrected &lt;code&gt;.env&lt;/code&gt; to full DNS: &lt;code&gt;...amazonaws.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Added a &lt;code&gt;/health&lt;/code&gt; endpoint for ALB health checks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;*&lt;strong&gt;*Problem 2: “Unknown database ‘bookreview’”&lt;br&gt;
**Symptom&lt;/strong&gt;: Backend crashes on startup.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Cause&lt;/strong&gt;: RDS doesn’t auto-create databases unless you specify it.&lt;br&gt;&lt;br&gt;
*&lt;em&gt;**Fix&lt;/em&gt;*:  &lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
sql
mysql -h &amp;lt;RDS&amp;gt; -u admin -p
CREATE DATABASE bookreview;

****Problem 3: Nginx Serving Blank Page
****Symptom**: `curl localhost` returns 403.  
****Cause**: I was trying to serve static files from `/var/www/html`—but Next.js needs to run as a server.  
**Fix**: Switched to **reverse proxy** mode (`proxy_pass http://localhost:3000`).

****Lessons Learned (The Hard Way)

1. **Infrastructure is code—and configuration is state.**  
   One missing character breaks everything.
2. **Test each layer independently.**  
   - Can you `curl localhost:3000`?  
   - Can you `curl localhost:3001/api/books` from the backend?  
   - Does `mysql -h ...` connect?
3. **ALB health checks are strict.**  
   Use a dedicated `/health` route that always returns `200`.

4. **Security Groups are your first line of defense.**  
   If traffic isn’t flowing, check SG rules before touching code.

5. **Real DevOps isn’t about perfection—it’s about persistence.**  
   I failed 20 times. On the 21st, it worked.

****Final Result



1. 
A fully functional Book Review App  

2. 
 Survives instance termination  

3. 
 Scales across Availability Zones  

4. 
Secure, isolated, and production-ready  

And most importantly: **I understand how it all fits together.**

****Want to Try It Yourself?

**GitHub Repo**:  
[https://github.com/pravinmishraaws/book-review-app](https://github.com/pravinmishraaws/book-review-app)

****What You’ll Need**:
- AWS account (Free Tier eligible)
- Basic Linux &amp;amp; Git knowledge
- Patience (and maybe coffee)
Follow the READMEs in `/frontend` and `/backend`, and use this blog as your troubleshooting guide.
****Final Thought
Deploying this app didn’t just teach me AWS—it taught me **how to think like an engineer**.  
Not “Does it work?” but **“Why does it work—and what happens when it doesn’t?”**
That’s the real DevOps mindset.
Now go build something awesome. 
*Like this post? Give it a share, share it with a fellow learner, or comment below with your own deployment war stories!*  
#DevOps #AWS #CloudEngineering #FullStack #NextJS #NodeJS #LearningInPublic #RealWorldDev

![ ](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/brlx9i6cs2k0prpa2b5c.png)****
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>architecture</category>
      <category>aws</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Executing a Full Scrum Sprint with Jira: From Backlog Refinement to Live Deployment</title>
      <dc:creator>Ebelechukwu Lucy Okafor</dc:creator>
      <pubDate>Sat, 07 Feb 2026 05:52:48 +0000</pubDate>
      <link>https://dev.to/ebelechukwu_lucyokafor/executing-a-full-scrum-sprint-with-jira-from-backlog-refinement-to-live-deployment-880</link>
      <guid>https://dev.to/ebelechukwu_lucyokafor/executing-a-full-scrum-sprint-with-jira-from-backlog-refinement-to-live-deployment-880</guid>
      <description>&lt;p&gt;This week, I executed a complete Scrum Sprint lifecycle using Jira Cloud (team-managed project) — covering backlog refinement, sprint planning, delivery, deployment, reporting, and retrospective.&lt;br&gt;
The focus was not on theory, but on end-to-end execution: planning work, shipping a production change, and providing verifiable proof.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project Context&lt;/strong&gt;&lt;br&gt;
Project: Gotto Job (UI-only enhancements)&lt;br&gt;
Methodology: Scrum (Team-managed, Solo execution)&lt;br&gt;
Core Tools: Jira Cloud, Git, GitHub, EC2 (Ubuntu), Nginx&lt;br&gt;
Scope: Small, high-impact UI improvements delivered incrementally&lt;br&gt;
Scrum Roles &amp;amp; Ownership (Solo Execution)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;To mirror real delivery accountability, I assumed all Scrum roles:&lt;/strong&gt;&lt;br&gt;
Product Owner: Prioritized UI changes with the highest user-perceived value&lt;br&gt;
Scrum Master: Enforced Scrum cadence (refinement → planning → sprint → retro)&lt;br&gt;
Dev Lead: Implemented UI changes with clear acceptance criteria&lt;br&gt;
DevOps Lead: Deployed changes to a live environment and validated delivery&lt;br&gt;
Outcome: Clear separation of concerns, even in solo mode.&lt;br&gt;
Backlog Refinement &amp;amp; Estimation&lt;br&gt;
I created a structured product backlog under a single Epic:&lt;br&gt;
Epic: Improve Gotto Job UI discoverability &amp;amp; trust&lt;br&gt;
6+ user stories&lt;br&gt;
Clear acceptance criteria&lt;br&gt;
Fibonacci estimation (1/2/3)&lt;br&gt;
Ranked by business value&lt;br&gt;
This ensured the sprint scope was predictable and executable.&lt;/p&gt;

&lt;p&gt;Sprint Planning (Sprint 1)&lt;br&gt;
Defined a clear Sprint Goal&lt;br&gt;
Selected 3–4 small, deliverable stories&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Broke stories into actionable subtasks:&lt;/strong&gt;&lt;br&gt;
Build&lt;br&gt;
Verify&lt;br&gt;
Deploy&lt;br&gt;
Screenshot (proof)&lt;br&gt;
&lt;strong&gt;Focus:&lt;/strong&gt; Deliver visible value, not partial work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delivery &amp;amp; Deployment (DevOps Execution)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One story from Sprint 1 was fully implemented and shipped:&lt;br&gt;
Code changes committed with meaningful Git messages&lt;br&gt;
Repository deployed on EC2 using Nginx static hosting&lt;br&gt;
Live URL verified&lt;br&gt;
Jira issues transitioned across the workflow to Done&lt;br&gt;
Deployment proof attached&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This followed a real DevOps loop:&lt;/strong&gt;&lt;br&gt;
Plan → Build → Ship → Verify → Document&lt;/p&gt;

&lt;p&gt;Reporting &amp;amp; Transparency&lt;br&gt;
Enabled and reviewed the Burndown Chart&lt;br&gt;
Used Jira reports to track sprint health and scope&lt;br&gt;
Ensured delivery visibility at all stages&lt;br&gt;
Sprint Retrospective&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A structured retro was documented, covering:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What went well&lt;br&gt;
What to improve&lt;br&gt;
Scrum pillar observed: Transparency&lt;br&gt;
Scrum value demonstrated: Commitment&lt;br&gt;
This reinforced continuous improvement, not just delivery.&lt;br&gt;
Why This Matters (DevOps Perspective)&lt;br&gt;
This sprint demonstrated practical capability in:&lt;br&gt;
Agile execution with Jira (not checkbox usage)&lt;br&gt;
Backlog refinement and estimation discipline&lt;br&gt;
Shipping small, safe increments&lt;br&gt;
Infrastructure + deployment ownership&lt;br&gt;
Evidence-based delivery (live URL + Jira + GitHub)&lt;br&gt;
Links&lt;br&gt;
Live Deployment: &lt;br&gt;
GitHub Repository: &lt;a href="https://github.com/Lucycloud2024/GOTTO-JOB-DEMO" rel="noopener noreferrer"&gt;https://github.com/Lucycloud2024/GOTTO-JOB-DEMO&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Final Note&lt;/strong&gt;&lt;br&gt;
This assignment strengthened my ability to operate in a real delivery environment, where planning, execution, deployment, and visibility are equally important.&lt;br&gt;
I’m now comfortable contributing to teams that value small batches, fast feedback, and measurable delivery.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>management</category>
      <category>softwaredevelopment</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
