<?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: Sohana Akbar</title>
    <description>The latest articles on DEV Community by Sohana Akbar (@sohanaakbar7).</description>
    <link>https://dev.to/sohanaakbar7</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%2F3878706%2F860b91d5-b418-4713-8f8e-8f11130c29e7.jpeg</url>
      <title>DEV Community: Sohana Akbar</title>
      <link>https://dev.to/sohanaakbar7</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sohanaakbar7"/>
    <language>en</language>
    <item>
      <title>Why We Ditched npm install in Production (And You Should Too)</title>
      <dc:creator>Sohana Akbar</dc:creator>
      <pubDate>Tue, 28 Jul 2026 09:39:19 +0000</pubDate>
      <link>https://dev.to/sohanaakbar7/why-we-ditched-npm-install-in-production-and-you-should-too-59o5</link>
      <guid>https://dev.to/sohanaakbar7/why-we-ditched-npm-install-in-production-and-you-should-too-59o5</guid>
      <description>&lt;p&gt;Two years ago, we made a change that cut our deployment failures by 80%.&lt;/p&gt;

&lt;p&gt;It wasn't a fancy new architecture. It wasn't a microservices overhaul. It was a simple switch from npm install to npm ci --omit=dev in our production Docker containers.&lt;/p&gt;

&lt;p&gt;That single change saved us from 4 dependency-related incidents in the first year alone. Here's why it matters, how it works, and why your team should make the switch today.&lt;/p&gt;

&lt;p&gt;The Problem: npm install is a Liability in Production&lt;br&gt;
Let's be honest—npm install is great for local development. It's flexible, forgiving, and gets the job done. But in production? It's a ticking time bomb.&lt;/p&gt;

&lt;p&gt;Here's what can (and did) go wrong:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Package Lock That Wasn't
npm install doesn't respect your package-lock.json the way you think it does. If your lock file is out of sync with your package.json, npm will update dependencies behind your back.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We once had a patch release of a logging library introduce a breaking change that crashed our entire API fleet. The lock file said one version. The container installed another.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The "It Works On My Machine" Nightmare
Local installs, CI installs, and production installs can all yield different dependency trees. Different npm versions, different registry responses, different caching behavior—it's chaos.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We spent 3 days debugging a staging vs. production discrepancy that turned out to be a transitive dependency with a slightly different semantic versioning resolution.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Time Tax
npm install is slow. It checks versions, resolves conflicts, and installs development dependencies you'll never use in production. Every minute spent installing is a minute your deployment is vulnerable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Solution: npm ci + --omit=dev&lt;br&gt;
The npm team gave us a production-ready alternative years ago, yet so many teams still sleep on it.&lt;/p&gt;

&lt;p&gt;What npm ci Does&lt;br&gt;
Strictly respects package-lock.json: If the lock file doesn't match package.json, npm throws an error and fails the build. No silent updates.&lt;/p&gt;

&lt;p&gt;Installs from the lock file only: It skips dependency resolution entirely, making it significantly faster.&lt;/p&gt;

&lt;p&gt;Removes node_modules first: Guarantees a clean, reproducible installation every time.&lt;/p&gt;

&lt;p&gt;What --omit=dev Does&lt;br&gt;
Excludes devDependencies: Those testing frameworks, linters, and build tools? They don't belong in production.&lt;/p&gt;

&lt;p&gt;Prevents accidental imports: If a developer mistakenly imports a dev-only package in production code, the error surfaces immediately.&lt;/p&gt;

&lt;p&gt;The Winning Combo&lt;br&gt;
dockerfile&lt;/p&gt;

&lt;h1&gt;
  
  
  Before (Dangerous)
&lt;/h1&gt;

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

&lt;h1&gt;
  
  
  After (Production-Ready)
&lt;/h1&gt;

&lt;p&gt;RUN npm ci --omit=dev&lt;br&gt;
That's it. One line change. Massive impact.&lt;/p&gt;

&lt;p&gt;The Numbers: 4 Incidents We Avoided&lt;br&gt;
Here's exactly what we escaped by making the switch:&lt;/p&gt;

&lt;p&gt;Incident #1: The Patch Release Apocalypse&lt;br&gt;
A minor patch update to axios changed how it handled certain headers. npm install pulled it in automatically. The new behavior broke our auth middleware. Downtime: 47 minutes.&lt;/p&gt;

&lt;p&gt;With npm ci? The lock file would have pinned the exact version. The patch wouldn't have been installed until we intentionally updated it in a PR.&lt;/p&gt;

&lt;p&gt;Incident #2: The Dev Dependency Security Scare&lt;br&gt;
A popular dev tool with a critical CVE was installed as a devDependency but also pulled in production code through an ill-advised import. Our security scanner flagged it. We scrambled to remove it.&lt;/p&gt;

&lt;p&gt;With --omit=dev? It would never have been installed in production. No flag, no panic, no fire drill.&lt;/p&gt;

&lt;p&gt;Incident #3: The Build Cache Corruption&lt;br&gt;
Our CI pipeline cached node_modules to speed up builds. A corrupted cache interacted with a fresh npm install to produce a broken build. The fix required invalidating caches and redeploying. Time lost: 2 hours.&lt;/p&gt;

&lt;p&gt;With npm ci? It removes node_modules before installing, guaranteeing a clean state every time. No cache contamination.&lt;/p&gt;

&lt;p&gt;Incident #4: The Inconsistent Staging&lt;br&gt;
Staging and production used different npm versions. The staging install worked. Production install failed. We wasted a full sprint day diagnosing a mismatch that boiled down to a peerDependency resolution difference.&lt;/p&gt;

&lt;p&gt;With npm ci? The lock file ensures identical trees regardless of npm version. No more "but it worked in staging."&lt;/p&gt;

&lt;p&gt;Why Teams Resist (And Why They're Wrong)&lt;br&gt;
I've heard every objection:&lt;/p&gt;

&lt;p&gt;"But npm install is more flexible!"&lt;br&gt;
Flexibility is a bug in production. We want predictability, not flexibility.&lt;/p&gt;

&lt;p&gt;"We'll update the lock file anyway."&lt;br&gt;
You will. But npm ci forces the issue. It doesn't let you drift.&lt;/p&gt;

&lt;p&gt;"It's just one more thing to remember."&lt;br&gt;
Put it in your Dockerfile. Put it in your CI scripts. Make it the default. You shouldn't have to remember—it should be automatic.&lt;/p&gt;

&lt;p&gt;How to Migrate Today&lt;br&gt;
Step 1: Update Your Dockerfile&lt;br&gt;
dockerfile&lt;br&gt;
FROM node:18-alpine&lt;/p&gt;

&lt;p&gt;WORKDIR /app&lt;/p&gt;

&lt;p&gt;COPY package*.json ./&lt;br&gt;
COPY package-lock.json ./&lt;/p&gt;

&lt;h1&gt;
  
  
  The magic line
&lt;/h1&gt;

&lt;p&gt;RUN npm ci --omit=dev&lt;/p&gt;

&lt;p&gt;COPY . .&lt;/p&gt;

&lt;p&gt;CMD ["node", "server.js"]&lt;br&gt;
Step 2: Update Your CI/CD Pipeline&lt;br&gt;
yaml&lt;/p&gt;

&lt;h1&gt;
  
  
  GitHub Actions example
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;name: Install dependencies
run: npm ci --omit=dev
Step 3: Audit Your devDependencies
Make sure nothing you actually need in production is accidentally flagged as a dev dependency. Move critical packages to dependencies.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Step 4: Add a Precommit Hook&lt;br&gt;
json&lt;br&gt;
{&lt;br&gt;
  "scripts": {&lt;br&gt;
    "precommit": "npm ci --omit=dev"&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
This catches discrepancies before they ever reach production.&lt;/p&gt;

&lt;p&gt;The Results 2 Years Later&lt;br&gt;
Zero dependency-related incidents in the last 18 months.&lt;/p&gt;

&lt;p&gt;Faster builds: Our production containers now install in ~40 seconds instead of ~90 seconds.&lt;/p&gt;

&lt;p&gt;Peace of mind: We know exactly what version of every package is running in production.&lt;/p&gt;

&lt;p&gt;Audit confidence: Security scans are clean because dev-only packages aren't installed.&lt;/p&gt;

&lt;p&gt;Don't Wait for an Incident&lt;br&gt;
If you're still using npm install in production containers, you're running on borrowed time. It's not if something will break—it's when.&lt;/p&gt;

&lt;p&gt;Make the switch to npm ci --omit=dev. It's free. It's simple. And it might just save your next deployment.&lt;/p&gt;

&lt;p&gt;Have you made the switch? Still hesitating? Drop your questions in the comments—let's talk about production npm practices&lt;/p&gt;

</description>
    </item>
    <item>
      <title>From 1.2GB to 118MB: How We Slashed Our Docker Image Size and EKS Pull Time to Under 8 Seconds</title>
      <dc:creator>Sohana Akbar</dc:creator>
      <pubDate>Sun, 26 Jul 2026 13:20:12 +0000</pubDate>
      <link>https://dev.to/sohanaakbar7/from-12gb-to-118mb-how-we-slashed-our-docker-image-size-and-eks-pull-time-to-under-8-seconds-44l1</link>
      <guid>https://dev.to/sohanaakbar7/from-12gb-to-118mb-how-we-slashed-our-docker-image-size-and-eks-pull-time-to-under-8-seconds-44l1</guid>
      <description>&lt;p&gt;The moment our EKS cluster stopped sweating&lt;/p&gt;

&lt;p&gt;The Problem: When Your Container Image Is Bigger Than Your Application&lt;br&gt;
It started with a simple observation during a deployment review. Our team was staring at deployment logs, watching the same 47-second pattern repeat:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
Pulling image: 45.3s&lt;br&gt;
Starting container: 1.2s&lt;br&gt;
Forty-five seconds of pull time. Every. Single. Deployment.&lt;/p&gt;

&lt;p&gt;Our Docker image had ballooned to 1.2GB — and honestly, we had no one to blame but ourselves. We were shipping node_modules like they were going out of style, including every dev dependency, build tool, and a full test suite in our production images.&lt;/p&gt;

&lt;p&gt;The worst part? This wasn't just a "wait a bit longer" problem. It was a:&lt;/p&gt;

&lt;p&gt;🚨 Security issue — larger attack surface with unnecessary packages&lt;/p&gt;

&lt;p&gt;💸 Cost problem — more storage in ECR and slower scaling events&lt;/p&gt;

&lt;p&gt;😤 Developer experience killer — impatient engineers waiting for deployments&lt;/p&gt;

&lt;p&gt;The Goal: Fast Pulls, Lean Images&lt;br&gt;
We set three clear objectives:&lt;/p&gt;

&lt;p&gt;Image size under 200MB&lt;/p&gt;

&lt;p&gt;ECR pull time under 10 seconds in EKS&lt;/p&gt;

&lt;p&gt;Zero compromises on runtime performance or security&lt;/p&gt;

&lt;p&gt;Here's exactly how we got there.&lt;/p&gt;

&lt;p&gt;Step 1: Switch to pnpm (Game Changer)&lt;br&gt;
We were using npm, and our node_modules folder was thicc. Around 800MB of dependencies, including 400MB of dev dependencies we didn't need in production.&lt;/p&gt;

&lt;p&gt;We switched to pnpm for three killer benefits:&lt;/p&gt;

&lt;p&gt;🔥 Hard Links = Disk Space Savings&lt;br&gt;
pnpm stores packages globally and hard-links them into projects. Our node_modules went from 800MB to 180MB in the build stage.&lt;/p&gt;

&lt;p&gt;🚀 Faster Installation&lt;br&gt;
No more re-downloading packages on every build. pnpm's content-addressable storage means if a package version exists, it's reused.&lt;/p&gt;

&lt;p&gt;⚡️ Native Workspace Support&lt;br&gt;
Our monorepo setup became significantly simpler.&lt;/p&gt;

&lt;p&gt;Here's what our Dockerfile looked like before:&lt;/p&gt;

&lt;p&gt;dockerfile&lt;br&gt;
FROM node:18-alpine AS builder&lt;br&gt;
WORKDIR /app&lt;br&gt;
COPY package*.json ./&lt;br&gt;
RUN npm ci --production=false  # 😬 brings ALL dependencies&lt;br&gt;
COPY . .&lt;br&gt;
RUN npm run build&lt;br&gt;
And after:&lt;/p&gt;

&lt;p&gt;dockerfile&lt;br&gt;
FROM node:18-alpine AS builder&lt;br&gt;
WORKDIR /app&lt;br&gt;
COPY package.json pnpm-lock.yaml ./&lt;br&gt;
RUN npm install -g pnpm &amp;amp;&amp;amp; pnpm install --frozen-lockfile&lt;br&gt;
COPY . .&lt;br&gt;
RUN pnpm run build&lt;br&gt;
RUN pnpm prune --prod  # 🎯 strip dev dependencies&lt;br&gt;
Step 2: Multi-Stage Builds Done Right&lt;br&gt;
We were already using multi-stage builds, but poorly. Our final stage was copying everything from the builder stage — including source code, tests, and build artifacts we didn't need.&lt;/p&gt;

&lt;p&gt;Before: Copying Too Much&lt;br&gt;
dockerfile&lt;br&gt;
FROM node:18-alpine AS runner&lt;br&gt;
WORKDIR /app&lt;br&gt;
COPY --from=builder /app .  # 🚨 copies EVERYTHING&lt;br&gt;
CMD ["node", "dist/main.js"]&lt;br&gt;
After: Selective Copying&lt;br&gt;
dockerfile&lt;br&gt;
FROM node:18-alpine AS runner&lt;br&gt;
WORKDIR /app&lt;br&gt;
ENV NODE_ENV=production&lt;/p&gt;

&lt;h1&gt;
  
  
  Copy only what we need
&lt;/h1&gt;

&lt;p&gt;COPY --from=builder /app/dist ./dist&lt;br&gt;
COPY --from=builder /app/node_modules ./node_modules&lt;br&gt;
COPY --from=builder /app/package.json ./&lt;/p&gt;

&lt;p&gt;RUN addgroup --system --gid 1001 nodejs &amp;amp;&amp;amp; \&lt;br&gt;
    adduser --system --uid 1001 nodejs&lt;/p&gt;

&lt;p&gt;USER nodejs&lt;br&gt;
CMD ["node", "dist/main.js"]&lt;br&gt;
This single change cut our image size by 60%.&lt;/p&gt;

&lt;p&gt;Step 3: Alpine Base Image + Build Optimizations&lt;br&gt;
We were already using Alpine, but we weren't taking full advantage of it. We added:&lt;/p&gt;

&lt;p&gt;📦 .dockerignore that actually works&lt;br&gt;
text&lt;br&gt;
node_modules&lt;br&gt;
.git&lt;br&gt;
&lt;em&gt;.md&lt;br&gt;
.env&lt;/em&gt;&lt;br&gt;
.DS_Store&lt;br&gt;
coverage&lt;br&gt;
.nyc_output&lt;br&gt;
logs&lt;br&gt;
tmp&lt;br&gt;
🧹 Layer Cleanup&lt;br&gt;
When installing native dependencies, we made sure to clean apt caches:&lt;/p&gt;

&lt;p&gt;dockerfile&lt;br&gt;
RUN apk add --no-cache --virtual .build-deps python3 make g++ &amp;amp;&amp;amp; \&lt;br&gt;
    pnpm install --frozen-lockfile &amp;amp;&amp;amp; \&lt;br&gt;
    apk del .build-deps&lt;br&gt;
🗜️ Build-time optimizations&lt;br&gt;
dockerfile&lt;br&gt;
RUN pnpm run build &amp;amp;&amp;amp; \&lt;br&gt;
    pnpm prune --prod &amp;amp;&amp;amp; \&lt;br&gt;
    rm -rf /root/.npm /root/.cache&lt;br&gt;
Step 4: ECR Lifecycle Policies + EKS Pull Optimization&lt;br&gt;
We also optimized the infrastructure side:&lt;/p&gt;

&lt;p&gt;ECR Lifecycle Rules&lt;br&gt;
We set up automatic cleanup for untagged and old images:&lt;/p&gt;

&lt;p&gt;Keep last 30 images&lt;/p&gt;

&lt;p&gt;Expire untagged images after 14 days&lt;/p&gt;

&lt;p&gt;Tag important images with prod-* to bypass cleanup&lt;/p&gt;

&lt;p&gt;EKS Node Optimization&lt;br&gt;
Enabled container image caching on our nodes&lt;/p&gt;

&lt;p&gt;Configured containerd with aggressive image GC settings&lt;/p&gt;

&lt;p&gt;Pre-pulled base images using a DaemonSet&lt;/p&gt;

&lt;p&gt;Here's the containerd config we use:&lt;/p&gt;

&lt;p&gt;toml&lt;br&gt;
[plugins."io.containerd.gc.v1.scheduler"]&lt;br&gt;
  pause_threshold = 0.02&lt;br&gt;
  deletion_threshold = 0&lt;br&gt;
  mutation_threshold = 100&lt;br&gt;
  schedule_delay = "0s"&lt;br&gt;
  startup_delay = "100ms"&lt;br&gt;
The Results: Numbers That Made Us Smile&lt;br&gt;
Metric  Before  After   Improvement&lt;br&gt;
Image Size  1.2 GB  118 MB  90.1% reduction&lt;br&gt;
ECR Pull Time   45.3s   7.8s    82.8% faster&lt;br&gt;
Deployment Time 2.1 min 28s 78% reduction&lt;br&gt;
Monthly ECR Cost    $47 $9  81% savings&lt;br&gt;
Build Time  4.2 min 1.8 min 57% faster&lt;br&gt;
Before and After: The Docker Image Breakdown&lt;br&gt;
Before (1.2GB)&lt;br&gt;
text&lt;br&gt;
📦 node_modules/     850MB   # dev + prod deps&lt;br&gt;
📁 src/              12MB&lt;br&gt;
📁 tests/            28MB    # 😱 tests in production image!&lt;br&gt;
📁 coverage/         15MB    # seriously? yes&lt;br&gt;
📄 .git/             250MB   # 🤦‍♂️&lt;br&gt;
📄 other files       45MB&lt;br&gt;
After (118MB)&lt;br&gt;
text&lt;br&gt;
📦 node_modules/     85MB    # prod deps only (pnpm)&lt;br&gt;
📁 dist/             15MB    # built application&lt;br&gt;
📄 package.json      2KB&lt;br&gt;
📄 other files       16MB    # minimal essentials&lt;br&gt;
Key Lessons Learned&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;pnpm isn't just a package manager — it's a strategy&lt;br&gt;
The hard-linking approach fundamentally changes how you think about dependency management in containers.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Copy selectively, live happily&lt;br&gt;
COPY --from=builder /app . is the enemy. Be explicit about what you're copying.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Test environments stay out of production images&lt;br&gt;
Your tests don't need to ship. Neither does your test framework, linter, or build tools.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Layer ordering matters&lt;br&gt;
Put frequently changing files (source code) at the bottom, infrequently changing files (dependencies) at the top.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Measure everything&lt;br&gt;
We couldn't have optimized without metrics. Use docker images, docker history, and tools like Dive to analyze your image layers.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Try This Yourself&lt;br&gt;
Here's a template you can adapt:&lt;/p&gt;

&lt;p&gt;dockerfile&lt;/p&gt;

&lt;h1&gt;
  
  
  Stage 1: Builder
&lt;/h1&gt;

&lt;p&gt;FROM node:18-alpine AS builder&lt;br&gt;
WORKDIR /app&lt;br&gt;
RUN npm install -g pnpm&lt;br&gt;
COPY package.json pnpm-lock.yaml ./&lt;br&gt;
RUN pnpm install --frozen-lockfile&lt;br&gt;
COPY . .&lt;br&gt;
RUN pnpm run build &amp;amp;&amp;amp; pnpm prune --prod&lt;/p&gt;

&lt;h1&gt;
  
  
  Stage 2: Runner
&lt;/h1&gt;

&lt;p&gt;FROM node:18-alpine&lt;br&gt;
WORKDIR /app&lt;br&gt;
ENV NODE_ENV=production&lt;br&gt;
COPY --from=builder /app/dist ./dist&lt;br&gt;
COPY --from=builder /app/node_modules ./node_modules&lt;br&gt;
COPY --from=builder /app/package.json ./&lt;br&gt;
RUN addgroup -g 1001 nodejs &amp;amp;&amp;amp; adduser -u 1001 -G nodejs nodejs&lt;br&gt;
USER nodejs&lt;br&gt;
EXPOSE 3000&lt;br&gt;
CMD ["node", "dist/main.js"]&lt;br&gt;
What's Next?&lt;br&gt;
We're not stopping here. Our next targets:&lt;/p&gt;

&lt;p&gt;~50MB with distroless images&lt;/p&gt;

&lt;p&gt;~3s ECR pull with image pre-fetching&lt;/p&gt;

&lt;p&gt;15s deployment times with rolling updates&lt;/p&gt;

&lt;p&gt;Full SBOM (Software Bill of Materials) scanning in CI/CD&lt;/p&gt;

&lt;p&gt;Final Thoughts&lt;br&gt;
Going from 1.2GB to 118MB wasn't magic — it was discipline. Every byte we removed improved our security, speed, and developer experience.&lt;/p&gt;

&lt;p&gt;The best part? We spent about 4 hours total on these optimizations. That's 4 hours to save our team hours every single week.&lt;/p&gt;

&lt;p&gt;If you're sitting on a bloated Docker image, start small:&lt;/p&gt;

&lt;p&gt;Check what's in your image (docker history)&lt;/p&gt;

&lt;p&gt;Move to pnpm&lt;/p&gt;

&lt;p&gt;Audit every COPY command&lt;/p&gt;

&lt;p&gt;Use Alpine as your base&lt;/p&gt;

&lt;p&gt;Measure your improvements&lt;/p&gt;

&lt;p&gt;Your EKS cluster will thank you. Your wallet will thank you. And most importantly, your team will thank you when deployments fly.&lt;/p&gt;

&lt;p&gt;Have you optimized your container images? What's the craziest thing you've found in a production image? Share your war stories in the comments! 👇&lt;/p&gt;

&lt;p&gt;Follow me for more DevOps deep dives 🚀&lt;/p&gt;

&lt;h1&gt;
  
  
  Docker #Kubernetes #EKS #DevOps #pnpm #ContainerOptimization #AWS #CloudNative
&lt;/h1&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>docker</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>From 5 PHP Nightmares to 1 Node Pipeline: Why Boring Standardization Is My Greatest Achievement</title>
      <dc:creator>Sohana Akbar</dc:creator>
      <pubDate>Fri, 24 Jul 2026 15:52:14 +0000</pubDate>
      <link>https://dev.to/sohanaakbar7/from-5-php-nightmares-to-1-node-pipeline-why-boring-standardization-is-my-greatest-achievement-4e63</link>
      <guid>https://dev.to/sohanaakbar7/from-5-php-nightmares-to-1-node-pipeline-why-boring-standardization-is-my-greatest-achievement-4e63</guid>
      <description>&lt;p&gt;TL;DR: I migrated 5 legacy PHP frontends to our standard Node pipeline. Onboarding dropped from 1 week to 1 day. Here's why "boring" is actually the most exciting thing you can do for your team.&lt;/p&gt;

&lt;p&gt;The Confession&lt;br&gt;
I used to hate standardization.&lt;/p&gt;

&lt;p&gt;I was that developer who thought every project needed its own unique flavor. Different build tools? Sure. Different folder structures? Why not? Different deployment scripts? Keeps things interesting, right?&lt;/p&gt;

&lt;p&gt;Wrong.&lt;/p&gt;

&lt;p&gt;Last quarter, I inherited 5 legacy PHP frontends. They were all built by different teams, in different years, with different levels of caffeine-induced insanity. They served the same business purpose, but they might as well have been written in 5 different languages.&lt;/p&gt;

&lt;p&gt;Here's what I found:&lt;/p&gt;

&lt;p&gt;App PHP Version Build Tool  Deployment Method   Developer Tears&lt;br&gt;
App A   5.6 None (FTP)  Manual drag-drop    High&lt;br&gt;
App B   7.0 Gulp    SSH + custom bash   Very High&lt;br&gt;
App C   7.4 Composer    Jenkins (broken)    Extreme&lt;br&gt;
App D   8.0 Webpack (kind of)   Kubernetes (sort of)    Moderate&lt;br&gt;
App E   5.6 (again) Literal nightmares  Prayers Infinite&lt;br&gt;
Onboarding a new developer meant spending a week just explaining the quirks of each app. A week. Before they wrote a single line of production code.&lt;/p&gt;

&lt;p&gt;The Decision&lt;br&gt;
I pitched a radical idea: Kill them all.&lt;/p&gt;

&lt;p&gt;Well, not kill—migrate. Consolidate all 5 frontends into our standard Node.js pipeline. One build process. One deployment strategy. One way to do things.&lt;/p&gt;

&lt;p&gt;The pushback was immediate:&lt;/p&gt;

&lt;p&gt;"But PHP is fine!"&lt;/p&gt;

&lt;p&gt;"Why fix what isn't broken?"&lt;/p&gt;

&lt;p&gt;"This will take months!"&lt;/p&gt;

&lt;p&gt;"We'll lose our 'character'!"&lt;/p&gt;

&lt;p&gt;I had one response: "Your character is costing us money."&lt;/p&gt;

&lt;p&gt;The Migration&lt;br&gt;
Here's what the migration actually looked like:&lt;/p&gt;

&lt;p&gt;Step 1: The Audit&lt;br&gt;
I mapped every feature, every route, every API call across all 5 apps. Surprisingly, they all did the same 3 things:&lt;/p&gt;

&lt;p&gt;Fetch data from an API&lt;/p&gt;

&lt;p&gt;Render a UI&lt;/p&gt;

&lt;p&gt;Handle form submissions&lt;/p&gt;

&lt;p&gt;The differences were purely in how they did it.&lt;/p&gt;

&lt;p&gt;Step 2: The Standard&lt;br&gt;
I defined our "standard" Node pipeline:&lt;/p&gt;

&lt;p&gt;Framework: Express + React (already in use elsewhere)&lt;/p&gt;

&lt;p&gt;Build: Vite (fast, modern)&lt;/p&gt;

&lt;p&gt;Linting: ESLint + Prettier (non-negotiable)&lt;/p&gt;

&lt;p&gt;Testing: Jest + React Testing Library&lt;/p&gt;

&lt;p&gt;Deployment: GitHub Actions → AWS ECS&lt;/p&gt;

&lt;p&gt;Step 3: The Rewrite&lt;br&gt;
I didn't rewrite everything from scratch. I extracted:&lt;/p&gt;

&lt;p&gt;Shared UI components into a monorepo package&lt;/p&gt;

&lt;p&gt;API client logic into a single service&lt;/p&gt;

&lt;p&gt;Environment configuration into a unified .env system&lt;/p&gt;

&lt;p&gt;Each app got re-implemented as a "theme" or "route" within the same codebase.&lt;/p&gt;

&lt;p&gt;Step 4: The Cutover&lt;br&gt;
We deployed one app at a time. If it broke, we rolled back. (It broke twice. We rolled back twice. No big deal.)&lt;/p&gt;

&lt;p&gt;The Results&lt;br&gt;
Onboarding: 1 Week → 1 Day&lt;br&gt;
This is the metric I'm most proud of.&lt;/p&gt;

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

&lt;p&gt;Day 1: Install PHP 5.6 (good luck on M1 Macs)&lt;/p&gt;

&lt;p&gt;Day 2: Configure 5 different .ini files&lt;/p&gt;

&lt;p&gt;Day 3: Learn App A's custom routing&lt;/p&gt;

&lt;p&gt;Day 4: Learn App B's custom templating&lt;/p&gt;

&lt;p&gt;Day 5: Actually write code (maybe)&lt;/p&gt;

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

&lt;p&gt;Morning: git clone, npm install, cp .env.example .env&lt;/p&gt;

&lt;p&gt;Afternoon: "Here's the codebase. You know React. You know Node. Go."&lt;/p&gt;

&lt;p&gt;New devs are pushing features on Day 1. Not just "Hello World"—actual features.&lt;/p&gt;

&lt;p&gt;Developer Satisfaction: 📈&lt;br&gt;
I surveyed the team before and after:&lt;/p&gt;

&lt;p&gt;Metric  Before  After&lt;br&gt;
"I understand the codebase" 2/10    8/10&lt;br&gt;
"I can debug issues quickly"    3/10    7/10&lt;br&gt;
"I enjoy working here"  4/10    9/10&lt;br&gt;
Deployments: Stressful → Boring&lt;br&gt;
Before: Deployments required 3 different people, 2 Slack channels, and a prayer circle.&lt;/p&gt;

&lt;p&gt;After: git push main → automated build → automated test → automated deploy. 15 minutes. No human intervention.&lt;/p&gt;

&lt;p&gt;The Hard Truth&lt;br&gt;
Standardization is boring.&lt;/p&gt;

&lt;p&gt;There's no glory in writing another YAML file. No one gives you a trophy for enforcing the same ESLint rules across 5 apps. No one tweets about "the elegant CI pipeline."&lt;/p&gt;

&lt;p&gt;But here's the thing:&lt;/p&gt;

&lt;p&gt;Boring is fast.&lt;br&gt;
Boring is predictable.&lt;br&gt;
Boring is scalable.&lt;br&gt;
Boring makes money.&lt;/p&gt;

&lt;p&gt;When we hired our last junior developer, they were productive on Day 1. That's not a flex—that's the system working. The system doesn't care about being exciting. The system cares about getting work done.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Standardization Is a Force Multiplier&lt;br&gt;
One week to onboard vs. one day means 4 extra days of productivity per new hire. If we hire 10 people this year, that's 40 days of salary saved. That's not "boring"—that's a business case.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Legacy Code Isn't "Character"—It's Debt&lt;br&gt;
I used to romanticize legacy code. "It has history!" "It's battle-tested!" No. It has bugs. It has config drift. It has hidden dependencies that no one remembers.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Kill it. Consolidate it. Move on.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Developers Want Clarity, Not Chaos
Every developer I've ever worked with wants to write code that matters. They don't want to spend 3 hours figuring out why App C won't connect to the database.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;By standardizing, I gave my team back hours of their lives. That's not boring—that's humane.&lt;/p&gt;

&lt;p&gt;The Meme Version (For X/Threads)&lt;br&gt;
5 legacy PHP apps ➡️ 1 Node pipeline.&lt;/p&gt;

&lt;p&gt;Onboarding:&lt;br&gt;
Before: 1 week of "Why does this server require PHP 5.6?"&lt;br&gt;
After: 1 day of "Here is your .env file."&lt;/p&gt;

&lt;p&gt;Standardization is boring.&lt;/p&gt;

&lt;p&gt;But watching new devs ship features on Day 1? That's beautiful.&lt;/p&gt;

&lt;p&gt;Final Thoughts&lt;br&gt;
If you're sitting on a pile of legacy apps that all do the same thing but look completely different—stop romanticizing the chaos.&lt;/p&gt;

&lt;p&gt;Consolidate.&lt;br&gt;
Standardize.&lt;br&gt;
Bore yourself to death.&lt;/p&gt;

&lt;p&gt;Your future self (and your future new hires) will thank you.&lt;/p&gt;

&lt;p&gt;What's your experience with standardizing legacy code? Drop a comment below. Let's be boring together. 👇&lt;/p&gt;

</description>
      <category>ai</category>
    </item>
    <item>
      <title>From 1 Week to 1 Day: The Unsexy Power of Standardization</title>
      <dc:creator>Sohana Akbar</dc:creator>
      <pubDate>Thu, 23 Jul 2026 15:01:48 +0000</pubDate>
      <link>https://dev.to/sohanaakbar7/from-1-week-to-1-day-the-unsexy-power-of-standardization-6c</link>
      <guid>https://dev.to/sohanaakbar7/from-1-week-to-1-day-the-unsexy-power-of-standardization-6c</guid>
      <description>&lt;p&gt;"Standardization is boring."&lt;/p&gt;

&lt;p&gt;I've heard this phrase more times than I can count. Usually from senior engineers who've been burned by "one-size-fits-all" solutions. Usually right before they propose building Yet Another Custom Framework™.&lt;/p&gt;

&lt;p&gt;But here's the thing about boring: it scales. And after migrating 5 legacy PHP frontends to our standard Node pipeline, I've become a born-again convert to the church of "boring technology."&lt;/p&gt;

&lt;p&gt;Let me tell you why.&lt;/p&gt;

&lt;p&gt;The Mess We Inherited&lt;br&gt;
Our product had grown organically over 7 years. Different teams, different eras, different opinions about what "good" looked like.&lt;/p&gt;

&lt;p&gt;We had:&lt;/p&gt;

&lt;p&gt;Legacy PHP monolith (custom framework, no tests)&lt;/p&gt;

&lt;p&gt;PHP + jQuery (circa 2014, with "creative" use of global variables)&lt;/p&gt;

&lt;p&gt;PHP + AngularJS (the 1.x version, because why not?)&lt;/p&gt;

&lt;p&gt;PHP + Vue 2 (our "modern" one, but with Webpack configs from hell)&lt;/p&gt;

&lt;p&gt;PHP + vanilla JS (a "micro-frontend" before that was a word)&lt;/p&gt;

&lt;p&gt;Each had its own:&lt;/p&gt;

&lt;p&gt;Deployment process (some FTP, some Jenkins, one was literally a bash script that scp'd files)&lt;/p&gt;

&lt;p&gt;Dependency management (Composer? NPM? Just commit the vendor folder?)&lt;/p&gt;

&lt;p&gt;Environment variables (.env, .env.local, .env.production, and one that used a JSON file in S3)&lt;/p&gt;

&lt;p&gt;Build process (or none at all)&lt;/p&gt;

&lt;p&gt;Testing strategy (or none at all)&lt;/p&gt;

&lt;p&gt;Onboarding a new developer took 1 week. Minimum.&lt;/p&gt;

&lt;p&gt;And that was assuming they already knew PHP. If they were a Node/React hire? Good luck. We'd lose them in the first 3 days, buried in a forest of php.ini configuration and mysterious 500 errors.&lt;/p&gt;

&lt;p&gt;The Migration: Boring Choices, Big Impact&lt;br&gt;
I didn't propose rewriting everything in Rust with WebAssembly. I didn't propose a microservices architecture with Kubernetes and service meshes.&lt;/p&gt;

&lt;p&gt;I proposed: "What if we just... used our standard Node pipeline?"&lt;/p&gt;

&lt;p&gt;The stack:&lt;/p&gt;

&lt;p&gt;Node.js + Express (API layer)&lt;/p&gt;

&lt;p&gt;React (frontend, with Vite)&lt;/p&gt;

&lt;p&gt;TypeScript (because we like our sanity)&lt;/p&gt;

&lt;p&gt;Standardized deployment (GitHub Actions → Docker → ECS)&lt;/p&gt;

&lt;p&gt;Shared ESLint/Prettier configs&lt;/p&gt;

&lt;p&gt;Same testing tools (Jest + React Testing Library)&lt;/p&gt;

&lt;p&gt;Same logging/monitoring (Datadog, structured JSON logs)&lt;/p&gt;

&lt;p&gt;Nothing innovative. Nothing that would get a conference talk. Nothing that made me feel like a 10x engineer.&lt;/p&gt;

&lt;p&gt;But here's what happened:&lt;/p&gt;

&lt;p&gt;Migration 1: The Monolith&lt;br&gt;
Took 3 weeks. We had to extract business logic, build a proper API, and reimplement feature flags. Painful, but we now had API contracts.&lt;/p&gt;

&lt;p&gt;Migration 2: jQuery Era&lt;br&gt;
Took 2 weeks. Mostly just rebuilding components in React. The API was already there from migration 1.&lt;/p&gt;

&lt;p&gt;Migration 3: AngularJS&lt;br&gt;
Took 1 week. Similar patterns, easier because React is... well, React.&lt;/p&gt;

&lt;p&gt;Migration 4: Vue 2&lt;br&gt;
Took 4 days. The patterns were close enough that we could map components 1:1.&lt;/p&gt;

&lt;p&gt;Migration 5: Vanilla JS&lt;br&gt;
Took 3 days. Minimal state management meant minimal migration effort.&lt;/p&gt;

&lt;p&gt;Total: ~6 weeks of focused work.&lt;/p&gt;

&lt;p&gt;The Real Win: Onboarding Time&lt;br&gt;
Before migration: 1 week to get a new dev productive.&lt;/p&gt;

&lt;p&gt;After migration: 1 day.&lt;/p&gt;

&lt;p&gt;Here's what that day looks like now:&lt;/p&gt;

&lt;p&gt;Hour 1: Clone repo, run npm install, run npm run dev. Application is running locally. No php.ini tweaks. No Xdebug setup. No "but it works on my machine."&lt;/p&gt;

&lt;p&gt;Hour 2: Walk through our standard dev setup: how to run tests (npm test), how to lint (npm run lint), how to build (npm run build). Same commands as our 8 other microservices.&lt;/p&gt;

&lt;p&gt;Hour 3: PR is opened for a small bug fix. CI passes (because the same CI runs on every repo). Reviewer comments on style (and they're using the same ESLint rules, so most style issues are already caught).&lt;/p&gt;

&lt;p&gt;Hour 4: First deployment. Same GitHub Actions workflow. Same ECS task definition. Same Datadog dashboards.&lt;/p&gt;

&lt;p&gt;Day 2: They're picking up tickets independently.&lt;/p&gt;

&lt;p&gt;The Unsexy Metrics That Matter&lt;br&gt;
Metric  Before  After&lt;br&gt;
New dev onboarding time 5-7 days    1 day&lt;br&gt;
Setup errors    ~3 per new dev  0&lt;br&gt;
Time to first PR    4 days  3 hours&lt;br&gt;
Deployment failures ~15%    ~2%&lt;br&gt;
Context switching   5 different mental models   1 mental model&lt;br&gt;
"Where is X configured?" questions  Daily   Never&lt;br&gt;
Why This Matters (Beyond the Numbers)&lt;br&gt;
Here's what I learned from this migration:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Innovation is expensive&lt;br&gt;
Every bespoke solution is a tax on future developers. Choose to be boring when it matters.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Boring scales, exciting doesn't&lt;br&gt;
Your team doesn't need to be impressed by your tech stack. They need to ship features and go home at 5 PM.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Onboarding is a signal&lt;br&gt;
If it takes 1 week to get someone productive, that's not a problem with your new hires. That's a problem with your architecture.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Consistency compounds&lt;br&gt;
Once everything uses the same patterns, you stop thinking about the tooling. You start thinking about the product. That's the real win.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The best migration is the one you finish&lt;br&gt;
Did we make perfect choices? No. Did we rewrite everything in the "optimal" way? No. Did we finish? Yes. That matters more than perfection.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What We Lost (and Didn't Miss)&lt;br&gt;
Lost: The freedom to choose the "right tool for the job" on a per-project basis.&lt;/p&gt;

&lt;p&gt;Didn't miss: The freedom to choose the "right tool for the job" on a per-project basis.&lt;/p&gt;

&lt;p&gt;Lost: Engineers feeling clever because they used a niche framework.&lt;/p&gt;

&lt;p&gt;Didn't miss: Engineers feeling clever because they used a niche framework.&lt;/p&gt;

&lt;p&gt;Lost: The ability to optimize every frontend independently.&lt;/p&gt;

&lt;p&gt;Didn't miss: Debugging 5 different deployment failures on a Friday afternoon.&lt;/p&gt;

&lt;p&gt;The One Problem We Still Have&lt;br&gt;
Recruiting. Because when I describe our stack to candidates, it's not exciting.&lt;/p&gt;

&lt;p&gt;"React? Node? TypeScript? You're like every other company."&lt;/p&gt;

&lt;p&gt;Yes. Exactly.&lt;/p&gt;

&lt;p&gt;But you know what else I tell them?&lt;/p&gt;

&lt;p&gt;"Your first day, you'll write code that ships to production. Your second day, you'll pick up a ticket. Your third day, you'll be teaching the intern."&lt;/p&gt;

&lt;p&gt;That is exciting.&lt;/p&gt;

&lt;p&gt;Practical Takeaways&lt;br&gt;
If you're considering a similar migration:&lt;/p&gt;

&lt;p&gt;Start with API extraction. Separate data from presentation before you touch the frontend.&lt;/p&gt;

&lt;p&gt;Standardize deployment first. If you can deploy anything the same way, migrations get easier.&lt;/p&gt;

&lt;p&gt;Accept temporary duplication. During migration, we had both PHP and Node running in production. Traffic was split. It worked.&lt;/p&gt;

&lt;p&gt;Don't rewrite business logic. If you can, keep the domain logic intact and just change the presentation layer.&lt;/p&gt;

&lt;p&gt;Move fast on migrations. The longer a migration takes, the less likely you are to finish. 6 weeks was intense, but it was finished.&lt;/p&gt;

&lt;p&gt;Celebrate the boring wins. When a new dev ships on day 1, call it out. That's the metric that matters.&lt;/p&gt;

&lt;p&gt;The Bottom Line&lt;br&gt;
Standardization isn't exciting. It won't get you on the front page of Hacker News. It won't get you a speaking slot at your local tech conference.&lt;/p&gt;

&lt;p&gt;But it will:&lt;/p&gt;

&lt;p&gt;Get new devs shipping code on day 1&lt;/p&gt;

&lt;p&gt;Reduce production incidents&lt;/p&gt;

&lt;p&gt;Make debugging easier&lt;/p&gt;

&lt;p&gt;Let your team focus on product, not tooling&lt;/p&gt;

&lt;p&gt;Ship features faster&lt;/p&gt;

&lt;p&gt;And honestly? That's way more interesting than arguing about frameworks.&lt;/p&gt;

&lt;p&gt;Have you standardized your stack? What was the biggest win (or pain point)? Drop a comment below—I'd love to hear your war stories.&lt;/p&gt;

&lt;p&gt;P.S. If you're on a team where "boring" is considered a bad word, send this article to your tech lead. Sometimes the most impactful engineering work is the stuff that's invisible when it's done right.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>node</category>
      <category>php</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>From Deployment Chaos to Clarity: Automating Jira Ticket Linking in Our Production Pipeline</title>
      <dc:creator>Sohana Akbar</dc:creator>
      <pubDate>Wed, 22 Jul 2026 15:12:00 +0000</pubDate>
      <link>https://dev.to/sohanaakbar7/from-deployment-chaos-to-clarity-automating-jira-ticket-linking-in-our-production-pipeline-5gm0</link>
      <guid>https://dev.to/sohanaakbar7/from-deployment-chaos-to-clarity-automating-jira-ticket-linking-in-our-production-pipeline-5gm0</guid>
      <description>&lt;p&gt;How we eliminated "what are we deploying?" from our release process without sacrificing our manual approval gate&lt;/p&gt;

&lt;p&gt;The 3 PM Panic&lt;br&gt;
We've all been there. It's 3 PM on a Thursday, and the deployment dashboard shows a green build waiting for production approval. The release manager opens Slack and types the dreaded question:&lt;/p&gt;

&lt;p&gt;"What are we deploying?"&lt;/p&gt;

&lt;p&gt;What follows is 15 minutes of frantic channel scrolling, digging through commit histories, and playing detective across Jira, GitHub, and Slack. Eventually, someone pieces together that the release contains three bug fixes, two features, and—surprise—a database migration that needs special attention.&lt;/p&gt;

&lt;p&gt;This scene played out in our team at least twice a week. We had a robust manual approval gate for production (security requirements meant we couldn't automate that final step), but we had zero visibility into what we were actually approving.&lt;/p&gt;

&lt;p&gt;The manual approval wasn't the problem. The context gap was.&lt;/p&gt;

&lt;p&gt;The Problem We Were Actually Solving&lt;br&gt;
Let's be clear: we weren't trying to remove the manual approval gate. In our regulated industry, that wasn't an option. But the approval was meaningless when approvers had to become digital archaeologists just to understand what they were signing off on.&lt;/p&gt;

&lt;p&gt;Our pain points were specific:&lt;/p&gt;

&lt;p&gt;Context switching overhead - Approvers spent 5-10 minutes gathering information per deployment&lt;/p&gt;

&lt;p&gt;Incomplete release notes - We relied on developers remembering to update a CHANGELOG&lt;/p&gt;

&lt;p&gt;Missing dependencies - Database migrations, environment variable changes, or third-party service updates often got lost in the shuffle&lt;/p&gt;

&lt;p&gt;Audit trail gaps - When something went wrong, tracing back to the original requirement was painful&lt;/p&gt;

&lt;p&gt;"Surprise" deployments - Team members would discover their changes were in production only when a user reported a bug&lt;/p&gt;

&lt;p&gt;The manual approval gate wasn't going anywhere. But the chaos around it? That we could fix.&lt;/p&gt;

&lt;p&gt;The Solution: Automated Jira Ticket Linking&lt;br&gt;
The idea was simple: make every deployment self-documenting by automatically pulling in Jira ticket context.&lt;/p&gt;

&lt;p&gt;Here's what we built:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Jira Integration in CI/CD
We added a step in our CI pipeline (we use GitHub Actions) that:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Parses commit messages for Jira ticket keys (e.g., PROJ-123)&lt;/p&gt;

&lt;p&gt;Fetches ticket metadata via Jira REST API&lt;/p&gt;

&lt;p&gt;Aggregates all unique tickets into a deployment manifest&lt;/p&gt;

&lt;p&gt;yaml&lt;/p&gt;

&lt;h1&gt;
  
  
  .github/workflows/deploy.yml snippet
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;name: Fetch Jira Tickets&lt;br&gt;
id: jira&lt;br&gt;
run: |&lt;br&gt;
TICKETS=$(git log ${{ github.sha }} --pretty=format:"%s" | grep -oE '[A-Z]+-[0-9]+' | sort -u)&lt;br&gt;
echo "tickets=$TICKETS" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;name: Get Ticket Details&lt;br&gt;
if: steps.jira.outputs.tickets != ''&lt;br&gt;
run: |&lt;/p&gt;
&lt;h1&gt;
  
  
  Fetch details for each ticket
&lt;/h1&gt;

&lt;p&gt;for ticket in ${{ steps.jira.outputs.tickets }}; do&lt;br&gt;
  curl -H "Authorization: Bearer $JIRA_TOKEN" \&lt;br&gt;
       "&lt;a href="https://jira.company.com/rest/api/2/issue/$ticket" rel="noopener noreferrer"&gt;https://jira.company.com/rest/api/2/issue/$ticket&lt;/a&gt;" \&lt;br&gt;
       | jq '.fields | {summary: .summary, status: .status.name, assignee: .assignee.displayName, priority: .priority.name}'&lt;br&gt;
done&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The Deployment Manifest&lt;br&gt;
Instead of just a version number, our deployment pipeline now generates a rich manifest:&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;json&lt;br&gt;
{&lt;br&gt;
  "deployment_id": "deploy-20240115-001",&lt;br&gt;
  "version": "v2.3.1",&lt;br&gt;
  "timestamp": "2024-01-15T14:30:00Z",&lt;br&gt;
  "jira_tickets": [&lt;br&gt;
    {&lt;br&gt;
      "key": "PROJ-123",&lt;br&gt;
      "summary": "Fix payment calculation for EU customers",&lt;br&gt;
      "status": "Done",&lt;br&gt;
      "assignee": "Alex",&lt;br&gt;
      "priority": "High",&lt;br&gt;
      "components": ["Payment API", "Backend"],&lt;br&gt;
      "fixVersion": "v2.3.1"&lt;br&gt;
    },&lt;br&gt;
    {&lt;br&gt;
      "key": "PROJ-124", &lt;br&gt;
      "summary": "Add dark mode toggle to dashboard",&lt;br&gt;
      "status": "Done", &lt;br&gt;
      "assignee": "Jamie",&lt;br&gt;
      "priority": "Medium",&lt;br&gt;
      "components": ["UI", "Frontend"],&lt;br&gt;
      "fixVersion": "v2.3.1"&lt;br&gt;
    }&lt;br&gt;
  ],&lt;br&gt;
  "release_notes": "Bug fixes: PROJ-123. Features: PROJ-124.",&lt;br&gt;
  "db_migrations": ["ALTER TABLE payments ADD COLUMN tax_rate"],&lt;br&gt;
  "env_changes": ["New env var: PAYMENT_TAX_ENABLED=true"]&lt;br&gt;
}&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Automated Release Notes
The pipeline generates formatted release notes from the Jira data and posts them to:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A dedicated Slack channel&lt;/p&gt;

&lt;p&gt;The GitHub release page&lt;/p&gt;

&lt;p&gt;Our internal documentation site&lt;/p&gt;

&lt;p&gt;Crucially: the manual approval request itself&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Approval with Context
When an approver now receives the manual approval request, they see:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;text&lt;br&gt;
📦 Deployment v2.3.1 to Production&lt;br&gt;
────────────────────────────────&lt;br&gt;
🕐 Requested: 2024-01-15 14:30 UTC&lt;br&gt;
👤 Requestor: deploy-bot&lt;/p&gt;

&lt;p&gt;📋 Tickets in this release:&lt;br&gt;
✅ PROJ-123: Fix payment calculation for EU customers (High)&lt;br&gt;
✅ PROJ-124: Add dark mode toggle to dashboard (Medium)&lt;br&gt;
✅ PROJ-125: Update error logging for API timeouts (Low)&lt;/p&gt;

&lt;p&gt;⚠️  Database Changes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ALTER TABLE payments ADD COLUMN tax_rate (auto-rollback available)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;⚙️ Environment Changes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PAYMENT_TAX_ENABLED=true (new)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;📎 View full details: &lt;a href="https://deployments.internal/release/v2.3.1" rel="noopener noreferrer"&gt;https://deployments.internal/release/v2.3.1&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;[Approve] [Reject] [View Details]&lt;br&gt;
No more context gathering. No more Slack panic.&lt;/p&gt;

&lt;p&gt;What We Built (The Technical Details)&lt;br&gt;
Here's the architecture we settled on:&lt;/p&gt;

&lt;p&gt;Pipeline Integration&lt;br&gt;
text&lt;br&gt;
┌─────────────┐     ┌─────────────┐     ┌─────────────┐&lt;br&gt;
│   Code Push │────▶│  CI Build   │────▶│  Jira Fetch │&lt;br&gt;
└─────────────┘     └─────────────┘     └─────────────┘&lt;br&gt;
                                                │&lt;br&gt;
                                                ▼&lt;br&gt;
┌─────────────┐     ┌─────────────┐     ┌─────────────┐&lt;br&gt;
│  Approval   │◀────│  Deploy to  │◀────│  Generate   │&lt;br&gt;
│   Request   │     │ Staging     │     │  Manifest   │&lt;br&gt;
└─────────────┘     └─────────────┘     └─────────────┘&lt;br&gt;
        │&lt;br&gt;
        ▼&lt;br&gt;
┌─────────────┐&lt;br&gt;
│  Deploy to  │&lt;br&gt;
│ Production  │&lt;br&gt;
└─────────────┘&lt;br&gt;
Key Components&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Git Commit Parser&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;python&lt;br&gt;
import re&lt;br&gt;
from typing import Set&lt;/p&gt;

&lt;p&gt;def extract_jira_tickets(commits: List[str]) -&amp;gt; Set[str]:&lt;br&gt;
    """Extract JIRA ticket keys from commit messages."""&lt;br&gt;
    pattern = r'[A-Z]{2,10}-\d+'&lt;br&gt;
    tickets = set()&lt;br&gt;
    for commit in commits:&lt;br&gt;
        tickets.update(re.findall(pattern, commit))&lt;br&gt;
    return tickets&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Jira API Client&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;python&lt;br&gt;
class JiraClient:&lt;br&gt;
    def get_ticket_details(self, ticket_key: str) -&amp;gt; dict:&lt;br&gt;
        response = requests.get(&lt;br&gt;
            f"{self.base_url}/rest/api/2/issue/{ticket_key}",&lt;br&gt;
            headers={"Authorization": f"Bearer {self.token}"}&lt;br&gt;
        )&lt;br&gt;
        data = response.json()&lt;br&gt;
        return {&lt;br&gt;
            "key": ticket_key,&lt;br&gt;
            "summary": data["fields"]["summary"],&lt;br&gt;
            "status": data["fields"]["status"]["name"],&lt;br&gt;
            "assignee": data["fields"]["assignee"]["displayName"] if data["fields"]["assignee"] else None,&lt;br&gt;
            "priority": data["fields"]["priority"]["name"],&lt;br&gt;
            "components": [c["name"] for c in data["fields"]["components"]],&lt;br&gt;
            "fixVersion": data["fields"]["fixVersions"][0]["name"] if data["fields"]["fixVersions"] else None&lt;br&gt;
        }&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Manifest Generator&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;python&lt;br&gt;
def generate_deployment_manifest(environment, version, tickets):&lt;br&gt;
    manifest = {&lt;br&gt;
        "deployment_id": f"deploy-{datetime.now().strftime('%Y%m%d-%H%M')}",&lt;br&gt;
        "environment": environment,&lt;br&gt;
        "version": version,&lt;br&gt;
        "timestamp": datetime.now().isoformat(),&lt;br&gt;
        "jira_tickets": [JiraClient().get_ticket_details(t) for t in tickets],&lt;br&gt;
        "release_notes": generate_release_notes(tickets),&lt;br&gt;
        "db_migrations": detect_migrations(),  # Custom detection logic&lt;br&gt;
        "env_changes": detect_env_changes()    # Custom detection logic&lt;br&gt;
    }&lt;br&gt;
    return manifest&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Slack Notification&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;python&lt;br&gt;
def send_approval_request(manifest):&lt;br&gt;
    blocks = [&lt;br&gt;
        {&lt;br&gt;
            "type": "header",&lt;br&gt;
            "text": {"type": "plain_text", "text": f"📦 Deployment {manifest['version']} to Production"}&lt;br&gt;
        },&lt;br&gt;
        {&lt;br&gt;
            "type": "section",&lt;br&gt;
            "fields": [&lt;br&gt;
                {"type": "mrkdwn", "text": f"&lt;em&gt;Tickets:&lt;/em&gt; {len(manifest['jira_tickets'])}"},&lt;br&gt;
                {"type": "mrkdwn", "text": f"&lt;em&gt;DB Changes:&lt;/em&gt; {len(manifest['db_migrations'])}"},&lt;br&gt;
            ]&lt;br&gt;
        }&lt;br&gt;
    ]&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Add ticket details
for ticket in manifest['jira_tickets'][:5]:  # Show top 5
    blocks.append({
        "type": "section",
        "text": {"type": "mrkdwn", "text": f"• *{ticket['key']}*: {ticket['summary']} ({ticket['priority']})"}
    })

if len(manifest['jira_tickets']) &amp;gt; 5:
    blocks.append({
        "type": "section",
        "text": {"type": "mrkdwn", "text": f"... and {len(manifest['jira_tickets']) - 5} more"}
    })

# Approval buttons
blocks.append({
    "type": "actions",
    "elements": [
        {"type": "button", "text": {"type": "plain_text", "text": "✅ Approve"}, 
         "value": f"approve_{manifest['deployment_id']}", "style": "primary"},
        {"type": "button", "text": {"type": "plain_text", "text": "❌ Reject"}, 
         "value": f"reject_{manifest['deployment_id']}", "style": "danger"},
    ]
})

return blocks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The Results (4 Months Later)&lt;br&gt;
What Improved&lt;br&gt;
Approval time decreased from ~8 minutes to under 1 minute - Approvers no longer needed to hunt for context. Everything they needed was in the request.&lt;/p&gt;

&lt;p&gt;"What are we deploying?" questions dropped by 95% - The first week we deployed this, I kept waiting for the Slack message. It never came.&lt;/p&gt;

&lt;p&gt;Audit compliance became effortless - Every deployment now has a complete audit trail linking commits → tickets → approvals → production.&lt;/p&gt;

&lt;p&gt;On-call incidents became easier to triage - When something broke, we could instantly see which tickets were in the release and roll back specific changes.&lt;/p&gt;

&lt;p&gt;Developers started writing better commit messages - Since commit messages are parsed, devs began naturally including ticket keys even more consistently.&lt;/p&gt;

&lt;p&gt;What We Learned&lt;br&gt;
Ticket keys in commits are non-negotiable - We added a pre-commit hook to prevent commits without ticket keys in main branches.&lt;/p&gt;

&lt;p&gt;Not every deployment needs Jira tickets - Hotfixes and infrastructure changes don't always map to tickets. We added the ability to skip ticket linking with [NO-TICKET] in the commit message.&lt;/p&gt;

&lt;p&gt;Database migrations need special handling - We added explicit detection for migration files and flagged them prominently in the approval request.&lt;/p&gt;

&lt;p&gt;The manifest is now a single source of truth - Teams started referencing the deployment manifest for everything from rollback decisions to release notes generation.&lt;/p&gt;

&lt;p&gt;The Code (If You Want to Try This)&lt;br&gt;
We open-sourced the core components. Here's the simplified version you can adapt:&lt;/p&gt;

&lt;p&gt;GitHub Action for Jira Linking&lt;br&gt;
yaml&lt;br&gt;
name: Generate Deployment Context&lt;/p&gt;

&lt;p&gt;on:&lt;br&gt;
  push:&lt;br&gt;
    branches: [main, staging]&lt;/p&gt;

&lt;p&gt;jobs:&lt;br&gt;
  generate-context:&lt;br&gt;
    runs-on: ubuntu-latest&lt;br&gt;
    steps:&lt;br&gt;
      - uses: actions/checkout@v3&lt;br&gt;
        with:&lt;br&gt;
          fetch-depth: 0  # Get all commit history&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  - name: Extract tickets from commits
    id: tickets
    run: |
      COMMITS=$(git log ${{ github.sha }} --pretty=format:"%s")
      TICKETS=$(echo "$COMMITS" | grep -oE '[A-Z]+-[0-9]+' | sort -u | tr '\n' ',')
      echo "tickets=${TICKETS%,}" &amp;gt;&amp;gt; $GITHUB_OUTPUT

  - name: Fetch Jira Details
    if: steps.tickets.outputs.tickets != ''
    uses: actions/github-script@v6
    with:
      script: |
        const tickets = '${{ steps.tickets.outputs.tickets }}'.split(',');
        // Call Jira API for each ticket
        // Store results in deployment-manifest.json

  - name: Create Deployment Manifest
    run: |
      echo "Deployment manifest created with Jira context"

  - name: Upload Manifest
    uses: actions/upload-artifact@v3
    with:
      name: deployment-manifest
      path: deployment-manifest.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Simple Jira Fetch Script&lt;br&gt;
bash&lt;/p&gt;

&lt;h1&gt;
  
  
  !/bin/bash
&lt;/h1&gt;

&lt;h1&gt;
  
  
  fetch-jira.sh - Fetch ticket details from Jira
&lt;/h1&gt;

&lt;p&gt;TICKETS=$(git log origin/main..HEAD --pretty=format:"%s" | grep -oE '[A-Z]+-[0-9]+' | sort -u)&lt;/p&gt;

&lt;p&gt;if [ -z "$TICKETS" ]; then&lt;br&gt;
  echo "No Jira tickets found in commits"&lt;br&gt;
  exit 0&lt;br&gt;
fi&lt;/p&gt;

&lt;p&gt;echo "Found tickets: $TICKETS"&lt;/p&gt;

&lt;p&gt;for TICKET in $TICKETS; do&lt;br&gt;
  echo "Fetching $TICKET..."&lt;br&gt;
  curl -s -H "Authorization: Bearer $JIRA_TOKEN" \&lt;br&gt;
       "&lt;a href="https://jira.company.com/rest/api/2/issue/$TICKET" rel="noopener noreferrer"&gt;https://jira.company.com/rest/api/2/issue/$TICKET&lt;/a&gt;" \&lt;br&gt;
       | jq '.key + ": " + .fields.summary'&lt;br&gt;
done&lt;br&gt;
The Bigger Picture&lt;br&gt;
This project taught us something important: automation isn't just about speed—it's about clarity.&lt;/p&gt;

&lt;p&gt;We didn't remove the manual approval gate because it serves a legitimate purpose. But we made it meaningful by providing approvers with the context they need to make informed decisions.&lt;/p&gt;

&lt;p&gt;The result isn't just faster deployments. It's better deployments. When people understand what they're approving, they catch issues earlier. They spot dependencies. They ask better questions.&lt;/p&gt;

&lt;p&gt;And when 3 PM rolls around on a Thursday, there's no Slack panic. Just a clear, informed approval request that says: "Here's exactly what we're deploying, and here's why."&lt;/p&gt;

&lt;p&gt;Want to Build Your Own?&lt;br&gt;
Start small:&lt;/p&gt;

&lt;p&gt;Parse commit messages for ticket keys&lt;/p&gt;

&lt;p&gt;Display that list in your CI/CD dashboard&lt;/p&gt;

&lt;p&gt;Add ticket summaries to Slack approval messages&lt;/p&gt;

&lt;p&gt;Build a full manifest with ticket details&lt;/p&gt;

&lt;p&gt;Add automated release notes&lt;/p&gt;

&lt;p&gt;Protip: Don't over-engineer it. A simple comma-separated list of tickets in your approval request is already a huge improvement over nothing.&lt;/p&gt;

&lt;p&gt;Have you solved a similar problem? How did you handle the balance between automation and human oversight? Drop your thoughts in the comments!&lt;/p&gt;

</description>
      <category>automation</category>
      <category>deployment</category>
      <category>devops</category>
    </item>
    <item>
      <title>When Your Code Quality Gate Makes Developers Cry (But Your Bugs Disappear)</title>
      <dc:creator>Sohana Akbar</dc:creator>
      <pubDate>Mon, 20 Jul 2026 18:00:38 +0000</pubDate>
      <link>https://dev.to/sohanaakbar7/when-your-code-quality-gate-makes-developers-cry-but-your-bugs-disappear-3odl</link>
      <guid>https://dev.to/sohanaakbar7/when-your-code-quality-gate-makes-developers-cry-but-your-bugs-disappear-3odl</guid>
      <description>&lt;p&gt;The 20% Merge Drop That Saved Our Production&lt;/p&gt;

&lt;p&gt;Let me paint you a picture: It's 4:47 PM on a Friday. Sarah from engineering just pushed her 47th commit to a PR that's been open for two weeks. The build passes. Tests are green. She hits "Merge" with the confidence of someone who's about to start their weekend early.&lt;/p&gt;

&lt;p&gt;Then, a red X appears.&lt;/p&gt;

&lt;p&gt;SonarQube is screaming about technical debt, code smells, and—God forbid—a single duplicated line of code.&lt;/p&gt;

&lt;p&gt;Sarah's Slack status changes to "brb crying." The merge is blocked. The weekend is ruined. The team lead is getting pings from product about "why is nothing shipping?"&lt;/p&gt;

&lt;p&gt;Sound familiar?&lt;/p&gt;

&lt;p&gt;That was us, three months ago. And it was the best decision we ever made.&lt;/p&gt;

&lt;p&gt;The Numbers That Made Us Sweat&lt;br&gt;
Let's get the ugly truth out first:&lt;/p&gt;

&lt;p&gt;PR merges dropped by 20% in the first month&lt;/p&gt;

&lt;p&gt;Developer satisfaction scores dipped to an all-time low&lt;/p&gt;

&lt;p&gt;Team velocity "felt" slower (I'll come back to this in a moment)&lt;/p&gt;

&lt;p&gt;At least one developer threatened to quit (they didn't, but the rant was legendary)&lt;/p&gt;

&lt;p&gt;But here's what happened next:&lt;/p&gt;

&lt;p&gt;Production bugs dropped by 60%&lt;/p&gt;

&lt;p&gt;Hotfixes went from weekly to monthly&lt;/p&gt;

&lt;p&gt;On-call rotations went from "I hate my life" to "I actually slept last night"&lt;/p&gt;

&lt;p&gt;Technical debt accumulation slowed by 40%&lt;/p&gt;

&lt;p&gt;Why Developers Hate Quality Gates (And Why They're Wrong)&lt;br&gt;
The pushback we heard was predictable:&lt;/p&gt;

&lt;p&gt;"SonarQube doesn't understand our business logic"&lt;/p&gt;

&lt;p&gt;"This is just bureaucratic overhead"&lt;/p&gt;

&lt;p&gt;"I'll fix it later, just let me merge"&lt;/p&gt;

&lt;p&gt;"We're shipping features, not perfect code"&lt;/p&gt;

&lt;p&gt;And honestly? They were right about the frustration, but wrong about the solution.&lt;/p&gt;

&lt;p&gt;What we realized is that developers weren't mad about quality gates themselves. They were mad about:&lt;/p&gt;

&lt;p&gt;Discovering issues too late in the PR process&lt;/p&gt;

&lt;p&gt;Getting penalized for problems that existed before they touched the code&lt;/p&gt;

&lt;p&gt;Not understanding the rules until the merge was blocked&lt;/p&gt;

&lt;p&gt;Feeling like robots instead of craftspeople&lt;/p&gt;

&lt;p&gt;How We Made It Work (Without Burning the Team Down)&lt;br&gt;
We learned that quality gates aren't about enforcement—they're about education and culture.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;We Gave Developers "The Fix Window"
Instead of blocking merges immediately, we allowed a 4-hour grace period for developers to fix quality issues before the gate locked. This removed the panic and gave them ownership.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Result: 80% of issues were fixed within 2 hours, without anyone feeling ambushed.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;We Introduced "Quality Debt Sprints"
Not every SonarQube issue is critical. We categorized:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Blockers (security, crashes): Merge-blocking&lt;/p&gt;

&lt;p&gt;Critical (performance, major bugs): Auto-fail after 24 hours&lt;/p&gt;

&lt;p&gt;Minor (code smells, duplication): Tracked, but not blocking&lt;/p&gt;

&lt;p&gt;This meant developers could ship features while still paying down tech debt in dedicated sprints.&lt;/p&gt;

&lt;p&gt;Result: Technical debt reduction became a team sport, not a punishment.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;We Integrated SonarQube Earlier
We moved quality checks to pre-commit hooks and local IDE plugins. Developers caught issues before they even opened a PR. The merge gate became the final checkpoint, not the first surprise.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Result: Merge time dropped from 6 hours to 2 hours because PRs weren't going back-and-forth over quality issues.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;We Celebrated Quality Wins
We started tracking "Clean PRs" (zero SonarQube issues) and "Quality Champions" in our standups. We turned code quality into a badge of honor, not a bureaucratic chore.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Result: Developers started competing to have the cleanest code. Yes, really.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;We Adjusted the Rules Over Time
Our initial quality gate was too strict. After two weeks, we relaxed thresholds on code duplication and cognitive complexity. We tuned the gate to our team's reality, not SonarQube's defaults.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Result: Compliance went from 40% to 95% because the rules made sense.&lt;/p&gt;

&lt;p&gt;The Hidden Benefit Nobody Talks About&lt;br&gt;
Beyond the numbers, the real win was confidence.&lt;/p&gt;

&lt;p&gt;When we merged code, we knew it was solid. We stopped second-guessing. We stopped fearing deployments. Product managers started trusting engineering timelines because "production issues" no longer meant "emergency meeting at 2 AM."&lt;/p&gt;

&lt;p&gt;The 20% drop in merges wasn't a slowdown—it was a shift from quantity to quality. We shipped fewer PRs, but each one was more valuable, more stable, and less likely to cause a fire.&lt;/p&gt;

&lt;p&gt;Velocity isn't about how fast you move. It's about how fast you move forward without breaking things.&lt;/p&gt;

&lt;p&gt;What We'd Do Differently Next Time&lt;br&gt;
Looking back, I'd make three changes:&lt;/p&gt;

&lt;p&gt;Communicate sooner. We rolled out the quality gate without enough developer buy-in. I'd spend two weeks building consensus first.&lt;/p&gt;

&lt;p&gt;Start with a "soft" gate. Instead of full enforcement, we should have run SonarQube in "monitor-only" mode for a month, then gradually turned on enforcement.&lt;/p&gt;

&lt;p&gt;Pair the gate with automated fixing. SonarQube can auto-fix many issues. We should have enabled that from day one.&lt;/p&gt;

&lt;p&gt;The Verdict: Worth Every Tantrum&lt;br&gt;
Yes, developers complained. Yes, merges dropped. Yes, the first month was painful.&lt;/p&gt;

&lt;p&gt;But today:&lt;/p&gt;

&lt;p&gt;Our production is stable&lt;/p&gt;

&lt;p&gt;Our team is confident&lt;/p&gt;

&lt;p&gt;Our customers are happier&lt;/p&gt;

&lt;p&gt;Our on-call rotation is peaceful&lt;/p&gt;

&lt;p&gt;SonarQube quality gates aren't about being the "code police." They're about being professional. They're about saying, "We care enough about our craft to ship code we're proud of."&lt;/p&gt;

&lt;p&gt;Would we do it again? Absolutely.&lt;/p&gt;

&lt;p&gt;Would I recommend it to other teams? Yes—but with the human element in mind.&lt;/p&gt;

&lt;p&gt;Your Turn: 5 Steps to Implementing Quality Gates Without Losing Your Team&lt;br&gt;
Run SonarQube in monitoring mode for 2 weeks. Collect data. Share it transparently.&lt;/p&gt;

&lt;p&gt;Involve developers in setting thresholds. Make it a team decision, not a mandate.&lt;/p&gt;

&lt;p&gt;Start with a soft gate (block only security vulnerabilities and bugs). Expand gradually.&lt;/p&gt;

&lt;p&gt;Automate fixes where possible (formatting, simple refactors, dependency updates).&lt;/p&gt;

&lt;p&gt;Celebrate quality improvements in team meetings. Make it a positive, not a punitive, force.&lt;/p&gt;

&lt;p&gt;Final thought: The goal isn't perfect code. The goal is intentional code—code that's been reviewed, refined, and respected. SonarQube just helps us get there faster.&lt;/p&gt;

&lt;p&gt;The tantrums will fade. The quality will remain.&lt;/p&gt;

&lt;p&gt;Have you implemented quality gates? Did your team revolt or rally? I'd love to hear your war stories in the comments below.&lt;/p&gt;

&lt;p&gt;🔔 Follow me for more honest engineering stories, lessons from the trenches, and practical advice that actually works&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How We Banished "Latest" and Made Rollbacks Instant</title>
      <dc:creator>Sohana Akbar</dc:creator>
      <pubDate>Sun, 19 Jul 2026 09:33:15 +0000</pubDate>
      <link>https://dev.to/sohanaakbar7/how-we-banished-latest-and-made-rollbacks-instant-3fpa</link>
      <guid>https://dev.to/sohanaakbar7/how-we-banished-latest-and-made-rollbacks-instant-3fpa</guid>
      <description>&lt;p&gt;Stop using :latest in production. Here’s why—and how we did it.&lt;/p&gt;

&lt;p&gt;The Problem We All Know&lt;br&gt;
It starts innocently enough:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
docker build -t myapp:latest .&lt;br&gt;
docker push myapp:latest&lt;br&gt;
You deploy. It works. You move on.&lt;/p&gt;

&lt;p&gt;Then Monday morning hits. Your monitoring dashboard turns red. Users are complaining. Something broke in the last deploy—but what exactly changed? The :latest tag moved yesterday, then again this morning, and maybe once more during that hotfix at 2 AM.&lt;/p&gt;

&lt;p&gt;Good luck figuring out which version is actually running.&lt;/p&gt;

&lt;p&gt;Our Breaking Point&lt;br&gt;
We had a "stable" production environment. We had CI pipelines. We had rollback scripts. And yet, every incident turned into a forensic investigation:&lt;/p&gt;

&lt;p&gt;"Which commit is this container running?"&lt;/p&gt;

&lt;p&gt;"Did that hotfix make it in?"&lt;/p&gt;

&lt;p&gt;"Is staging on the same version as production?"&lt;/p&gt;

&lt;p&gt;"Can I even roll back to yesterday's image?"&lt;/p&gt;

&lt;p&gt;The answer was almost always: ¯_(ツ)_/¯&lt;/p&gt;

&lt;p&gt;Worst of all—rollbacks weren't truly instant. We'd revert code, rebuild, repush, and redeploy. That's 8-12 minutes of downtime during an active incident.&lt;/p&gt;

&lt;p&gt;The Decision: Ban :latest in Production&lt;br&gt;
We made a drastic but simple rule:&lt;/p&gt;

&lt;p&gt;No :latest tag is ever pushed to our production ECR repository.&lt;/p&gt;

&lt;p&gt;Not "discouraged." Not "we'll phase it out."&lt;/p&gt;

&lt;p&gt;Banned. Blocked. Period.&lt;/p&gt;

&lt;p&gt;What We Did Instead&lt;br&gt;
Every image gets tagged with its commit hash (shortened to 7 characters):&lt;/p&gt;

&lt;p&gt;bash&lt;/p&gt;

&lt;h1&gt;
  
  
  In our CI pipeline
&lt;/h1&gt;

&lt;p&gt;COMMIT_HASH=$(git rev-parse --short HEAD)&lt;br&gt;
docker build -t myapp:$COMMIT_HASH .&lt;br&gt;
docker push myapp:$COMMIT_HASH&lt;br&gt;
That's it. No :latest. No environment-specific tags. Just the immutable commit hash.&lt;/p&gt;

&lt;p&gt;The Architecture&lt;br&gt;
Here's what our ECR repo looks like now:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
123456789012.dkr.ecr.us-east-1.amazonaws.com/myapp&lt;br&gt;
├── abc1234   # commit hash&lt;br&gt;
├── def5678&lt;br&gt;
├── ghi9012&lt;br&gt;
├── jkl3456&lt;br&gt;
└── mno7890&lt;br&gt;
Every hash is immutable. Once pushed, it never changes. No overwrites. No ambiguity.&lt;/p&gt;

&lt;p&gt;Deployments Become Explicit&lt;br&gt;
Our deployment manifest (Kubernetes, ECS, etc.) now references the exact commit hash:&lt;/p&gt;

&lt;p&gt;yaml&lt;/p&gt;

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

&lt;p&gt;image: 123456789012.dkr.ecr.us-east-1.amazonaws.com/myapp:abc1234&lt;br&gt;
No more abstract "latest" that means different things at different times. The deployment manifest tells you exactly what's running.&lt;/p&gt;

&lt;p&gt;The Magic: Instant Rollbacks&lt;br&gt;
This is where everything changed.&lt;/p&gt;

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

&lt;p&gt;text&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Detect incident (2 min)&lt;/li&gt;
&lt;li&gt;Identify bad commit (5-15 min)&lt;/li&gt;
&lt;li&gt;Revert code in Git (3 min)&lt;/li&gt;
&lt;li&gt;CI builds new image (5-8 min)&lt;/li&gt;
&lt;li&gt;Push to ECR (2 min)&lt;/li&gt;
&lt;li&gt;Deploy (2 min)
Total: 19-32 minutes
After:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;text&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Detect incident (2 min)&lt;/li&gt;
&lt;li&gt;Find previous known-good commit hash&lt;/li&gt;
&lt;li&gt;Update manifest and redeploy (2 min)
Total: 4 minutes
Because every commit hash is an immutable, ready-to-run image in ECR, rollback is just a manifest change. No rebuild. No repush. No waiting.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We went from 20+ minute rollbacks to under 5 minutes.&lt;/p&gt;

&lt;p&gt;How We Enforced It&lt;br&gt;
We implemented three layers of protection:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;CI Pipeline Enforcement
bash
# Block &lt;code&gt;:latest&lt;/code&gt; pushes to production ECR
if [[ "$ECR_REPO" == "production" &amp;amp;&amp;amp; "$IMAGE_TAG" == "latest" ]]; then
echo "❌ Cannot push :latest to production ECR"
exit 1
fi&lt;/li&gt;
&lt;li&gt;&lt;p&gt;ECR Lifecycle Policy&lt;br&gt;
json&lt;br&gt;
{&lt;br&gt;
"rules": [&lt;br&gt;
{&lt;br&gt;
  "rulePriority": 1,&lt;br&gt;
  "description": "Expire all untagged images",&lt;br&gt;
  "selection": {&lt;br&gt;
    "tagStatus": "untagged",&lt;br&gt;
    "countType": "imageCountMoreThan",&lt;br&gt;
    "countNumber": 100&lt;br&gt;
  },&lt;br&gt;
  "action": {&lt;br&gt;
    "type": "expire"&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
]&lt;br&gt;
}&lt;br&gt;
We also expire old images after 90 days to keep costs manageable.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Terraform/CloudFormation Guardrails&lt;br&gt;
Our infrastructure-as-code templates block any deployment that doesn't specify an explicit, non-latest tag:&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;hcl&lt;/p&gt;

&lt;h1&gt;
  
  
  Terraform validation
&lt;/h1&gt;

&lt;p&gt;variable "image_tag" {&lt;br&gt;
  type        = string&lt;br&gt;
  validation {&lt;br&gt;
    condition     = var.image_tag != "latest"&lt;br&gt;
    error_message = "Cannot use 'latest' tag in production."&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
The Unexpected Benefits&lt;br&gt;
Once we banned :latest, we discovered perks we hadn't anticipated:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Instant Audit Trail&lt;br&gt;
Each deployment is tied to a Git commit SHA. You can trace every container back to its source code, CI build logs, and the exact moment it was created.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Zero Ambiguity&lt;br&gt;
"Which version is running?" Look at the tag. No guessing. No "well, it's latest from yesterday."&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Simplified Debugging&lt;br&gt;
When developers SSH into a container, docker inspect shows the commit hash. They know exactly which code they're debugging.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Safe Canary Deployments&lt;br&gt;
We can deploy abc1234 to 10% of traffic and def5678 to 90%, comparing metrics without any tagging confusion.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Cleaner Rollforward&lt;br&gt;
If the rollback fixes the issue, we can later redeploy the same hash or a new one—all without tagging collisions.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What About Staging and Dev?&lt;br&gt;
We kept :latest for non-production environments:&lt;/p&gt;

&lt;p&gt;Dev: :latest is fine for rapid iteration.&lt;/p&gt;

&lt;p&gt;Staging: We use commit hashes too, but allow :latest as an alias for convenience.&lt;/p&gt;

&lt;p&gt;The production repo is the only one with the strict ban.&lt;/p&gt;

&lt;p&gt;The One Thing to Watch Out For&lt;br&gt;
Image bloat.&lt;/p&gt;

&lt;p&gt;If you push every commit to ECR and never clean up, your storage costs will creep up. Our solution:&lt;/p&gt;

&lt;p&gt;Lifecycle rule: Expire images older than 90 days.&lt;/p&gt;

&lt;p&gt;Selective retention: Keep the last 50 images regardless of age (for emergency rollbacks).&lt;/p&gt;

&lt;p&gt;Manual archives: For major releases, we pin specific hashes as "golden images" with infinite retention.&lt;/p&gt;

&lt;p&gt;The Results (By The Numbers)&lt;br&gt;
After 6 months with this system:&lt;/p&gt;

&lt;p&gt;Metric  Before  After&lt;br&gt;
Avg rollback time   22 min  4 min&lt;br&gt;
Rollback success rate   72% 98%&lt;br&gt;
Incident resolution time    45 min  18 min&lt;br&gt;
Developer confusion High    Zero&lt;br&gt;
"What's deployed?" questions    Daily   Never&lt;br&gt;
How to Implement This Tomorrow&lt;br&gt;
Stop pushing :latest to prod today. Seriously. Just stop.&lt;/p&gt;

&lt;p&gt;Use git rev-parse --short HEAD as your primary tag.&lt;/p&gt;

&lt;p&gt;Update your CD pipeline to reference explicit hashes in manifests.&lt;/p&gt;

&lt;p&gt;Store the current production hash somewhere (we use a simple S3 file with the hash and timestamp).&lt;/p&gt;

&lt;p&gt;Test a rollback right now—manually update your manifest to a known-good hash and redeploy. Time it.&lt;/p&gt;

&lt;p&gt;Celebrate when it takes &amp;lt; 5 minutes.&lt;/p&gt;

&lt;p&gt;The Bottom Line&lt;br&gt;
docker push myapp:latest is a developer convenience, not a production strategy.&lt;/p&gt;

&lt;p&gt;Banning :latest from our ECR repo was one of the simplest, highest-impact changes we made to our deployment pipeline. It didn't require rewriting architecture or buying new tools. It just required a rule—and the discipline to enforce it.&lt;/p&gt;

&lt;p&gt;Rollbacks went from a stressful, multi-step ordeal to a boring, scripted operation.&lt;/p&gt;

&lt;p&gt;And boring operations are the best kind.&lt;/p&gt;

&lt;p&gt;Your turn: Are you still using :latest in production? What's stopping you from switching?&lt;/p&gt;

&lt;p&gt;Follow me for more production engineering lessons learned the hard way.&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>docker</category>
      <category>sre</category>
    </item>
    <item>
      <title>How We Caught 12 Breaking API Changes Before They Hit Main: Our Journey to Ephemeral Staging Environments</title>
      <dc:creator>Sohana Akbar</dc:creator>
      <pubDate>Sat, 18 Jul 2026 09:20:49 +0000</pubDate>
      <link>https://dev.to/sohanaakbar7/how-we-caught-12-breaking-api-changes-before-they-hit-main-our-journey-to-ephemeral-staging-4gip</link>
      <guid>https://dev.to/sohanaakbar7/how-we-caught-12-breaking-api-changes-before-they-hit-main-our-journey-to-ephemeral-staging-4gip</guid>
      <description>&lt;p&gt;The moment we realized our staging environment was broken&lt;/p&gt;

&lt;p&gt;It was 3 PM on a Thursday, and our team was scrambling. A critical API change had just been merged to main, but the staging environment—our supposed safety net—was showing false positives. The integration tests passed, but the mobile app was completely broken in production.&lt;/p&gt;

&lt;p&gt;That's when we knew: our shared staging environment was failing us.&lt;/p&gt;

&lt;p&gt;The Problem: Shared Staging Is Broken by Design&lt;br&gt;
Like many engineering teams, we operated with a single, shared staging environment. Every developer deployed their changes to the same place, leading to:&lt;/p&gt;

&lt;p&gt;Deployment conflicts: "Who deployed that breaking change?"&lt;/p&gt;

&lt;p&gt;Cascading failures: One broken PR would block the entire team&lt;/p&gt;

&lt;p&gt;Test contamination: Data from one test would leak into another&lt;/p&gt;

&lt;p&gt;Delayed feedback: You'd only discover issues after merging your PR and deploying to staging&lt;/p&gt;

&lt;p&gt;The "works on my machine" syndrome, now at scale&lt;/p&gt;

&lt;p&gt;The worst part? Our API contracts were changing constantly, but we only discovered breaking changes during integration testing—often too late.&lt;/p&gt;

&lt;p&gt;The Solution: Ephemeral Environments per PR&lt;br&gt;
We made a radical change: every PR gets its own isolated, short-lived environment.&lt;/p&gt;

&lt;p&gt;Here's our architecture:&lt;/p&gt;

&lt;p&gt;Our Implementation Stack&lt;br&gt;
Infrastructure: Kubernetes (EKS) with namespace-per-PR&lt;/p&gt;

&lt;p&gt;Orchestration: Custom GitHub Action workflow&lt;/p&gt;

&lt;p&gt;Database: Isolated RDS instance per environment&lt;/p&gt;

&lt;p&gt;Contract Testing: Pact flow + OpenAPI validation&lt;/p&gt;

&lt;p&gt;Cleanup: AWS Lambda that runs every hour, destroying environments older than 2 hours&lt;/p&gt;

&lt;p&gt;The Game Changer: Automated Contract Testing&lt;br&gt;
The magic wasn't just in isolated environments—it was in what we did with them. Every time a PR deployed to its ephemeral environment, we ran:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Consumer-Driven Contract Testing (Pact)&lt;br&gt;
Our mobile and web clients would verify their expectations against the actual deployed API. If a change broke what the client expected, the PR would fail.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Provider Contract Validation&lt;br&gt;
We'd automatically verify that the deployed API matched our OpenAPI specification. If you added a required field without updating the spec, you'd know immediately.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Schema Diff Detection&lt;br&gt;
We compared the new API schema against the production baseline. Any breaking changes (removing fields, changing types, adding required properties) would trigger a review.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Integration Smoke Tests&lt;br&gt;
Each environment ran a suite of end-to-end tests with real client applications connecting to the backend.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Results: 12 Breaking Changes Caught&lt;br&gt;
In our first month with ephemeral environments, we caught 12 breaking API contract changes that would have:&lt;/p&gt;

&lt;p&gt;Crashed our mobile app&lt;/p&gt;

&lt;p&gt;Broken third-party integrations&lt;/p&gt;

&lt;p&gt;Caused data corruption in production&lt;/p&gt;

&lt;p&gt;Required emergency rollbacks&lt;/p&gt;

&lt;p&gt;One Real Example&lt;br&gt;
A developer modified the User object to rename user_id to userId for consistency. The OpenAPI spec was updated, but the mobile app's contract test immediately caught the mismatch:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
❌ Contract violation: Expected 'user_id' but found 'userId'&lt;br&gt;
Breaking change detected in GET /api/v2/users/123&lt;br&gt;
The PR was blocked, the issue was fixed in 30 minutes, and the mobile app never broke.&lt;/p&gt;

&lt;p&gt;The Economics: Cost vs. Value&lt;br&gt;
You might think: "Ephemeral environments sound expensive." Let's do the math:&lt;/p&gt;

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

&lt;p&gt;1 shared staging environment running 24/7: $500/month&lt;/p&gt;

&lt;p&gt;1 production outage per month: $10,000+ in lost revenue&lt;/p&gt;

&lt;p&gt;Developer hours wasted debugging environment issues: 40+ hours/month&lt;/p&gt;

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

&lt;p&gt;Ephemeral environments run ~8 hours/day (active PRs): ~$300/month&lt;/p&gt;

&lt;p&gt;0 production-breaking API changes in 3 months&lt;/p&gt;

&lt;p&gt;Developer productivity increased by ~30%&lt;/p&gt;

&lt;p&gt;Net result: We're saving money AND shipping faster.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Start Small, Scale Smart&lt;br&gt;
We didn't spin up full environments for every PR immediately. We started with just the critical backend services.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Make Cleanup Aggressive&lt;br&gt;
2-hour TTL seemed short initially, but developers learned to work faster. For long-running PRs, they could request extensions.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Invest in Observability&lt;br&gt;
Each environment had its own logging and metrics. We could debug failures without affecting others.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Education is Key&lt;br&gt;
Developers needed to understand why userId vs user_id matters. We created "Breaking Change 101" docs.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Contract Tests Are Only Half the Battle&lt;br&gt;
We also needed:&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Load testing (some issues only appear under load)&lt;/p&gt;

&lt;p&gt;Security scanning&lt;/p&gt;

&lt;p&gt;Database migration testing&lt;/p&gt;

&lt;p&gt;The Future: Beyond API Contracts&lt;br&gt;
Now that we have the infrastructure, we're expanding:&lt;/p&gt;

&lt;p&gt;Database migrations: Test schema changes on a copy of production data&lt;/p&gt;

&lt;p&gt;Feature flags: Test features in isolation before global rollout&lt;/p&gt;

&lt;p&gt;Performance benchmarks: Detect performance regressions per PR&lt;/p&gt;

&lt;p&gt;Security scanning: Run vulnerability scans on each environment&lt;/p&gt;

&lt;p&gt;Should You Do This?&lt;br&gt;
Yes, if:&lt;/p&gt;

&lt;p&gt;You have breaking changes that go to production&lt;/p&gt;

&lt;p&gt;Your staging environment is a bottleneck&lt;/p&gt;

&lt;p&gt;You have multiple services with dependencies&lt;/p&gt;

&lt;p&gt;You want to ship faster with more confidence&lt;/p&gt;

&lt;p&gt;Not yet, if:&lt;/p&gt;

&lt;p&gt;You're a pre-revenue startup with no customers&lt;/p&gt;

&lt;p&gt;Your infrastructure is manual and fragile&lt;/p&gt;

&lt;p&gt;You're still working toward basic CI/CD&lt;/p&gt;

&lt;p&gt;Getting Started: A Practical Roadmap&lt;br&gt;
Week 1-2: Automation Foundation&lt;/p&gt;

&lt;p&gt;Set up infrastructure-as-code (Terraform/CDK)&lt;/p&gt;

&lt;p&gt;Create scripts to spin up/down environments&lt;/p&gt;

&lt;p&gt;Define environment variables and secrets management&lt;/p&gt;

&lt;p&gt;Week 3-4: Core Services&lt;/p&gt;

&lt;p&gt;Start with your most critical microservice&lt;/p&gt;

&lt;p&gt;Deploy it to an ephemeral environment per PR&lt;/p&gt;

&lt;p&gt;Run basic smoke tests&lt;/p&gt;

&lt;p&gt;Week 5-6: Contract Testing&lt;/p&gt;

&lt;p&gt;Implement Pact or OpenAPI validation&lt;/p&gt;

&lt;p&gt;Create test suites that verify contracts&lt;/p&gt;

&lt;p&gt;Set up CI/CD integration&lt;/p&gt;

&lt;p&gt;Week 7-8: Scale and Optimize&lt;/p&gt;

&lt;p&gt;Add more services to the stack&lt;/p&gt;

&lt;p&gt;Fine-tune resource allocation&lt;/p&gt;

&lt;p&gt;Implement cost monitoring&lt;/p&gt;

&lt;p&gt;Week 9+: Iterate&lt;/p&gt;

&lt;p&gt;Gather developer feedback&lt;/p&gt;

&lt;p&gt;Add database support&lt;/p&gt;

&lt;p&gt;Enhance observability&lt;/p&gt;

&lt;p&gt;The Bottom Line&lt;br&gt;
Ephemeral staging environments transformed how we build software. We've gone from "breaking changes are inevitable" to "breaking changes are caught in PRs."&lt;/p&gt;

&lt;p&gt;The 12 breaking changes we caught weren't just bugs—they were 12 production incidents that never happened.&lt;/p&gt;

&lt;p&gt;That's not just a win for engineering. It's a win for every user who depends on our software.&lt;/p&gt;

&lt;p&gt;Have you implemented ephemeral environments? What challenges did you face? Let's discuss in the comments!&lt;/p&gt;

&lt;p&gt;Resources to Get Started&lt;br&gt;
Kubernetes Namespaces: Kubernetes.io docs&lt;/p&gt;

&lt;p&gt;Pact Contract Testing: Pact.io&lt;/p&gt;

&lt;p&gt;GitHub Actions for Preview Deployments: GitHub Docs&lt;/p&gt;

&lt;p&gt;AWS EKS Ephemeral Environments: AWS Quick Start&lt;/p&gt;

&lt;p&gt;About the Author: I'm a lead engineer at a growing SaaS company, passionate about developer productivity and reliable systems. You can find me on GitHub and Twitter.&lt;/p&gt;

&lt;p&gt;Have questions about implementing this in your org? Reach out—I'm happy to share our battle scars!&lt;/p&gt;

</description>
      <category>api</category>
      <category>cicd</category>
      <category>devops</category>
      <category>testing</category>
    </item>
    <item>
      <title>From 15 Daily Deployments to Zero Downtime: Our Next.js ECS Fargate Journey</title>
      <dc:creator>Sohana Akbar</dc:creator>
      <pubDate>Thu, 16 Jul 2026 09:57:53 +0000</pubDate>
      <link>https://dev.to/sohanaakbar7/from-15-daily-deployments-to-zero-downtime-our-nextjs-ecs-fargate-journey-35ib</link>
      <guid>https://dev.to/sohanaakbar7/from-15-daily-deployments-to-zero-downtime-our-nextjs-ecs-fargate-journey-35ib</guid>
      <description>&lt;p&gt;Zero-downtime isn't a buzzword; it's an Application Load Balancer + graceful shutdown config I tweaked for 2 weeks.&lt;/p&gt;

&lt;p&gt;The Reality of 15 Deployments a Day&lt;br&gt;
When your team deploys 15 times a day, every second of downtime multiplies fast. A 5-second blip becomes 75 seconds of cumulative errors. A 30-second rolling restart becomes 7.5 minutes of degraded service. Every. Single. Day.&lt;/p&gt;

&lt;p&gt;We run Next.js on ECS Fargate, and achieving true zero-downtime took me two weeks of late nights, CloudWatch deep-dives, and a love-hate relationship with AWS load balancer health checks.&lt;/p&gt;

&lt;p&gt;Here's exactly what worked—and what didn't.&lt;/p&gt;

&lt;p&gt;The Architecture Snapshot&lt;br&gt;
text&lt;br&gt;
Internet → ALB (port 443) → Target Group → ECS Fargate (Next.js)&lt;br&gt;
                                   ↓&lt;br&gt;
                            Container Health Checks&lt;br&gt;
                                   ↓&lt;br&gt;
                         Graceful Shutdown (SIGTERM)&lt;br&gt;
Stack:&lt;/p&gt;

&lt;p&gt;Next.js 14 (App Router, standalone output)&lt;/p&gt;

&lt;p&gt;AWS ECS Fargate (capacity provider: FARGATE_SPOT)&lt;/p&gt;

&lt;p&gt;Application Load Balancer (not Classic, not NLB)&lt;/p&gt;

&lt;p&gt;Docker images ~450MB (yes, we're working on it)&lt;/p&gt;

&lt;p&gt;Phase 1: What "Zero-Downtime" Actually Means Here&lt;br&gt;
Let's kill the buzzword. For us, zero-downtime means:&lt;/p&gt;

&lt;p&gt;No 5xx errors during deployment (ALB 504s? Gone.)&lt;/p&gt;

&lt;p&gt;No dropped connections for in-flight requests&lt;/p&gt;

&lt;p&gt;Sub-100ms p99 latency increase during rolling updates&lt;/p&gt;

&lt;p&gt;Complete rollback capability in under 90 seconds&lt;/p&gt;

&lt;p&gt;Phase 2: The ALB Setup That Finally Worked&lt;br&gt;
Target Group Health Check Settings&lt;br&gt;
This is where I spent most of my two weeks. The defaults are wrong for Next.js.&lt;/p&gt;

&lt;p&gt;yaml&lt;br&gt;
HealthCheckProtocol: HTTP&lt;br&gt;
HealthCheckPath: /api/health    # NOT / (more on this)&lt;br&gt;
HealthyThresholdCount: 2&lt;br&gt;
UnhealthyThresholdCount: 3&lt;br&gt;
HealthCheckTimeoutSeconds: 5&lt;br&gt;
HealthCheckIntervalSeconds: 15&lt;br&gt;
Why /api/health over /?&lt;/p&gt;

&lt;p&gt;Next.js / does SSR, which can be slow and fetch from APIs/databases&lt;/p&gt;

&lt;p&gt;/api/health is a lightweight, serverless function that returns { status: 'ok' }&lt;/p&gt;

&lt;p&gt;Your health check should test the runtime, not the app logic&lt;/p&gt;

&lt;p&gt;Deregistration Delay (Draining)&lt;br&gt;
This is the silent killer of zero-downtime:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
DeregistrationDelay: 120 seconds&lt;br&gt;
Why 120 seconds? Our average request duration is ~200ms, p99 is ~4.5 seconds. The default 300 seconds is too long (keeps unhealthy containers alive), but 60 seconds is too short for long-running Next.js server actions. 120 was our Goldilocks number.&lt;/p&gt;

&lt;p&gt;Phase 3: The Container Health Check (The Real MVP)&lt;br&gt;
Your Docker health check isn't optional. It's the difference between ECS leaving a broken container running for 3 minutes and killing it in 15 seconds.&lt;/p&gt;

&lt;p&gt;dockerfile&lt;br&gt;
HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=3 \&lt;br&gt;
  CMD curl -f &lt;a href="http://localhost:3000/api/health" rel="noopener noreferrer"&gt;http://localhost:3000/api/health&lt;/a&gt; || exit 1&lt;br&gt;
Pro tip: The start-period is critical. Next.js takes ~18 seconds to boot on Fargate (node_modules, compilation). Without this, ECS will kill your container before it even starts serving traffic.&lt;/p&gt;

&lt;p&gt;Phase 4: Next.js Graceful Shutdown (The Hard Part)&lt;br&gt;
This was the hardest piece to nail. Next.js doesn't handle SIGTERM gracefully by default—it just dies.&lt;/p&gt;

&lt;p&gt;The Solution: Custom Server with Signal Handling&lt;br&gt;
We migrated from next start to a custom server:&lt;/p&gt;

&lt;p&gt;javascript&lt;br&gt;
// server.js&lt;br&gt;
const { createServer } = require('http');&lt;br&gt;
const next = require('next');&lt;/p&gt;

&lt;p&gt;const app = next({ dev: false });&lt;br&gt;
const handle = app.getRequestHandler();&lt;/p&gt;

&lt;p&gt;let server;&lt;/p&gt;

&lt;p&gt;app.prepare().then(() =&amp;gt; {&lt;br&gt;
  server = createServer((req, res) =&amp;gt; handle(req, res));&lt;/p&gt;

&lt;p&gt;server.listen(3000, () =&amp;gt; {&lt;br&gt;
    console.log('Next.js ready on 3000');&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;// --- GRACEFUL SHUTDOWN ---&lt;br&gt;
  const shutdown = () =&amp;gt; {&lt;br&gt;
    console.log('Received SIGTERM, starting graceful shutdown...');&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Stop accepting new connections
server.close(() =&amp;gt; {
  console.log('Closed all connections, exiting.');
  process.exit(0);
});

// Force exit after timeout
setTimeout(() =&amp;gt; {
  console.error('Force exiting after timeout.');
  process.exit(1);
}, 30000); // ALB draining timeout - 10s buffer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;};&lt;/p&gt;

&lt;p&gt;process.on('SIGTERM', shutdown);&lt;br&gt;
  process.on('SIGINT', shutdown);&lt;br&gt;
});&lt;br&gt;
Dockerfile Entrypoint&lt;br&gt;
dockerfile&lt;br&gt;
ENTRYPOINT ["node", "server.js"]&lt;br&gt;
NOT npm start or next start. We need process-level control.&lt;/p&gt;

&lt;p&gt;Phase 5: ECS Task Definition (The Final Config)&lt;br&gt;
json&lt;br&gt;
{&lt;br&gt;
  "family": "nextjs-app",&lt;br&gt;
  "networkMode": "awsvpc",&lt;br&gt;
  "containerDefinitions": [{&lt;br&gt;
    "name": "nextjs",&lt;br&gt;
    "image": "...",&lt;br&gt;
    "essential": true,&lt;br&gt;
    "portMappings": [{&lt;br&gt;
      "containerPort": 3000,&lt;br&gt;
      "hostPort": 3000,&lt;br&gt;
      "protocol": "tcp"&lt;br&gt;
    }],&lt;br&gt;
    "healthCheck": {&lt;br&gt;
      "command": ["CMD-SHELL", "curl -f &lt;a href="http://localhost:3000/api/health" rel="noopener noreferrer"&gt;http://localhost:3000/api/health&lt;/a&gt; || exit 1"],&lt;br&gt;
      "interval": 10,&lt;br&gt;
      "timeout": 3,&lt;br&gt;
      "retries": 3,&lt;br&gt;
      "startPeriod": 30&lt;br&gt;
    },&lt;br&gt;
    "linuxParameters": {&lt;br&gt;
      "capabilities": {&lt;br&gt;
        "add": ["NET_ADMIN"]  // For container-level networking&lt;br&gt;
      }&lt;br&gt;
    }&lt;br&gt;
  }],&lt;br&gt;
  "requiresCompatibilities": ["FARGATE"],&lt;br&gt;
  "executionRoleArn": "...",&lt;br&gt;
  "taskRoleArn": "..."&lt;br&gt;
}&lt;br&gt;
Phase 6: The Deployment Flow That Actually Works&lt;br&gt;
text&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Build Next.js (standalone output)&lt;/li&gt;
&lt;li&gt;Docker build (multi-stage, ~450MB → 180MB with standalone)&lt;/li&gt;
&lt;li&gt;Push to ECR&lt;/li&gt;
&lt;li&gt;ECS update-service (force-new-deployment)&lt;/li&gt;
&lt;li&gt;ALB gracefully drains old tasks (120s deregistration)&lt;/li&gt;
&lt;li&gt;ECS starts new tasks with health check (30s start-period)&lt;/li&gt;
&lt;li&gt;ALB begins routing after 2 successful health checks (20s)&lt;/li&gt;
&lt;li&gt;Old tasks drain and terminate
Total impact: ~3 seconds of elevated latency during the handover. No errors. No dropped connections.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Phase 7: The Monitoring That Validates It&lt;br&gt;
We monitor every deployment with these CloudWatch alarms:&lt;/p&gt;

&lt;p&gt;yaml&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;TargetGroup.ResponseTime.p99 &amp;gt; 500ms (degraded performance)&lt;/li&gt;
&lt;li&gt;TargetGroup.UnHealthyHostCount &amp;gt; 0 (stuck)&lt;/li&gt;
&lt;li&gt;ECS.Service.utilization.memory &amp;gt; 85% (container strain)&lt;/li&gt;
&lt;li&gt;LoadBalancer.HTTPCode_ELB_5XX &amp;gt; 0 (the big red flag)
Deployment dashboards: We overlay deployment timestamps on latency graphs. No spikes = success.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Phase 8: What Almost Broke Us&lt;br&gt;
The Node.js Event Loop Issue: Next.js 14's server components can block the event loop during boot. Our health check timed out because the server was busy compiling. Solved by:&lt;/p&gt;

&lt;p&gt;javascript&lt;br&gt;
// next.config.js&lt;br&gt;
module.exports = {&lt;br&gt;
  experimental: {&lt;br&gt;
    optimizeServerReact: true,&lt;br&gt;
    serverMinification: true&lt;br&gt;
  },&lt;br&gt;
  compiler: {&lt;br&gt;
    removeConsole: process.env.NODE_ENV === 'production'&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
The ALB Sticky Sessions Problem: We use session affinity. During deployments, the ALB kept routing users to dying containers. Fixed by setting:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
StickinessEnabled: true&lt;br&gt;
StickinessType: lb_cookie&lt;br&gt;
CookieExpirationPeriod: 300  # 5 minutes&lt;br&gt;
This gave old containers enough time to drain existing sessions while new ones handled fresh connections.&lt;/p&gt;

&lt;p&gt;The Numbers After 2 Weeks&lt;br&gt;
Metric  Before  After&lt;br&gt;
Deployment errors (5xx) 3-8 per deploy  0&lt;br&gt;
Deployment latency spike    2-8 seconds &amp;lt;100ms&lt;br&gt;
P99 response time during rollout    ~3 seconds  ~250ms&lt;br&gt;
Failed deployments  ~10%    &amp;lt;1%&lt;br&gt;
Rollback time   ~3 minutes  ~90 seconds&lt;br&gt;
The "I Told You So" Moment&lt;br&gt;
Our VP of Engineering watched a deployment during peak traffic. No alerts. No errors. Just a Slack message: "Deploy #12,873 completed."&lt;/p&gt;

&lt;p&gt;He looked at me and said, "So it just... works now?"&lt;/p&gt;

&lt;p&gt;That was worth the two weeks.&lt;/p&gt;

&lt;p&gt;Key Takeaways for Your Setup&lt;br&gt;
Health checks matter more than you think. Use a lightweight endpoint, not the homepage.&lt;/p&gt;

&lt;p&gt;Deregistration delay is a balancing act. Too short = dropped requests. Too long = zombie containers.&lt;/p&gt;

&lt;p&gt;Custom server with SIGTERM handling is non-negotiable. Next.js's default isn't production-grade for high-frequency deploys.&lt;/p&gt;

&lt;p&gt;Start-period in Docker health checks saves you. Next.js needs warm-up time.&lt;/p&gt;

&lt;p&gt;Monitor everything. You can't fix what you can't measure.&lt;/p&gt;

&lt;p&gt;What's Next for Us&lt;br&gt;
CDN + Next.js ISR: Offload some traffic to reduce container load&lt;/p&gt;

&lt;p&gt;GitOps with ArgoCD: Automated rollbacks based on error budgets&lt;/p&gt;

&lt;p&gt;Canary deployments: 10% traffic to new version before full rollout&lt;/p&gt;

&lt;p&gt;Warm containers: Pre-warm a container before ECS kills the old one&lt;/p&gt;

&lt;p&gt;Final thought: Zero-downtime isn't a checkbox. It's a continuous, iterative process that requires understanding your application at the container, network, and framework level. Two weeks of tweaking ALB settings and health checks turned our deployment anxiety into deployment confidence.&lt;/p&gt;

&lt;p&gt;And now, 15 times a day, we deploy without a single user noticing.&lt;/p&gt;

&lt;p&gt;That's the win.&lt;/p&gt;

&lt;p&gt;Have you wrestled with ECS + Next.js deployments? Drop your war stories in the comments—I want to hear what broke for you.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>aws</category>
      <category>devops</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>The 2-Week Journey to Next.js Zero-Downtime on ECS Fargate</title>
      <dc:creator>Sohana Akbar</dc:creator>
      <pubDate>Wed, 15 Jul 2026 14:15:38 +0000</pubDate>
      <link>https://dev.to/sohanaakbar7/the-2-week-journey-to-nextjs-zero-downtime-on-ecs-fargate-1i4</link>
      <guid>https://dev.to/sohanaakbar7/the-2-week-journey-to-nextjs-zero-downtime-on-ecs-fargate-1i4</guid>
      <description>&lt;p&gt;Zero-downtime isn't just a buzzword we throw around in standups. It's the result of an Application Load Balancer and a graceful shutdown config that took me two weeks to dial in perfectly. And honestly? It was worth every minute.&lt;/p&gt;

&lt;p&gt;We deploy to ECS Fargate 15 times a day. Fifteen. That's not a flex—it's a necessity for our team's velocity. And with that many deployments, even a few seconds of downtime per deploy adds up fast. Here's how we cracked the code.&lt;/p&gt;

&lt;p&gt;Why We Chose ECS Fargate&lt;br&gt;
Let me save you the comparison analysis paralysis: we evaluated App Runner, Lambda with Web Adapter, and even EKS. App Runner looked tempting—dead simple setup—but it doesn't support our preferred region. Lambda? Cold starts and RDS connection management made it a non-starter for our Next.js SSR workloads.&lt;/p&gt;

&lt;p&gt;ECS Fargate hit the sweet spot: container-based (so our Docker workflow stayed intact), no EC2 management, and we're only paying for what the containers actually use. Plus, it plays beautifully with RDS, which we were already using for production data.&lt;/p&gt;

&lt;p&gt;The Architecture That Finally Worked&lt;br&gt;
Here's what our stack looks like after two weeks of tweaking:&lt;/p&gt;

&lt;p&gt;Route 53 handling DNS with ALIAS records pointing to our ALB&lt;/p&gt;

&lt;p&gt;Application Load Balancer with TLS 1.3 (because 2026, people) and HSTS headers&lt;/p&gt;

&lt;p&gt;ECS Fargate running our Next.js tasks in private subnets&lt;/p&gt;

&lt;p&gt;Secrets Manager + SSM Parameter Store for all environment variables (more on this later)&lt;/p&gt;

&lt;p&gt;CloudWatch Logs for centralized logging—non-negotiable for debugging&lt;/p&gt;

&lt;p&gt;The critical piece? Standalone output mode in Next.js. The docs aren't great, but once you figure out what to copy over, it's magic:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
COPY --from=build /app/.next/standalone ./&lt;br&gt;
COPY --from=build /app/public ./public&lt;br&gt;
COPY --from=build /app/.next/static ./.next/static&lt;br&gt;
Without this, your Docker images balloon in size and your startup times tank. With it? Lean, mean containers that spin up in seconds.&lt;/p&gt;

&lt;p&gt;The 2-Week Journey to Zero-Downtime&lt;br&gt;
Week 1: The Pain&lt;/p&gt;

&lt;p&gt;Our first attempt at zero-downtime was... optimistic. We thought the ALB's built-in health checks would handle everything. They didn't.&lt;/p&gt;

&lt;p&gt;We learned the hard way that ECS sends a SIGTERM signal when it scales down tasks. If your app doesn't handle it properly, it dies mid-request. Users see 503 errors. You see angry Slack messages.&lt;/p&gt;

&lt;p&gt;We started seeing unhealthy targets in the ALB—all of them timing out on health checks. The classic 503 Service Unavailable dance. After debugging networking layers, security groups, and container health check paths, we realized the problem was deeper.&lt;/p&gt;

&lt;p&gt;Week 2: The Fix&lt;/p&gt;

&lt;p&gt;The breakthrough came when we stopped thinking about the ALB as the solution and started thinking about it as part of the solution.&lt;/p&gt;

&lt;p&gt;Here's what finally worked:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Proper Graceful Shutdown Configuration
Your app needs to finish serving the current request before shutting down. No shortcuts. We configured:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;javascript&lt;br&gt;
// In your server setup&lt;br&gt;
process.on('SIGTERM', () =&amp;gt; {&lt;br&gt;
  server.close(() =&amp;gt; {&lt;br&gt;
    process.exit(0);&lt;br&gt;
  });&lt;br&gt;
});&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;ECS stopTimeout&lt;br&gt;
Set this in your task definition. Give your app enough time to gracefully handle in-flight requests before ECS force-kills it. We settled on 30 seconds—enough for most SSR operations to complete.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;ALB Health Check Tuning&lt;br&gt;
The default health check configuration won't cut it. We had to:&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Set a reasonable healthCheckGracePeriodSeconds in the ECS service (60-120 seconds to let tasks warm up)&lt;/p&gt;

&lt;p&gt;Tune the ALB target group's health check path to something lightweight but representative of a healthy app&lt;/p&gt;

&lt;p&gt;Ensure the health check path returns a 200 within 5 seconds—no heavy rendering for health checks&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Environment Variable Strategy
This one took time to get right. We eventually adopted a 5-category classification system:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Category    Storage Injected At Example&lt;br&gt;
Non-sensitive build-time    Task def environment    Container start NODE_ENV, PORT&lt;br&gt;
Non-sensitive runtime   SSM Standard    Container start API_URLs&lt;br&gt;
Semi-sensitive  SSM SecureString    Container start Client IDs&lt;br&gt;
Highly sensitive    Secrets Manager Container start DATABASE_URL, API keys&lt;br&gt;
Client-bundled  SSM → Docker build-arg    Build time  NEXT_PUBLIC_*&lt;br&gt;
This separation means secrets are never in the task definition JSON. They're referenced by ARN only, which makes the ECS console not a security liability.&lt;/p&gt;

&lt;p&gt;The Deploy Pipeline That Runs 15x Daily&lt;br&gt;
Our GitHub Actions workflow uses OIDC (no long-lived AWS keys) with environment-based trust policies:&lt;/p&gt;

&lt;p&gt;yaml&lt;br&gt;
name: Deploy to ECS&lt;br&gt;
on:&lt;br&gt;
  push:&lt;br&gt;
    branches: [main, develop]&lt;/p&gt;

&lt;p&gt;jobs:&lt;br&gt;
  deploy:&lt;br&gt;
    environment: ${{ github.ref == 'refs/heads/main' &amp;amp;&amp;amp; 'prod' || 'dev' }}&lt;br&gt;
    runs-on: ubuntu-latest&lt;br&gt;
    steps:&lt;br&gt;
      - Build Docker image with platform linux/amd64&lt;br&gt;
      - Push to ECR with tag: ${{ env }}-${{ sha }}&lt;br&gt;
      - Render and register new task definition&lt;br&gt;
      - Update ECS service with forced new deployment&lt;br&gt;
      - Wait for stability (with 10-minute timeout)&lt;br&gt;
The wait-for-service-stability step is crucial. Without it, we'd deploy, assume everything was fine, and only discover issues five minutes later when the health checks failed.&lt;/p&gt;

&lt;p&gt;What I'd Tell My Past Self&lt;br&gt;
If I could go back two weeks and give myself one piece of advice? Start with health checks, then add graceful shutdowns, then tune them together.&lt;/p&gt;

&lt;p&gt;They're not separate concerns. The ALB needs to know when a task is healthy enough to receive traffic. The task needs to know how to become healthy and how to become not healthy gracefully. They're a system, not independent configs.&lt;/p&gt;

&lt;p&gt;Also: use --platform linux/amd64 in your Docker builds if you're on an ARM Mac. I spent a day wondering why local builds worked but Fargate tasks crashed. Different architectures. Oops.&lt;/p&gt;

&lt;p&gt;The Results&lt;br&gt;
Fifteen deployments a day. Zero detected downtime for users.&lt;/p&gt;

&lt;p&gt;Is it over-engineered? Maybe. But when you're shipping multiple times a day and your product is critical to your business, "over-engineered" starts to look a lot like "properly engineered."&lt;/p&gt;

&lt;p&gt;The two weeks of configuration pain paid off in user trust. No more "we're deploying, give us 30 seconds" messages. No more 503 errors during peak traffic. Just seamless updates, every single time.&lt;/p&gt;

&lt;p&gt;And honestly? That's what zero-downtime should mean. Not a bullet point on a marketing page, but a real, measurable property of your deployment pipeline.&lt;/p&gt;

&lt;p&gt;Now if you'll excuse me, I have my 14th deployment of the day to trigger.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>infrastructure</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>How I Tamed Flaky E2E Tests and Boosted Build Stability from 89% to 99%</title>
      <dc:creator>Sohana Akbar</dc:creator>
      <pubDate>Tue, 14 Jul 2026 13:45:40 +0000</pubDate>
      <link>https://dev.to/sohanaakbar7/how-i-tamed-flaky-e2e-tests-and-boosted-build-stability-from-89-to-99-4l4j</link>
      <guid>https://dev.to/sohanaakbar7/how-i-tamed-flaky-e2e-tests-and-boosted-build-stability-from-89-to-99-4l4j</guid>
      <description>&lt;p&gt;The story of how a simple retry policy saved our team's sanity and our main branch&lt;/p&gt;

&lt;p&gt;The Breaking Point&lt;br&gt;
It was the third time in a month. Our main branch had gone red again. The culprit? A flaky E2E test that decided to fail for no good reason.&lt;/p&gt;

&lt;p&gt;Our team was frustrated. Developers were wasting hours re-running pipelines, investigating false positives, and context-switching away from feature development. Our build stability had plummeted to a dismal 89%, and trust in our CI/CD process was at an all-time low.&lt;/p&gt;

&lt;p&gt;Something had to change.&lt;/p&gt;

&lt;p&gt;The Diagnosis: Flaky Tests Are Not All Created Equal&lt;br&gt;
Before implementing any solution, I needed to understand why our E2E tests were flaky. After analyzing our failure patterns, I identified three main categories:&lt;/p&gt;

&lt;p&gt;Race conditions (40%): Tests expecting UI updates faster than they could render&lt;/p&gt;

&lt;p&gt;Network timing issues (35%): API responses taking slightly longer in CI than locally&lt;/p&gt;

&lt;p&gt;Environment-specific failures (25%): Browser quirks and CI resource contention&lt;/p&gt;

&lt;p&gt;The key insight? 80% of our flaky failures would pass on the very next run.&lt;/p&gt;

&lt;p&gt;The Solution: A Smart Retry Policy&lt;br&gt;
Instead of overhauling our entire test suite (which would take months), I implemented a pragmatic retry strategy:&lt;/p&gt;

&lt;p&gt;javascript&lt;br&gt;
// Example: Playwright retry configuration&lt;br&gt;
import { defineConfig } from '@playwright/test';&lt;/p&gt;

&lt;p&gt;export default defineConfig({&lt;br&gt;
  retries: process.env.CI ? 2 : 0, // Retry twice in CI only&lt;br&gt;
  workers: 4,&lt;br&gt;
  timeout: 60000,&lt;br&gt;
  // ... other config&lt;br&gt;
});&lt;br&gt;
The rule was simple:&lt;/p&gt;

&lt;p&gt;Local development: 0 retries (catch issues early)&lt;/p&gt;

&lt;p&gt;CI pipeline: 2 retries (give flaky tests a second chance)&lt;/p&gt;

&lt;p&gt;Only retry the specific failed test, not the entire suite&lt;/p&gt;

&lt;p&gt;The Results: What 10% Improvement Looks Like&lt;br&gt;
Metric  Before  After   Change&lt;br&gt;
Build stability 89% 99% +10%&lt;br&gt;
Developer productivity  3-4 hours/week wasted   &amp;lt;30 min/week    ~3 hours saved/developer&lt;br&gt;
Main branch breakages   3/month 0/month (so far)    100% reduction&lt;br&gt;
Team confidence Low High    Priceless&lt;br&gt;
Lessons Learned Along the Way&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Retry Policies Are a Band-Aid, Not a Cure
While retries immediately improved stability, they didn't fix the underlying issues. We made a pact to:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Investigate any test that needed both retries to pass&lt;/p&gt;

&lt;p&gt;Add logging around flaky areas to identify root causes&lt;/p&gt;

&lt;p&gt;Gradually rewrite the flakiest tests&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Communicate the Change
We sent a simple Slack message explaining the new policy:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;🔧 New Flaky Test Policy: We've added automatic retries (2x) for E2E tests in CI. If a test fails twice, we need to investigate. This should reduce false positives while we continue to stabilize our test suite.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Track Retry Metrics
We started monitoring how often retries actually helped:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;javascript&lt;br&gt;
// Track retry success rate&lt;br&gt;
const flakyTestMetrics = {&lt;br&gt;
  totalRuns: 1247,&lt;br&gt;
  retriesUsed: 89,&lt;br&gt;
  flakyPasses: 74, // 83% of retries were successful&lt;br&gt;
  persistentFailures: 15 // Tests that truly needed fixing&lt;br&gt;
};&lt;br&gt;
This data helped us prioritize which tests to permanently fix.&lt;/p&gt;

&lt;p&gt;What We Fixed Permanently&lt;br&gt;
Once the immediate crisis was over, we systematically addressed the root causes:&lt;/p&gt;

&lt;p&gt;Category    Fix Impact&lt;br&gt;
Race conditions Added explicit wait strategies (waitFor, expect.poll)   Reduced retries needed by 60%&lt;br&gt;
Network timing  Increased API timeouts in CI environment    Reduced retries needed by 25%&lt;br&gt;
Environment issues  Fixed browser versions and resource allocation  Reduced retries needed by 15%&lt;br&gt;
The Bottom Line&lt;br&gt;
Retries are not a silver bullet. They're a pragmatic tool to stop bleeding while you heal the wound.&lt;/p&gt;

&lt;p&gt;For our team, implementing a flaky-test retry policy was the difference between:&lt;/p&gt;

&lt;p&gt;Chaos: Wasting time on false positives, broken builds, and blame games&lt;/p&gt;

&lt;p&gt;Calm: Trusting our CI, shipping faster, and knowing that when a build actually fails, it's real&lt;/p&gt;

&lt;p&gt;From 89% to 99% stability in a week—not because our tests got better overnight, but because we stopped letting perfection be the enemy of progress.&lt;/p&gt;

&lt;p&gt;Your Turn: How to Implement This&lt;br&gt;
Step 1: Choose Your Retry Strategy&lt;br&gt;
Playwright: retries: 2 in config&lt;/p&gt;

&lt;p&gt;Cypress: retries: { runMode: 2, openMode: 0 }&lt;/p&gt;

&lt;p&gt;Jest/Puppeteer: Custom wrapper or jest.retryTimes(2)&lt;/p&gt;

&lt;p&gt;Step 2: Set Clear Rules&lt;br&gt;
CI: 2 retries&lt;/p&gt;

&lt;p&gt;Local: 0 retries&lt;/p&gt;

&lt;p&gt;Report: Log when retries are used&lt;/p&gt;

&lt;p&gt;Step 3: Fix the Worst Offenders&lt;br&gt;
Use retry data to identify and permanently fix the flakiest 20% of tests—they probably cause 80% of the frustration.&lt;/p&gt;

&lt;p&gt;Step 4: Monitor and Iterate&lt;br&gt;
Track your stability score and gradually reduce retries as you fix underlying issues.&lt;/p&gt;

&lt;p&gt;Final Thoughts&lt;br&gt;
Our main branch hasn't broken in 6 weeks now. The team is shipping faster, with more confidence, and less stress. The retry policy bought us the time we needed to fix things properly.&lt;/p&gt;

&lt;p&gt;Sometimes the best solution isn't perfect—it's the one that works right now.&lt;/p&gt;

&lt;p&gt;Have you dealt with flaky E2E tests? How did your team handle it? Share your story in the comments below! 👇&lt;/p&gt;

</description>
      <category>automation</category>
      <category>cicd</category>
      <category>devops</category>
      <category>testing</category>
    </item>
    <item>
      <title>Killing Jenkins Was the Best Career Move I Made This Year</title>
      <dc:creator>Sohana Akbar</dc:creator>
      <pubDate>Mon, 13 Jul 2026 08:43:42 +0000</pubDate>
      <link>https://dev.to/sohanaakbar7/killing-jenkins-was-the-best-career-move-i-made-this-year-42in</link>
      <guid>https://dev.to/sohanaakbar7/killing-jenkins-was-the-best-career-move-i-made-this-year-42in</guid>
      <description>&lt;p&gt;Senior DevOps Engineer | 15 microservices | 2,800 deployments last month&lt;/p&gt;

&lt;p&gt;Let me tell you about the day I took down our Jenkins master during peak traffic. 3:47 PM on a Thursday. 147 builds queued. 12 angry devs in Slack. VP of Engineering asking "what's the ETA?" like I had a crystal ball.&lt;/p&gt;

&lt;p&gt;I fixed it by disabling the master node for 14 minutes while the database recovered. Then I spent the weekend migrating everything to GitHub Actions. That was 8 months ago. We haven't had a CI outage since.&lt;/p&gt;

&lt;p&gt;Here's what I learned, what broke, and what I'd do differently.&lt;/p&gt;

&lt;p&gt;The Problem Wasn't Jenkins. It Was Us.&lt;br&gt;
Jenkins worked fine for 4 years. We had:&lt;/p&gt;

&lt;p&gt;15 microservices with 200+ test suites each&lt;/p&gt;

&lt;p&gt;6 shared Jenkins agents in Kubernetes&lt;/p&gt;

&lt;p&gt;3,400 lines of pipeline-as-code&lt;/p&gt;

&lt;p&gt;87 plugins (most of them obsolete)&lt;/p&gt;

&lt;p&gt;1 overworked master node with 16GB RAM&lt;/p&gt;

&lt;p&gt;The crash happened because a developer pushed a PR that triggered 27 downstream jobs simultaneously. The master ran out of heap space. Not because of a memory leak—because we never tuned -Xmx past 8GB.&lt;/p&gt;

&lt;p&gt;What broke: We had 4 separate Jenkinsfiles per repo (build, test, deploy, rollback). Devs kept copying them from Stack Overflow without understanding the Groovy syntax. Pipeline logs were 40MB of noise.&lt;/p&gt;

&lt;p&gt;How I fixed it: I didn't. I killed it.&lt;/p&gt;

&lt;p&gt;Why GitHub Actions Won (For Us)&lt;br&gt;
Metric  Jenkins GitHub Actions&lt;br&gt;
Build time (avg)    8m 42s  3m 17s&lt;br&gt;
Pipeline failures   Dev: "works on my machine"  Dev: sees exact error in PR&lt;br&gt;
Plugins to maintain 87  13 (all official)&lt;br&gt;
Cost/month  $427 (EC2)  $0 (free tier + 2,000 minutes)&lt;br&gt;
Developer complaints    Daily   Zero in 8 months&lt;br&gt;
The real win: devs debug their own failures now. No more "hey DevOps, my pipeline is red" tickets. They see the exact step that failed, with the exact YAML line, right in the PR checks tab.&lt;/p&gt;

&lt;p&gt;No more SSH-ing into Jenkins agents to read workspace logs. No more "can you restart the master?" at 2 AM.&lt;/p&gt;

&lt;p&gt;The Migration: What Actually Worked&lt;br&gt;
Phase 1: The Parallel Run (2 weeks)&lt;br&gt;
We kept Jenkins as primary while I ported the simplest service to Actions:&lt;/p&gt;

&lt;p&gt;yaml&lt;br&gt;
name: CI&lt;br&gt;
on: [push, pull_request]&lt;/p&gt;

&lt;p&gt;jobs:&lt;br&gt;
  test:&lt;br&gt;
    runs-on: ubuntu-latest&lt;br&gt;
    steps:&lt;br&gt;
      - uses: actions/checkout@v4&lt;br&gt;
      - uses: actions/setup-node@v4&lt;br&gt;
        with:&lt;br&gt;
          node-version: '20'&lt;br&gt;
          cache: 'npm'&lt;br&gt;
      - run: npm ci&lt;br&gt;
      - run: npm test&lt;br&gt;
      - run: npm run build&lt;br&gt;
That's it. 14 lines. The Jenkinsfile for the same service was 187 lines.&lt;/p&gt;

&lt;p&gt;Phase 2: The Matrix Strategy (1 week)&lt;br&gt;
Our biggest headache in Jenkins was running tests across 6 Node versions and 3 databases. Actions handled this natively:&lt;/p&gt;

&lt;p&gt;yaml&lt;br&gt;
test-matrix:&lt;br&gt;
  runs-on: ubuntu-latest&lt;br&gt;
  strategy:&lt;br&gt;
    matrix:&lt;br&gt;
      node: [16, 18, 20]&lt;br&gt;
      db: [postgres, mysql, sqlite]&lt;br&gt;
  steps:&lt;br&gt;
    - uses: actions/checkout@v4&lt;br&gt;
    - uses: actions/setup-node@v4&lt;br&gt;
      with:&lt;br&gt;
        node-version: ${{ matrix.node }}&lt;br&gt;
    - run: docker-compose up -d ${{ matrix.db }}&lt;br&gt;
    - run: npm test&lt;br&gt;
18 parallel jobs. 4 minutes total. Jenkins took 12 minutes sequentially because we never configured parallel stages properly.&lt;/p&gt;

&lt;p&gt;Phase 3: The Monorepo Migration (The Painful One)&lt;br&gt;
We have 3 microservices sharing code in one repo. Jenkins handled this with a custom Groovy script that conditionally built based on changed paths.&lt;/p&gt;

&lt;p&gt;Actions does this natively with path filters:&lt;/p&gt;

&lt;p&gt;yaml&lt;br&gt;
on:&lt;br&gt;
  push:&lt;br&gt;
    paths:&lt;br&gt;
      - 'services/auth/&lt;strong&gt;'&lt;br&gt;
      - 'shared/&lt;/strong&gt;'&lt;br&gt;
  pull_request:&lt;br&gt;
    paths:&lt;br&gt;
      - 'services/auth/&lt;strong&gt;'&lt;br&gt;
      - 'shared/&lt;/strong&gt;'&lt;br&gt;
But we needed to build and deploy only the changed service. Here's the pattern that works:&lt;/p&gt;

&lt;p&gt;yaml&lt;br&gt;
changed-files:&lt;br&gt;
  uses: actions/checkout@v4&lt;br&gt;
  with:&lt;br&gt;
    fetch-depth: 2&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;id: files&lt;br&gt;
uses: tj-actions/changed-files@v39&lt;br&gt;
with:&lt;br&gt;
files: |&lt;br&gt;
  services/auth/**&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;name: build-auth&lt;br&gt;
if: steps.files.outputs.any_changed == 'true'&lt;br&gt;
run: |&lt;br&gt;
cd services/auth&lt;br&gt;
docker build -t auth-service .&lt;br&gt;
docker push ...&lt;br&gt;
What I'd do differently: I'd split the monorepo sooner. We wasted 3 days fighting the path filter syntax. Use tj-actions/changed-files from day one. It's the only third-party action I recommend.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Security Nightmare We Fixed&lt;br&gt;
In Jenkins, we stored secrets as environment variables in the master's configuration. Every developer with "read" access could see them in the build logs if they added printenv.&lt;/p&gt;

&lt;p&gt;In Actions, we use GitHub Secrets scoped to the repository:&lt;/p&gt;

&lt;p&gt;yaml&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;name: Deploy to ECS
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
ECR_REPO: ${{ secrets.ECR_REPO }}
run: |
aws ecs update-service ...
You can also use environment-level secrets for staging vs production:&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;yaml&lt;br&gt;
deploy-prod:&lt;br&gt;
  runs-on: ubuntu-latest&lt;br&gt;
  environment: production&lt;br&gt;
  steps:&lt;br&gt;
    - run: deploy.sh&lt;br&gt;
      env:&lt;br&gt;
        API_KEY: ${{ secrets.PROD_API_KEY }}&lt;br&gt;
The one gotcha: You can't reuse the same secret name across environments. secrets.API_KEY exists once. Name them secrets.DEV_API_KEY, secrets.PROD_API_KEY.&lt;/p&gt;

&lt;p&gt;The Things That Almost Broke Us&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;OIDC vs Static Credentials
We started with static AWS keys rotated monthly. Then a developer committed dev.env with credentials. Never again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Use OIDC authentication:&lt;/p&gt;

&lt;p&gt;yaml&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT }}:role/github-actions
aws-region: us-east-1
No keys. No rotation. Each run gets a temporary session.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What broke: I forgot to update the trust relationship when we added a new repo. 5 AM Sunday, deploy failed, I'm reading AWS docs on my phone.&lt;/p&gt;

&lt;p&gt;The fix: Use a reusable workflow with the OIDC config baked in:&lt;/p&gt;

&lt;p&gt;yaml&lt;/p&gt;

&lt;h1&gt;
  
  
  .github/workflows/deploy.yml (shared)
&lt;/h1&gt;

&lt;p&gt;on:&lt;br&gt;
  workflow_call:&lt;br&gt;
    inputs:&lt;br&gt;
      environment:&lt;br&gt;
        required: true&lt;br&gt;
        type: string&lt;/p&gt;

&lt;p&gt;jobs:&lt;br&gt;
  deploy:&lt;br&gt;
    runs-on: ubuntu-latest&lt;br&gt;
    permissions:&lt;br&gt;
      id-token: write&lt;br&gt;
      contents: read&lt;br&gt;
    steps:&lt;br&gt;
      - uses: aws-actions/configure-aws-credentials@v4&lt;br&gt;
        with:&lt;br&gt;
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT }}:role/github-actions-${{ inputs.environment }}&lt;br&gt;
Now every repo calls this. The trust relationship is in one place.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Docker Build Cache
Our Jenkins agents stored Docker layers in /var/lib/docker. Builds were fast because images were cached locally.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;GitHub Actions runners are ephemeral. No cache = 6-minute builds from scratch.&lt;/p&gt;

&lt;p&gt;The fix: Use GitHub's cache action for Docker layers:&lt;/p&gt;

&lt;p&gt;yaml&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;name: Set up Docker Buildx&lt;br&gt;
uses: docker/setup-buildx-action@v3&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;name: Cache Docker layers&lt;br&gt;
uses: actions/cache@v3&lt;br&gt;
with:&lt;br&gt;
path: /tmp/.buildx-cache&lt;br&gt;
key: ${{ runner.os }}-buildx-${{ github.sha }}&lt;br&gt;
restore-keys: |&lt;br&gt;
  ${{ runner.os }}-buildx-&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;name: Build and push&lt;br&gt;
uses: docker/build-push-action@v5&lt;br&gt;
with:&lt;br&gt;
cache-from: type=local,src=/tmp/.buildx-cache&lt;br&gt;
cache-to: type=local,dest=/tmp/.buildx-cache&lt;br&gt;
Build time dropped from 6m to 1m 40s. The cache key uses the commit SHA, so PRs restore from main's cache.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;Self-Hosted Runners (The "We're Too Big" Problem)
We peaked at 2,800 runs/month. GitHub-hosted runners were costing us $0.008/minute × ~1,200 minutes/day = $288/day.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We moved to self-hosted runners on AWS EC2 Spot instances. Cost dropped to $0.003/minute.&lt;/p&gt;

&lt;p&gt;The YAML:&lt;/p&gt;

&lt;p&gt;yaml&lt;br&gt;
runs-on: self-hosted&lt;br&gt;
The catch: Self-hosted runners don't auto-update. We're running Ubuntu 22.04 LTS with manual patching. I have an Ansible playbook that rebuilds the AMI weekly.&lt;/p&gt;

&lt;p&gt;The setup (Terraform):&lt;/p&gt;

&lt;p&gt;hcl&lt;br&gt;
resource "aws_ec2_instance" "runner" {&lt;br&gt;
  ami           = data.aws_ami.ubuntu.id&lt;br&gt;
  instance_type = "c6i.4xlarge"&lt;br&gt;
  spot_price    = "0.15"&lt;/p&gt;

&lt;p&gt;user_data = file("${path.module}/user-data.sh")&lt;/p&gt;

&lt;p&gt;tags = {&lt;br&gt;
    Name = "github-actions-runner-${count.index}"&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
We have 6 runners. 3 for PRs, 3 for main branch. The PR runners terminate after 30 minutes idle. Main runners stay hot.&lt;/p&gt;

&lt;p&gt;What I'd do differently: Start with GitHub-hosted until you actually need self-hosted. We prematurely optimized. The $288/day was worth the zero maintenance in hindsight.&lt;/p&gt;

&lt;p&gt;What Broke That I Didn't Expect&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The GITHUB_TOKEN Permissions Issue
Our CD pipeline needed to comment on PRs after deployment. I used the default ${{ secrets.GITHUB_TOKEN }} and got a 403.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Solution: Set permissions at the job level:&lt;/p&gt;

&lt;p&gt;yaml&lt;br&gt;
jobs:&lt;br&gt;
  deploy:&lt;br&gt;
    runs-on: self-hosted&lt;br&gt;
    permissions:&lt;br&gt;
      contents: read&lt;br&gt;
      pull-requests: write&lt;br&gt;
      issues: write&lt;br&gt;
    steps:&lt;br&gt;
      - name: Comment on PR&lt;br&gt;
        uses: actions/github-script@v7&lt;br&gt;
        with:&lt;br&gt;
          script: |&lt;br&gt;
            github.rest.issues.createComment({&lt;br&gt;
              issue_number: context.issue.number,&lt;br&gt;
              owner: context.repo.owner,&lt;br&gt;
              repo: context.repo.repo,&lt;br&gt;
              body: '🚀 Deployed to production'&lt;br&gt;
            })&lt;br&gt;
Took me 3 hours of trial and error. The GitHub docs bury this in the "Advanced" section.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Concurrency Limits
Two PRs trying to deploy to the same environment simultaneously. Database migrations conflict. The first succeeds, the second fails with "table already exists."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix: Use concurrency groups:&lt;/p&gt;

&lt;p&gt;yaml&lt;br&gt;
concurrency:&lt;br&gt;
  group: ${{ github.workflow }}-${{ github.ref_name }}&lt;br&gt;
  cancel-in-progress: true&lt;br&gt;
Now the second job waits for the first to finish. No more migration conflicts.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The 60-Minute Timeout
One of our E2E test suites takes 45 minutes on a good day. GitHub-hosted runners have a 6-hour limit. Self-hosted don't (we set our own).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;But we had a test that hung for 2 hours because of a race condition. The runner stayed active, blocking the queue.&lt;/p&gt;

&lt;p&gt;Fix: Set explicit timeouts:&lt;/p&gt;

&lt;p&gt;yaml&lt;br&gt;
jobs:&lt;br&gt;
  e2e:&lt;br&gt;
    runs-on: self-hosted&lt;br&gt;
    timeout-minutes: 55&lt;br&gt;
    steps:&lt;br&gt;
      - run: npm run test:e2e&lt;br&gt;
Now it fails at 55 minutes, not 120. The race condition got fixed because developers actually saw the failure.&lt;/p&gt;

&lt;p&gt;The Numbers After 8 Months&lt;br&gt;
Metric  Before  After&lt;br&gt;
Average build time  8m 42s  3m 17s&lt;br&gt;
Deployment frequency    42/day  93/day&lt;br&gt;
CI failure rate 8%  1.2%&lt;br&gt;
Pipeline code (total lines) 3,400   620&lt;br&gt;
Dev tickets to DevOps   34/week 3/week&lt;br&gt;
Sleep score (my Oura ring)  67  83&lt;br&gt;
The last one is real. No more 3 AM Jenkins alerts. No more "can you restart the agent?" at 11 PM.&lt;/p&gt;

&lt;p&gt;What I'd Do Differently (The Honest Retro)&lt;br&gt;
Migrate one service at a time. I did the monorepo last. Should've done it first. The monorepo had the most complexity and cost us the most time.&lt;/p&gt;

&lt;p&gt;Start with reusable workflows. I wrote the same YAML 15 times. Now I have a central .github/workflows/reusable-test.yml that all repos import:&lt;/p&gt;

&lt;p&gt;yaml&lt;br&gt;
name: Test&lt;br&gt;
on: [workflow_call]&lt;/p&gt;

&lt;p&gt;jobs:&lt;br&gt;
  test:&lt;br&gt;
    runs-on: ubuntu-latest&lt;br&gt;
    steps:&lt;br&gt;
      - uses: actions/checkout@v4&lt;br&gt;
      - name: Run tests&lt;br&gt;
        run: npm test&lt;br&gt;
Don't try to replicate Jenkins 1:1. I wasted a week making Actions "look like Jenkins." The pipeline should match your workflow, not your old tool.&lt;/p&gt;

&lt;p&gt;Document the failure points. When I migrated, I kept notes on every error. That became our internal wiki. Now new DevOps engineers onboard in 2 days instead of 2 weeks.&lt;/p&gt;

&lt;p&gt;Set up monitoring before the migration. I had no baseline for build times. I assumed Actions was faster. It was, but I couldn't prove it. Use the GitHub API to log metrics from day one.&lt;/p&gt;

&lt;p&gt;The Bottom Line&lt;br&gt;
Jenkins is a fine tool if you have 1-2 services and a dedicated team to maintain it. We had 15 services and me.&lt;/p&gt;

&lt;p&gt;GitHub Actions eliminated the maintenance overhead. Developers fix their own pipelines because the errors are in their PRs, not buried in a Jenkins build #4837 log.&lt;/p&gt;

&lt;p&gt;The migration cost me 3 weeks of focused work. The ROI was 2 months.&lt;/p&gt;

&lt;p&gt;Would I do it again? In a heartbeat. But I'd start the monorepo split 6 months earlier and I'd write reusable workflows from day one.&lt;/p&gt;

&lt;p&gt;My advice if you're considering it: Try the migration on a Sunday. Pick your simplest service. Get it running in Actions with the PR check passing. See how many lines of YAML it takes. Compare that to your Jenkinsfile. Then decide.&lt;/p&gt;

&lt;p&gt;Just don't kill your Jenkins master at 3:47 PM on a Thursday. Learn from me.&lt;/p&gt;

&lt;p&gt;Senior DevOps Engineer. 15 microservices. 2,800 deployments/month. 0 CI outages in 8 months.&lt;/p&gt;

&lt;p&gt;Connect if you want to compare war stories. I've got more.&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
