<?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: Karthik K Pradeep</title>
    <description>The latest articles on DEV Community by Karthik K Pradeep (@foldedodin).</description>
    <link>https://dev.to/foldedodin</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%2F3811793%2F86d48240-7033-4492-9587-c5cff2ee70d8.jpg</url>
      <title>DEV Community: Karthik K Pradeep</title>
      <link>https://dev.to/foldedodin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/foldedodin"/>
    <language>en</language>
    <item>
      <title>Building a CI/CD Engine That Doesn't Feel Like a Black Box</title>
      <dc:creator>Karthik K Pradeep</dc:creator>
      <pubDate>Sat, 01 Aug 2026 06:50:38 +0000</pubDate>
      <link>https://dev.to/foldedodin/building-a-cicd-engine-that-doesnt-feel-like-a-black-box-52k8</link>
      <guid>https://dev.to/foldedodin/building-a-cicd-engine-that-doesnt-feel-like-a-black-box-52k8</guid>
      <description>&lt;p&gt;&lt;strong&gt;Designing Real-Time Observability with Docker, Domain Events, and WebSockets&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every developer has experienced this:&lt;/p&gt;

&lt;p&gt;You push code.&lt;/p&gt;

&lt;p&gt;CI starts.&lt;/p&gt;

&lt;p&gt;Five minutes later...&lt;/p&gt;

&lt;p&gt;"Build Failed."&lt;/p&gt;

&lt;p&gt;Why?&lt;/p&gt;

&lt;p&gt;Somewhere inside thousands of log lines generated by an ephemeral Docker container that no longer exists, there is a tiny, obscure error message. You refresh the page, dig through the static text, and try to reverse-engineer what the build agent was actually doing when it died.&lt;/p&gt;

&lt;p&gt;That frustration is one of the reasons I started building PipelineOS. Instead of treating observability as an afterthought—something you bolt onto the database after the execution engine is finished—I made it a first-class part of the runtime itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Why I Didn't Use Existing Tools&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Before diving into the architecture, I should answer the obvious question: Why build another CI/CD engine? Why not just use GitHub Actions, GitLab CI, Jenkins, Tekton, or Argo?&lt;/p&gt;

&lt;p&gt;Those tools are incredible. GitHub Actions revolutionized how we think about integrated workflows, and Tekton provides an industrial-grade, Kubernetes-native execution layer. I didn't build PipelineOS because I think the industry is doing it wrong. I built it because I wanted to understand how these massive distributed systems actually work under the hood.&lt;/p&gt;

&lt;p&gt;I wanted to learn what it takes to securely isolate workloads inside Docker, how to reliably stream multiplexed logs over WebSockets, and how to design a distributed job queue that doesn't lose state when a runner node crashes. When you use a managed platform, you are shielded from the brutal reality of system state machines, zombie process recovery, and stream demuxing. By building PipelineOS from the ground up, I forced myself to solve these exact engineering problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;1. Why Observability Matters&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;In modern engineering, waiting on pipelines is one of the biggest momentum killers.&lt;/p&gt;

&lt;p&gt;When developers push code, they need immediate, granular feedback. If an npm install stalls because of a network proxy issue, or a C++ compilation spikes memory and gets silently killed by the Linux OOM killer, developers shouldn't have to wait 15 minutes for the pipeline to time out before they realize something is wrong.&lt;br&gt;
Poor visibility creates poor developer experience:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Debugging blind: Parsing thousands of lines of raw text logs hours after the container has been destroyed.&lt;/li&gt;
&lt;li&gt;Delayed feedback: Reloading a dashboard 20 times to see if a status has moved from Pending to Running.&lt;/li&gt;
&lt;li&gt;Lack of infrastructure insight: When a build runs slowly, you have no idea if the cause is a poorly optimized Dockerfile or a starved CI/CD runner host running out of CPU credits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Observability isn't just about collecting debug logs when things break; it’s about reducing cognitive load and giving engineers real-time confidence in their automation.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. What "Real-Time Observability" Actually Means
&lt;/h2&gt;

&lt;p&gt;In CI/CD, most platforms equate "observability" to simple streaming text logs. But true real-time visibility in a modern distributed build runtime requires multi-dimensional awareness:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stage Lifecycle Transitions: Exact state machines reporting millisecond-accurate transitions (Pending → Claimed → Running → Success / Failed).&lt;/li&gt;
&lt;li&gt;Runtime Resource Metrics: Continuous CPU profiling, memory utilization peaks, network RX/TX bandwidth, and block I/O per container.&lt;/li&gt;
&lt;li&gt;Runner Health &amp;amp; Heartbeats: Detecting daemon crashes and host resource exhaustion before builds turn into orphaned zombies.&lt;/li&gt;
&lt;li&gt;Execution Timelines: Waterfall charts and cost estimations based on live compute duration.&lt;/li&gt;
&lt;/ul&gt;

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

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

&lt;p&gt;To achieve this level of granular visibility without overloading the backend, you have to separate event generation from ingestion and transport.&lt;/p&gt;

&lt;p&gt;Here is how the PipelineOS observability data plane flows from a git push all the way to a developer's React dashboard:&lt;/p&gt;

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

&lt;p&gt;This decoupled architecture ensures that the execution engine (Docker container) doesn't care who is watching it, and the transport layer (WebSockets) doesn't care how the events were generated.&lt;br&gt;
By treating observability as a stream of immutable domain events rather than scattered API calls, the same architecture can support future features like distributed runners, replayable execution timelines, and AI-driven analysis without changing the execution engine itself.&lt;/p&gt;
&lt;h2&gt;
  
  
  4. The Biggest Engineering Challenges
&lt;/h2&gt;

&lt;p&gt;While building PipelineOS, I explored several architectural trade-offs and engineering challenges.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A. Docker Telemetry&lt;/strong&gt;&lt;br&gt;
To get live CPU and memory metrics out of a running stage, PipelineOS periodically polls snapshots of the Docker Engine API using the stats({ stream: false }) endpoint. But calculating true CPU utilization across multiple cores isn't trivial.&lt;/p&gt;

&lt;p&gt;You can't just read a cpu_usage_percent field. You have to calculate the delta between the container's CPU cycles and the host system's CPU cycles, factored across the number of online CPU cores:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Calculating CPU percentage across online cores&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cpuDelta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cpuTotal&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;prevCpu&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;systemDelta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;systemTotal&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;prevSystem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onlineCpus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cpuStats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;online_cpus&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cpuDelta&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;systemDelta&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cpuDelta&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;systemDelta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;onlineCpus&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;&lt;strong&gt;B. Event-Driven Architecture (Reducing Runner Complexity)&lt;/strong&gt;&lt;br&gt;
In early prototypes, the Runner Agent made direct REST API calls every time a stage changed status: POST /status, POST /logs, POST /metrics. This tightly coupled the runner's execution logic with the backend's HTTP routing.&lt;/p&gt;

&lt;p&gt;I solved this by decomposing the executor and introducing an InProcessEventBus. Now, the container runner simply publishes immutable domain events (like StageStarted or StageMetricsUpdated) to the local bus. It doesn't know or care if those events are written to a file, sent to an API, or dropped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;C. Bulk Ingestion: Slashing Network Chatter&lt;/strong&gt;&lt;br&gt;
When a container installs heavy Node dependencies, it can generate hundreds of log chunks per second. If the Runner made a separate HTTP POST request for every single log chunk and telemetry poll, it would exhaust its TCP connection pool and effectively DDoS the control plane API.&lt;/p&gt;

&lt;p&gt;To solve this, I implemented Bulk Ingestion. The internal event bus batches all domain events and flushes them to a single endpoint (POST /internal/events).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before&lt;/strong&gt;: ~200 HTTP requests/sec during a noisy build&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After&lt;/strong&gt;: 1 batched request every 2000 ms&lt;/p&gt;

&lt;p&gt;This drastically reduces HTTP overhead, lowers TCP handshake latency, and turns erratic, spiky traffic into a smooth, predictable ingestion pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;D. WebSockets vs. Server-Sent Events (SSE)&lt;/strong&gt;&lt;br&gt;
Once the Control Plane ingests these bulk events, it needs to stream them to the user's browser. PipelineOS supports both WebSockets and Server-Sent Events (SSE).&lt;/p&gt;

&lt;p&gt;While WebSockets provide full-duplex, low-latency communication, Server-Sent Events (SSE) provide unidirectional streaming over standard HTTP/1.1 (or HTTP/2). Because SSE is just a long-lived HTTP request, it automatically handles reconnections and plays nicely with standard reverse proxies (like NGINX).&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Runtime Telemetry &amp;amp; Operational Health
&lt;/h2&gt;

&lt;p&gt;Monitoring the code that builds your code is just as critical as monitoring production.&lt;/p&gt;

&lt;p&gt;In PipelineOS, runtime telemetry extends beyond container CPU and network I/O. The Runner Agent constantly emits RunnerHeartbeat events. This automated pulse allows the control plane to actively monitor live workers.&lt;/p&gt;

&lt;p&gt;Runner Health DashboardActive heartbeats ensuring the runner hasn't crashed or run out of memory.&lt;/p&gt;

&lt;p&gt;If a runner hardware node crashes mid-build or experiences a power interruption, the heartbeat drops. The control plane’s Staleness Detection Service sweeps the database, identifies the orphaned "zombie" run, and reclaims or cleanly fails the pipeline so developers aren't stuck staring at a dashboard waiting for a build that will never finish&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Project Evolution Timeline
&lt;/h2&gt;

&lt;p&gt;It is deeply rewarding to look back and see how PipelineOS has grown from a basic state machine into a robust, observable distributed system. Here is a timeline of our recent milestones:&lt;/p&gt;

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

&lt;h2&gt;
  
  
  7. Lessons Learned
&lt;/h2&gt;

&lt;p&gt;Building this architecture reinforced a few deep engineering truths:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;**Observability is an architectural bedrock, not a bolt-on feature. **If you don't design your runtime around emitting atomic events from day one, adding real-time visibility later requires rewriting half your execution loops.&lt;/li&gt;
&lt;li&gt;**Event-driven architectures dramatically simplify live streaming. **When your backend internally communicates via immutable events, streaming to WebSockets or SSE becomes nothing more than attaching a simple .on() listener.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separating transport from business logic pays compound interest.&lt;/strong&gt; By isolating the transport from event ingestion, adding future real-time features requires zero changes to the core Docker execution engine.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  8. What's Next: From Visibility to Intelligence
&lt;/h2&gt;

&lt;p&gt;Building a CI/CD platform has taught me that execution is only the beginning.&lt;br&gt;
Reliable systems need durability.&lt;br&gt;
Durable systems need observability.&lt;br&gt;
And observable systems finally become capable of intelligence.&lt;br&gt;
That's the direction PipelineOS is heading next. In the coming milestones, we will move beyond just displaying failures to understanding them—applying automated remediation rules, dynamically allocating resources based on historical patterns, and using intelligence to keep developers in their flow state.&lt;/p&gt;

&lt;p&gt;If you're interested in CI/CD internals, distributed systems, or building developer tools from scratch, I'd love to hear your thoughts or feedback on the architecture.&lt;br&gt;
&lt;em&gt;PipelineOS is an open-source, self-hosted CI/CD runtime built for developers who value simplicity, visibility, and control. Check out our architecture and contribute on&lt;/em&gt; &lt;a href="https://github.com/FoldedOdin/PipelineOS" rel="noopener noreferrer"&gt;&lt;em&gt;GitHub&lt;/em&gt;&lt;/a&gt;!&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Why Modern CI/CD Needs Self-Healing Resilience: Moving Beyond "Re-run Failed Jobs"</title>
      <dc:creator>Karthik K Pradeep</dc:creator>
      <pubDate>Tue, 21 Jul 2026 13:33:55 +0000</pubDate>
      <link>https://dev.to/foldedodin/why-modern-cicd-needs-self-healing-resilience-moving-beyond-re-run-failed-jobs-38ld</link>
      <guid>https://dev.to/foldedodin/why-modern-cicd-needs-self-healing-resilience-moving-beyond-re-run-failed-jobs-38ld</guid>
      <description>&lt;p&gt;Every DevOps engineer has seen it:&lt;br&gt;
A deployment fails because Docker Hub returns a &lt;code&gt;503 Service Unavailable&lt;/code&gt; or a package registry briefly times out.&lt;br&gt;
Someone clicks &lt;strong&gt;"Re-run failed jobs."&lt;/strong&gt;&lt;br&gt;
The pipeline passes.&lt;/p&gt;

&lt;p&gt;Nothing was actually fixed.&lt;/p&gt;

&lt;p&gt;CI/CD systems have become incredibly good at automation, but surprisingly poor at resilience. Most pipelines treat every failure identically—whether it’s a flaky network request, a temporary DNS blip, an API rate limit, or an actual C++ compilation error. When a stage breaks, the pipeline halts immediately and throws an error into a Slack channel.&lt;/p&gt;

&lt;p&gt;In the modern development lifecycle, &lt;strong&gt;humans have become the default retry mechanism.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Traditional CI/CD Has a Reliability Problem
&lt;/h2&gt;

&lt;p&gt;When we analyze why production builds fail across thousands of daily runs, pure application syntax errors make up only a fraction of red builds. The vast majority of everyday CI friction comes from transient infrastructure instabilities:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Network &amp;amp; Registry Outages:&lt;/strong&gt; &lt;code&gt;EAI_AGAIN&lt;/code&gt;, &lt;code&gt;ECONNRESET&lt;/code&gt;, or &lt;code&gt;502 Bad Gateway&lt;/code&gt; from npm, PyPI, or Docker Hub.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image Pull Failures:&lt;/strong&gt; &lt;code&gt;docker pull&lt;/code&gt; timing out due to transient network congestion or TLS handshake drops.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Git Operations:&lt;/strong&gt; &lt;code&gt;git clone&lt;/code&gt; or &lt;code&gt;git fetch&lt;/code&gt; failing due to temporary SSH socket resets or GitHub API throttling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Container Startup Delays:&lt;/strong&gt; Docker daemon delays or resource contention under high concurrent runner load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API Rate Limits:&lt;/strong&gt; Cloud providers or external SaaS services rejecting integration tests with &lt;code&gt;429 Too Many Requests&lt;/code&gt;.
Traditional CI/CD engines operate on a rigid, binary execution model:&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk3wtg616jhx8wz185l59.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk3wtg616jhx8wz185l59.png" alt="Transient failures often require a manual re-run." width="798" height="181"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This brittle binary design creates significant downstream friction:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Unnecessary Manual Reruns:&lt;/strong&gt; Engineers waste valuable focus hours checking dashboards, diagnosing benign glitches, and clicking retry buttons.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wasted Compute:&lt;/strong&gt; When step 14 of a 15-step pipeline fails due to a network glitch, clicking rerun often re-executes all preceding steps from scratch, burning CPU credits and blocking CI queues.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slower Feedback Loops:&lt;/strong&gt; Developers lose momentum while waiting for entire workflows to spin up again just to verify that a flaky network timeout has cleared.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  What Does "Resilient CI/CD" Mean?
&lt;/h2&gt;

&lt;p&gt;In distributed systems engineering, fault tolerance is not an afterthought; it is a fundamental architectural assumption. When a microservice attempts to call an upstream database and encounters a brief connection reset, we don't crash the entire Kubernetes cluster. Instead, we classify the exception, apply exponential backoff, and retry gracefully.&lt;br&gt;
Resilient CI/CD brings this same distributed systems maturity to our build runtimes. Instead of treating every non-zero exit code as a fatal catastrophe, a resilient pipeline introduces an intelligent feedback loop between execution and failure:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo1fnlqgrx7vex9ul8o4q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo1fnlqgrx7vex9ul8o4q.png" alt="Resilient CI/CD treats failures as decision points rather than endpoints. By analyzing execution context and applying predefined recovery rules, the pipeline can automatically recover from transient or known failures while escalating only genuine errors to developers." width="800" height="120"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;By classifying the root cause of an error &lt;em&gt;before&lt;/em&gt; giving up, the build platform can autonomously recover from transient hiccups without disturbing the developer.&lt;/p&gt;
&lt;h2&gt;
  
  
  PipelineOS Architecture: Separating Decision-Making from Execution
&lt;/h2&gt;

&lt;p&gt;To build a truly resilient pipeline runtime, we have to start with a core distributed systems design principle: &lt;strong&gt;separate decision-making from execution.&lt;/strong&gt;&lt;br&gt;
In traditional CI tools, the runner is often a monolithic agent that blindly runs bash scripts until it hits a non-zero exit status. It has no awareness of history, state, or recovery policies.&lt;br&gt;
&lt;strong&gt;PipelineOS&lt;/strong&gt; applies control-plane/data-plane separation directly to CI/CD. The &lt;strong&gt;Control Plane (API)&lt;/strong&gt; owns state, configuration, telemetry, and the remediation intelligence. The &lt;strong&gt;Data Plane (Runner Agent)&lt;/strong&gt; is an isolated, containerized executor whose sole responsibility is to run Docker stages, report rich telemetry, and execute instructions.&lt;/p&gt;

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

&lt;p&gt;Because the runner executes every stage inside an isolated, clean Docker container (&lt;code&gt;stage.image&lt;/code&gt;), recovering from a failure does not pollute the host machine. The control plane can command the runner to tear down the container, wait for backoff, and spin up a fresh instance with zero residual state.&lt;/p&gt;
&lt;h2&gt;
  
  
  Stage-Level Telemetry: Why Exit Codes Aren't Enough
&lt;/h2&gt;

&lt;p&gt;An exit code tells you that something failed. &lt;strong&gt;It doesn’t tell you why.&lt;/strong&gt;&lt;br&gt;
If a &lt;code&gt;docker build&lt;/code&gt; stage exits with code &lt;code&gt;1&lt;/code&gt;, that binary number gives the runner zero context on whether the error was caused by a missing semicolon on line 42 of &lt;code&gt;main.go&lt;/code&gt; or an &lt;code&gt;EOF&lt;/code&gt; timeout from a remote container registry.&lt;br&gt;
To enable intelligent decisions, the runner must become more than just a command launcher. It must act as a sensory data plane that captures multi-dimensional telemetry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Runner Execution Stream
 ├── Capture real-time stdout chunk streams
 ├── Capture real-time stderr chunk streams
 ├── Record exact process Exit Code (e.g., 137 vs 1)
 ├── Track Execution Duration (Wall clock vs CPU seconds)
 ├── Monitor Peak Memory Usage (memBytesMax)
 └── Request AI Failure Diagnosis from Control Plane
        └── POST /internal/runs/:id/stages/:name/diagnosis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a stage fails, the PipelineOS runner does not simply abort. It packages the raw stdout/stderr logs, execution metrics, and exit status, reporting them back to the API. The control plane parses these logs—leveraging AI diagnosis layers and pattern extractors—and returns structured failure signatures (such as summary hints and regex match patterns) right back to the runner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule Matching: The Remediation Engine in Action
&lt;/h2&gt;

&lt;p&gt;Once the failure signature is extracted from the telemetry, the &lt;strong&gt;Remediation Engine&lt;/strong&gt; evaluates the failure against active recovery rules.&lt;br&gt;
Because the API owns the intelligence while the runner simply executes instructions, rule matching becomes extremely clean and deterministic. Let’s look at two concrete examples:&lt;/p&gt;

&lt;h3&gt;
  
  
  Example 1: Container Out of Memory (OOM)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Exit Code = 137 (SIGKILL)
   │
   ▼
Search Active Remediation Rules
   │
   ▼
OOM Rule Matched (Pattern: "Container killed due to memory limits")
   │
   ▼
Recovery Instruction: Increase Stage Memory Allocation (+50%)
   │
   ▼
Runner Relaunches Container with Expanded Memory Limits
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Example 2: Transient TLS / Connection Drop
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Stage Error: `docker pull node:20` -&amp;gt; "net/http: TLS handshake timeout" or "ECONNRESET"
   │
   ▼
Search Active Remediation Rules
   │
   ▼
Network Timeout Rule Matched (Substring: "TLS handshake timeout")
   │
   ▼
Recovery Instruction: Action = `retry_stage`, backoffSeconds = 20, maxAttempts = 3
   │
   ▼
Runner Logs Backoff Warning -&amp;gt; Sleeps 20s -&amp;gt; Retries Stage Cleanly
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The runner doesn't need hardcoded regexes or complex heuristics built into its binary. It asks the API for the active rules, checks them against the failure context, and executes the exact action dictated by the control plane.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dynamic Mid-Pipeline Recovery
&lt;/h2&gt;

&lt;p&gt;This is the core innovation of resilient CI/CD. Let’s look at the side-by-side contrast when a stage fails midway through an execution graph:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc2tf0zrkrme15e3n9gvn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc2tf0zrkrme15e3n9gvn.png" alt="This comparison illustrates the difference between traditional and resilient CI/CD. Rather than terminating the entire pipeline after a transient failure, PipelineOS analyzes the failure context, executes an appropriate recovery action, retries only the affected stage, and continues execution without requiring manual intervention." width="614" height="879"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;By decoupling stage execution into isolated container units, the remediation engine can apply a broad spectrum of rule-driven recovery actions mid-flight:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retry Stage:&lt;/strong&gt; Re-run the specific failed stage with linear or exponential backoff (&lt;code&gt;backoffSeconds&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cleanup Workspace:&lt;/strong&gt; Purge temporary build artifacts or corrupted &lt;code&gt;node_modules&lt;/code&gt; cache layers before retrying.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restart Docker Container:&lt;/strong&gt; Tear down dead or hung daemons and spin up fresh container instances.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pull Image Again:&lt;/strong&gt; Force a clean re-fetch of base images if a registry connection dropped mid-layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alternative Mirror:&lt;/strong&gt; Switch environment variables dynamically to point to a backup package registry mirror.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skip Optional Stage:&lt;/strong&gt; Safely bypass non-critical linting or notification steps if an external third-party service is down.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why This Matters
&lt;/h2&gt;

&lt;p&gt;Moving from manual intervention to autonomous self-healing transforms engineering velocity across four critical vectors:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Higher Reliability:&lt;/strong&gt; Transient failures, DNS hiccups, and registry timeouts disappear behind the scenes without breaking the main branch build or waking up on-call engineers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lower Operational Cost:&lt;/strong&gt; Engineering teams stop babysitting pipelines. Compute costs drop because pipelines no longer re-run 20 successful early stages just to retry a flaky e2e test at the very end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Faster Feedback Loops:&lt;/strong&gt; A developer pushing code gets actionable green or red feedback in minutes. If a step experiences a brief glitch, it auto-recovers in 15 seconds instead of sitting dead in a dashboard for two hours until someone notices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Better Observability:&lt;/strong&gt; Because every remediation attempt (&lt;code&gt;attempt&lt;/code&gt;, &lt;code&gt;save&lt;/code&gt;, &lt;code&gt;failure&lt;/code&gt;) is recorded as structured telemetry in the database, platform engineering teams gain complete visibility into &lt;em&gt;which&lt;/em&gt; infrastructure components are flaking and how often self-healing saves the day.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Design Challenges and Engineering Trade-offs
&lt;/h2&gt;

&lt;p&gt;No resilient system comes without trade-offs. When designing an automated recovery layer for CI/CD, we have to carefully navigate several deep engineering challenges:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Infinite Retry Loops and Upstream DoS
&lt;/h3&gt;

&lt;p&gt;If a pipeline blindly retries on every failure, a persistent syntax error or a hard service outage turns your CI runner into a denial-of-service bot hammering upstream servers.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Solution:&lt;/strong&gt; Every remediation rule must enforce strict &lt;code&gt;maxAttempts&lt;/code&gt; ceilings and exponential backoff schedules. If a stage fails after its maximum attempts, the pipeline must fail fast and alert a human.
### 2. False Positives and Misclassification
Simple string matching or loose regular expressions can easily misclassify errors. If a developer accidentally writes &lt;code&gt;throw new Error("connection timeout")&lt;/code&gt; inside application business logic, a naive regex might classify the unit test failure as an infrastructure outage and retry it endlessly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Solution:&lt;/strong&gt; Rule matching must combine exact exit codes, stage names, and AI-assisted log pattern classification (&lt;code&gt;diagnosis.patterns&lt;/code&gt;). We must distinguish between environmental infrastructure failures and deterministic application bugs.
### 3. Idempotency and Safe Re-Execution
Retrying a stage is only safe if the stage is strictly &lt;strong&gt;idempotent&lt;/strong&gt;. If a stage writes partial records to a staging database or deploys half of an artifact bundle before crashing, re-running it might cause duplicate records or corrupted state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Solution:&lt;/strong&gt; Stage containers must operate inside ephemeral, isolated Docker filesystems. If a stage interacts with external state, the recovery rule can specify pre-retry cleanup instructions or require explicit developer opt-in for side-effecting stages.
### 4. Rule Precedence and Self-Pruning Ineffective Rules
Over time, teams accumulate dozens of remediation rules. What happens when multiple rules match a single failure? Even more critically, what happens when a once-helpful retry rule stops working because the underlying infrastructure changed?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Solution (Self-Disabling Rules):&lt;/strong&gt; To prevent obsolete rules from wasting compute, PipelineOS tracks continuous outcome metrics (&lt;code&gt;attempts&lt;/code&gt;, &lt;code&gt;saves&lt;/code&gt;, &lt;code&gt;failures&lt;/code&gt;, &lt;code&gt;successRate&lt;/code&gt;). If a rule executes frequently (&lt;code&gt;attempts &amp;gt;= minAttempts&lt;/code&gt;) but its success rate drops below a safety threshold (&lt;code&gt;successRate &amp;lt; disableBelowSuccessRate&lt;/code&gt;, e.g., &lt;code&gt;&amp;lt; 20%&lt;/code&gt;), the control plane &lt;strong&gt;automatically disables the rule&lt;/strong&gt; (&lt;code&gt;rule.enabled = false&lt;/code&gt;). The system self-prunes ineffective rules without manual cleanup!&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Future Direction: Where Intelligent CI/CD Goes Next
&lt;/h2&gt;

&lt;p&gt;Today, PipelineOS retries failed stages using deterministic rules and basic AI diagnosis cards. But building an understandable, resilient control plane opens up exciting frontiers on our technical roadmap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Local LLM &amp;amp; Ollama Integration:&lt;/strong&gt; Running intelligent log diagnostics and root cause classification 100% locally on-premise, removing external dependencies while maintaining strict data privacy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Learning from Successful Recoveries:&lt;/strong&gt; Training local models on historical run outcomes so the system automatically synthesizes new remediation rules based on what successfully resolved past failures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flakiness Scoring &amp;amp; Heatmaps:&lt;/strong&gt; Rolling statistical windows that track stage stability over months, pinpointing exactly which test suites or container images are degrading team velocity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visual Rule Builder:&lt;/strong&gt; Providing an intuitive, dashboard-driven drag-and-drop interface where platform engineers can build complex recovery graphs without writing raw JSON or touching the database directly.
Imagine a CI/CD platform that recognizes a transient network timeout, retries only the affected stage, prunes its own ineffective policies, and continues without anyone touching the "Re-run" button.
&lt;em&gt;Today, PipelineOS retries failed stages using deterministic rules. What happens when those rules can learn from every successful recovery?&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;PipelineOS is an open-source, self-hosted CI/CD runtime built for developers who value simplicity, visibility, and control. Check out our architecture and contribute on &lt;a href="https://github.com/FoldedOdin/PipelineOS" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;!&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>docker</category>
      <category>automation</category>
    </item>
    <item>
      <title>I Stopped Deploying Manually - Here’s My CI/CD Pipeline with GitHub Actions</title>
      <dc:creator>Karthik K Pradeep</dc:creator>
      <pubDate>Mon, 23 Mar 2026 21:39:25 +0000</pubDate>
      <link>https://dev.to/foldedodin/i-stopped-deploying-manually-heres-my-cicd-pipeline-with-github-actions-2h6k</link>
      <guid>https://dev.to/foldedodin/i-stopped-deploying-manually-heres-my-cicd-pipeline-with-github-actions-2h6k</guid>
      <description>&lt;p&gt;How I moved AquaChain from manual deployments to a GitHub Actions pipeline that catches bad changes before they hit production.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;AquaChain is a production-focused IoT water quality monitoring platform: sensors send readings into AWS, Lambda services process and analyze them, and the frontend gives admins, technicians, and consumers role-based views into device health, alerts, and operations.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I Started
&lt;/h2&gt;

&lt;p&gt;For a long time, my deployment process looked like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Build locally&lt;/li&gt;
&lt;li&gt;SSH into the server&lt;/li&gt;
&lt;li&gt;Restart the app&lt;/li&gt;
&lt;li&gt;Hope nothing breaks&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last step was the real problem.&lt;/p&gt;

&lt;p&gt;The moment I finally stopped trusting manual deploys was a frontend release that looked fine on my machine but went out with the wrong production environment settings. AquaChain loaded, but API requests started failing immediately. I was SSH'ing into the box, checking logs, rolling back, and trying to remember which local change caused it. The rollback only took a few tense minutes, but a few minutes of visible production errors is still a production incident. Nothing about that failure was dramatic. It was worse: it was avoidable.&lt;/p&gt;

&lt;p&gt;That was the pattern I wanted to kill. Too much depended on memory, local state, and luck.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Wanted From the Pipeline
&lt;/h2&gt;

&lt;p&gt;I wanted a pipeline that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Runs linting, type checks, and tests on every pull request&lt;/li&gt;
&lt;li&gt;Blocks merges when quality checks fail&lt;/li&gt;
&lt;li&gt;Deploys the frontend only when frontend files change&lt;/li&gt;
&lt;li&gt;Deploys Lambda functions only when backend files change&lt;/li&gt;
&lt;li&gt;Uses short-lived AWS credentials instead of long-lived secrets&lt;/li&gt;
&lt;li&gt;Completes under 5 minutes so it does not become something people route around&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One practical note before the YAML: GitHub Actions minutes are not free forever. On GitHub-hosted runners, every PR build costs time and money once you move past the included allowance. Path filters, caching, and splitting work by concern are not just performance improvements. They control your bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Repository Reality
&lt;/h2&gt;

&lt;p&gt;This post uses AquaChain names throughout because the examples are derived from the current AquaChain workflow, not a sanitized demo.&lt;/p&gt;

&lt;p&gt;The repo itself looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;aquachain/
├── frontend/          # Next.js app
├── lambda/            # AWS Lambda functions (Python)
├── infrastructure/    # AWS CDK and infra code
├── config/            # Shared CI config such as requirements-dev.txt
└── .github/
    └── workflows/
        └── ci-cd-pipeline.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These examples are derived from &lt;code&gt;.github/workflows/ci-cd-pipeline.yml&lt;/code&gt;; I am splitting them into three workflows here for clarity, but in practice they currently live in one file.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;config/&lt;/code&gt; directory holds shared CI tooling such as &lt;code&gt;requirements-dev.txt&lt;/code&gt; and &lt;code&gt;pytest.ini&lt;/code&gt;, so linting and test setup lives in one place instead of being duplicated across every Lambda service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Workflow 1: PR Checks
&lt;/h2&gt;

&lt;p&gt;This runs on pull requests and on pushes to &lt;code&gt;main&lt;/code&gt;. If this fails, nothing should merge.&lt;/p&gt;

&lt;p&gt;If code can still drift into &lt;code&gt;main&lt;/code&gt; after this job fails, the pipeline is just theater.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/pr-checks.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PR Checks&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;develop&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;frontend-checks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Frontend - Lint, Type Check, Test&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;defaults&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontend&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup Node.js&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;18"&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;npm"&lt;/span&gt;
          &lt;span class="na"&gt;cache-dependency-path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontend/package-lock.json&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install dependencies&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Lint&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run lint&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Type check&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx tsc --noEmit&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run tests&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm test -- --watchAll=false --coverage --passWithNoTests&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run build&lt;/span&gt;

  &lt;span class="na"&gt;backend-checks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Backend - Lint, Type Check, Test&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup Python&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-python@v5&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;python-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.11"&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pip"&lt;/span&gt;
          &lt;span class="na"&gt;cache-dependency-path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;config/requirements-dev.txt&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install backend tooling&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pip install -r ./config/requirements-dev.txt&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Lint&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flake8 ./lambda --max-line-length=120 --exclude=./lambda/layers&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Type check&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mypy ./lambda --config-file ./lambda/mypy.ini --ignore-missing-imports&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run tests&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pytest ./lambda -v --tb=short -c ./config/pytest.ini&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details matter here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;npm ci&lt;/code&gt; instead of &lt;code&gt;npm install&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;ci&lt;/code&gt; installs exactly what is in &lt;code&gt;package-lock.json&lt;/code&gt; and fails if the lockfile is out of sync. That is what you want in CI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The backend paths are explicit.&lt;/strong&gt; In AquaChain, shared Python tooling lives under &lt;code&gt;config/&lt;/code&gt;, so I reference &lt;code&gt;./config/requirements-dev.txt&lt;/code&gt; directly instead of assuming readers know what the runner's default directory is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--watchAll=false&lt;/code&gt; on Jest is non-negotiable.&lt;/strong&gt; Without it, the job hangs waiting for interactive input.&lt;/p&gt;

&lt;p&gt;If your Lambda estate gets large, this is the point where I would switch from one backend job to a matrix strategy per function. AquaChain's full workflow already does that for better parallelism and clearer failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching Is Worth It
&lt;/h2&gt;

&lt;p&gt;The cache lines in the workflow above are not decorative. On a codebase this size, a cold &lt;code&gt;npm ci&lt;/code&gt; can easily take roughly 60-90 seconds, while a warm cache often brings that same step down closer to 10-20 seconds. The exact numbers vary by runner, but the order-of-magnitude difference is real.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pip&lt;/code&gt; caching buys the same kind of win for Python tooling. If you skip caching, the pipeline still works. It just feels slower every single run, and that is how teams end up resenting CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Workflow 2: Deploy Frontend to Vercel
&lt;/h2&gt;

&lt;p&gt;I use the Vercel CLI instead of relying entirely on the built-in GitHub integration. It gives me tighter control over when builds happen and which environment variables are in play.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/deploy-frontend.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy Frontend&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;frontend/**"&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to Vercel&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup Node.js&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;18"&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;npm"&lt;/span&gt;
          &lt;span class="na"&gt;cache-dependency-path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontend/package-lock.json&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install Vercel CLI&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm install -g vercel@latest&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Pull Vercel environment&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontend&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontend&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;REACT_APP_API_ENDPOINT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.PROD_API_ENDPOINT }}&lt;/span&gt;
          &lt;span class="na"&gt;REACT_APP_USER_POOL_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.PROD_USER_POOL_ID }}&lt;/span&gt;
          &lt;span class="na"&gt;REACT_APP_USER_POOL_CLIENT_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.PROD_USER_POOL_CLIENT_ID }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontend&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;paths&lt;/code&gt; filter is doing two jobs for me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It stops backend-only commits from wasting frontend build minutes.&lt;/li&gt;
&lt;li&gt;It keeps the workflow history clean because only relevant deploys appear.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Convenience is great right up until you are explaining an unexpected deploy during an incident. I would rather be explicit.&lt;/p&gt;

&lt;p&gt;For PR previews, I use the same pattern on &lt;code&gt;pull_request&lt;/code&gt; without &lt;code&gt;--prod&lt;/code&gt;. Each PR gets its own preview deployment URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Workflow 3: Deploy Lambda to AWS
&lt;/h2&gt;

&lt;p&gt;This is the part I would explicitly not implement with &lt;code&gt;github.event.commits[0].modified&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That field only tells you what changed in the first commit listed in the push payload. On a multi-commit push or a squash merge, it can miss files that absolutely changed. It looks clever, but it is fragile.&lt;/p&gt;

&lt;p&gt;If a deployment decision depends on a webhook payload shortcut instead of the actual file paths I care about, I do not trust it.&lt;/p&gt;

&lt;p&gt;The more reliable pattern is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use a top-level &lt;code&gt;paths&lt;/code&gt; filter so the workflow runs only for backend changes.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;dorny/paths-filter&lt;/code&gt; inside the job when you need per-folder deploy decisions.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/deploy-backend.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy Backend&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lambda/**"&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy Lambda Functions&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;

    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Detect changed Lambda folders&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;changes&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dorny/paths-filter@v3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;filters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;data_processing:&lt;/span&gt;
              &lt;span class="s"&gt;- "lambda/data_processing/**"&lt;/span&gt;
            &lt;span class="s"&gt;notification_service:&lt;/span&gt;
              &lt;span class="s"&gt;- "lambda/notification_service/**"&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure AWS credentials (OIDC)&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;arn:aws:iam::123456789012:role/github-actions-deploy&lt;/span&gt;
          &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ap-south-1&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup Python&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-python@v5&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;python-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.11"&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy data processing Lambda&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.changes.outputs.data_processing == 'true'&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lambda/data_processing&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;zip -r function.zip . -x "tests/*" "*.pyc"&lt;/span&gt;
          &lt;span class="s"&gt;aws lambda update-function-code \&lt;/span&gt;
            &lt;span class="s"&gt;--function-name AquaChain-Function-data_processing-production \&lt;/span&gt;
            &lt;span class="s"&gt;--zip-file fileb://function.zip \&lt;/span&gt;
            &lt;span class="s"&gt;--region ap-south-1&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy notification API Lambda&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.changes.outputs.notification_service == 'true'&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lambda/notification_service&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;zip -r function.zip . -x "tests/*" "*.pyc"&lt;/span&gt;
          &lt;span class="s"&gt;aws lambda update-function-code \&lt;/span&gt;
            &lt;span class="s"&gt;--function-name AquaChain-Function-notification_service-production \&lt;/span&gt;
            &lt;span class="s"&gt;--zip-file fileb://function.zip \&lt;/span&gt;
            &lt;span class="s"&gt;--region ap-south-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you only have one Lambda function, the workflow-level &lt;code&gt;paths&lt;/code&gt; filter is enough. If you have many, add a change-detection step like the one above. AquaChain's current production workflow uses environment-specific names such as &lt;code&gt;AquaChain-Function-data_processing-production&lt;/code&gt;, so the example mirrors that pattern instead of pointing at a &lt;code&gt;-dev&lt;/code&gt; function.&lt;/p&gt;

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

&lt;p&gt;The security mistake I still see too often in GitHub Actions is storing &lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt; and &lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;/code&gt; as repository secrets.&lt;/p&gt;

&lt;p&gt;Those are long-lived credentials. If they leak, the problem does not end when the workflow ends.&lt;/p&gt;

&lt;p&gt;OIDC is the better pattern. GitHub Actions assumes an IAM role directly and receives short-lived credentials for that run only.&lt;/p&gt;

&lt;p&gt;Here is the CDK shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aws_cdk&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;aws_iam&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;iam&lt;/span&gt;

&lt;span class="n"&gt;github_provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;OpenIdConnectProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GitHubOIDC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://token.actions.githubusercontent.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;client_ids&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sts.amazonaws.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;deploy_role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GitHubActionsDeployRole&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;role_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;github-actions-deploy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;assumed_by&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WebIdentityPrincipal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;github_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;open_id_connect_provider_arn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;conditions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;StringEquals&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token.actions.githubusercontent.com:aud&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sts.amazonaws.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;StringLike&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token.actions.githubusercontent.com:sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;repo:your-org/your-repo:ref:refs/heads/main&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;deploy_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_to_policy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PolicyStatement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lambda:UpdateFunctionCode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lambda:UpdateFunctionConfiguration&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;resources&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arn:aws:lambda:ap-south-1:123456789012:function:AquaChain-Function-*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important detail is the &lt;code&gt;sub&lt;/code&gt; restriction. Without that, you are trusting GitHub broadly. With it, you are trusting one repository and one branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets Management
&lt;/h2&gt;

&lt;p&gt;GitHub gives you three useful scopes for secrets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repository secrets for repo-wide values&lt;/li&gt;
&lt;li&gt;Environment secrets for protected environments such as &lt;code&gt;production&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Organization secrets for shared values across repos&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For production deploys, I prefer environment secrets plus protection rules. That forces a deliberate approval step before the workflow can read sensitive values.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one line is small, but it changes the blast radius of a bad push.&lt;/p&gt;

&lt;h2&gt;
  
  
  Branch Protection Rules
&lt;/h2&gt;

&lt;p&gt;The pipeline only matters if people cannot quietly route around it.&lt;/p&gt;

&lt;p&gt;GitHub starts running these workflow files as soon as they exist under &lt;code&gt;.github/workflows/&lt;/code&gt; and you push them to the repo. What it does not do by itself is block merges. For that, go to &lt;strong&gt;Settings -&amp;gt; Branches&lt;/strong&gt; in the repository and lock down &lt;code&gt;main&lt;/code&gt; with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Required status checks&lt;/li&gt;
&lt;li&gt;Up-to-date branch enforcement&lt;/li&gt;
&lt;li&gt;At least one approving review&lt;/li&gt;
&lt;li&gt;No direct pushes to &lt;code&gt;main&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That turns the release path into one narrow lane instead of a social convention.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Full Picture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Developer pushes branch
        ↓
PR opened -&amp;gt; PR checks run
  ├── Frontend: lint + typecheck + test + build
  └── Backend: flake8 + mypy + pytest
        ↓
All checks pass -&amp;gt; PR review
        ↓
PR merged to main
        ↓
  ├── frontend/** changed -&amp;gt; deploy-frontend.yml -&amp;gt; Vercel production
  └── lambda/** changed   -&amp;gt; deploy-backend.yml  -&amp;gt; AWS Lambda
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  CI/CD Pipeline Visualization
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj3tjh8ezrf8anf69wku1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj3tjh8ezrf8anf69wku1.png" alt="GitHub Actions Pipeline" width="800" height="332"&gt;&lt;/a&gt;&lt;br&gt;
Real GitHub Actions workflow for AquaChain — showing code quality checks, testing, build, and deployment stages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next Steps I'm Planning
&lt;/h2&gt;

&lt;p&gt;This setup is already useful, but it is not the endpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add a smoke test after deployment.&lt;/strong&gt; A quick health-check call after deploy would catch broken releases before users do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Extract reusable workflow pieces.&lt;/strong&gt; Once the pipeline settles down, shared setup can move into reusable workflows or composite actions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep GitHub Actions versions current automatically.&lt;/strong&gt; Dependabot already makes sense for application dependencies. It should also keep workflow actions fresh.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Payoff
&lt;/h2&gt;

&lt;p&gt;Before this pipeline, a configuration mistake turned into a production debugging session over SSH. After it, that same class of mistake is far more likely to show up as a visible CI failure, a scoped deploy failure, or at minimum a traceable release with logs and approvals attached.&lt;/p&gt;

&lt;p&gt;That is the benchmark I care about now. The failed deploy from the intro should not be a live fire exercise on a server. It should be a boring red workflow run that never gets mistaken for a normal release.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>cicd</category>
      <category>automation</category>
      <category>github</category>
    </item>
    <item>
      <title>Serverless ML Inference with AWS Lambda + Docker</title>
      <dc:creator>Karthik K Pradeep</dc:creator>
      <pubDate>Sun, 22 Mar 2026 11:10:49 +0000</pubDate>
      <link>https://dev.to/foldedodin/serverless-ml-inference-with-aws-lambda-docker-25nk</link>
      <guid>https://dev.to/foldedodin/serverless-ml-inference-with-aws-lambda-docker-25nk</guid>
      <description>&lt;p&gt;Running ML models in production sounds simple until you realize you're paying for servers 24/7 even when nobody is using them. That was my situation. &lt;br&gt;
I had a model running on EC2, serving predictions through Flask. It worked. It also quietly burned money every hour of the day. So I rebuilt the entire inference pipeline using AWS Lambda and reduced costs to almost zero during idle time. &lt;br&gt;
This post walks through exactly how I did it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Problem with "Always-On" ML Inference
&lt;/h2&gt;

&lt;p&gt;When I first deployed a machine learning model, I followed the standard approach:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Flask API&lt;/li&gt;
&lt;li&gt;EC2 instance&lt;/li&gt;
&lt;li&gt;Load model at startup&lt;/li&gt;
&lt;li&gt;Serve predictions over HTTP&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It worked.&lt;/p&gt;

&lt;p&gt;But it also meant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Paying for compute 24/7&lt;/li&gt;
&lt;li&gt;Even at 3AM when traffic = 0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For systems like AquaChain, inference is event-driven:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bursts of requests from devices&lt;/li&gt;
&lt;li&gt;Long idle periods&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Running a server continuously for this pattern is wasteful.&lt;/p&gt;

&lt;p&gt;Enter: Serverless ML Inference&lt;br&gt;
With AWS Lambda:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You pay only when your model runs&lt;/li&gt;
&lt;li&gt;No idle infrastructure&lt;/li&gt;
&lt;li&gt;Fully event-driven execution&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;scikit-learn 1.4.0&lt;/li&gt;
&lt;li&gt;XGBoost 2.0.3&lt;/li&gt;
&lt;li&gt;numpy 1.26.3 + pandas 2.1.4&lt;/li&gt;
&lt;li&gt;Python 3.11&lt;/li&gt;
&lt;li&gt;AWS Lambda (container image)&lt;/li&gt;
&lt;li&gt;Amazon ECR (container registry)&lt;/li&gt;
&lt;li&gt;S3 (model artifact storage)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Project Structure
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ml_inference/
├── handler.py          # Lambda entry point
├── model_loader.py     # S3 model caching logic
├── feature_extractor.py
├── Dockerfile
└── requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  The Dockerfile
&lt;/h2&gt;

&lt;p&gt;The key is using AWS's official Lambda base image. It includes the Lambda runtime interface client, so your container behaves exactly like a standard Lambda function.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; public.ecr.aws/lambda/python:3.11&lt;/span&gt;

&lt;span class="c"&gt;# Copy requirements first for layer caching&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements.txt .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-cache-dir&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt

&lt;span class="c"&gt;# Copy function code&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; handler.py model_loader.py feature_extractor.py ./&lt;/span&gt;

&lt;span class="c"&gt;# Lambda handler entrypoint&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["handler.lambda_handler"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;requirements.txt&lt;/code&gt; for the ML stack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;scikit-learn&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;=1.4.0&lt;/span&gt;
&lt;span class="py"&gt;xgboost&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;=2.0.3&lt;/span&gt;
&lt;span class="py"&gt;numpy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;=1.26.3&lt;/span&gt;
&lt;span class="py"&gt;pandas&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;=2.1.4&lt;/span&gt;
&lt;span class="py"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;=1.34.34&lt;/span&gt;
&lt;span class="py"&gt;joblib&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;=1.3.2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;One important detail: put &lt;code&gt;COPY requirements.txt&lt;/code&gt; and &lt;code&gt;RUN pip install&lt;/code&gt; before copying your application code. Docker caches each layer — if your code changes but your dependencies don't, the pip install layer is reused and your build takes seconds instead of minutes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Handler
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;model_loader&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;get_model&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;feature_extractor&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;extract_features&lt;/span&gt;

&lt;span class="n"&gt;logger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLogger&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INFO&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lambda_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;readings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;readings&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
        &lt;span class="n"&gt;device_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;deviceId&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# Validate inputs before touching the model
&lt;/span&gt;        &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pH&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;turbidity&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;tds&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;readings&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;statusCode&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
                    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Missing fields: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;VALIDATION_ERROR&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
                &lt;span class="p"&gt;})&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;# Extract features (includes trend calculations)
&lt;/span&gt;        &lt;span class="n"&gt;features&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_features&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;readings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# Get model — cached in /tmp after first load
&lt;/span&gt;        &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_model&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="c1"&gt;# Run inference
&lt;/span&gt;        &lt;span class="n"&gt;wqi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;predict&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;predict_proba&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

        &lt;span class="n"&gt;quality&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;classify_wqi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wqi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Inference complete&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extra&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;deviceId&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;device_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;wqi&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;wqi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;quality&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;confidence&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;confidence&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;statusCode&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
                &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;wqi&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wqi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;quality&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;confidence&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;deviceId&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;device_id&lt;/span&gt;
            &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Inference error: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exc_info&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;statusCode&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
                &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Inference failed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;INFERENCE_ERROR&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
            &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;classify_wqi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wqi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;wqi&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Excellent&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;wqi&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Good&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;wqi&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Fair&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;wqi&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Poor&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Very Poor&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Model Caching: The Most Important Optimization
&lt;/h2&gt;

&lt;p&gt;Lambda's &lt;code&gt;/tmp&lt;/code&gt; directory persists across warm invocations of the same container instance. Loading a model from S3 on every request would add 200–500ms of latency and unnecessary S3 GET costs. Cache it in &lt;code&gt;/tmp&lt;/code&gt; on first load:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;joblib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;

&lt;span class="n"&gt;logger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLogger&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;MODEL_S3_BUCKET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;MODEL_BUCKET&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;MODEL_S3_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;MODEL_KEY&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;LOCAL_MODEL_PATH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/tmp/model.joblib&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;

&lt;span class="n"&gt;_model_cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# Module-level cache — survives across warm invocations
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_model&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="n"&gt;_model_cache&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_model_cache&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Using in-memory model cache&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_model_cache&lt;/span&gt;

    &lt;span class="c1"&gt;# Check /tmp first (warm container, model already downloaded)
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LOCAL_MODEL_PATH&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Loading model from /tmp cache&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;_model_cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;joblib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LOCAL_MODEL_PATH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_model_cache&lt;/span&gt;

    &lt;span class="c1"&gt;# Cold start — download from S3
&lt;/span&gt;    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Downloading model from s3://&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;MODEL_S3_BUCKET&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;MODEL_S3_KEY&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;s3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s3&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;download_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MODEL_S3_BUCKET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MODEL_S3_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LOCAL_MODEL_PATH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;_model_cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;joblib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LOCAL_MODEL_PATH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Model loaded and cached&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_model_cache&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two levels of caching here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;_model_cache&lt;/code&gt; — in-memory, fastest possible, survives as long as the container is warm&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/tmp/model.joblib&lt;/code&gt; — survives container reuse even if the Python process restarts&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On a cold start you pay the S3 download once. Every subsequent warm invocation skips it entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building and Pushing to ECR
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Authenticate Docker with ECR&lt;/span&gt;
aws ecr get-login-password &lt;span class="nt"&gt;--region&lt;/span&gt; ap-south-1 | &lt;span class="se"&gt;\&lt;/span&gt;
  docker login &lt;span class="nt"&gt;--username&lt;/span&gt; AWS &lt;span class="nt"&gt;--password-stdin&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  758346259059.dkr.ecr.ap-south-1.amazonaws.com

&lt;span class="c"&gt;# Build the image&lt;/span&gt;
docker build &lt;span class="nt"&gt;-t&lt;/span&gt; aquachain-ml-inference &lt;span class="nb"&gt;.&lt;/span&gt;

&lt;span class="c"&gt;# Tag for ECR&lt;/span&gt;
docker tag aquachain-ml-inference:latest &lt;span class="se"&gt;\&lt;/span&gt;
  758346259059.dkr.ecr.ap-south-1.amazonaws.com/aquachain-ml-inference:latest

&lt;span class="c"&gt;# Push&lt;/span&gt;
docker push &lt;span class="se"&gt;\&lt;/span&gt;
  758346259059.dkr.ecr.ap-south-1.amazonaws.com/aquachain-ml-inference:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then deploy the Lambda pointing at the ECR image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws lambda update-function-code &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--function-name&lt;/span&gt; aquachain-function-ml-inference-dev &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image-uri&lt;/span&gt; 758346259059.dkr.ecr.ap-south-1.amazonaws.com/aquachain-ml-inference:latest &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; ap-south-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  CDK Definition
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aws_cdk&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;aws_lambda&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;lambda_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;aws_ecr&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ecr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;aws_iam&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Duration&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Reference existing ECR repo
&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ecr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_repository_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MLInferenceRepo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aquachain-ml-inference&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;ml_inference_fn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lambda_&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DockerImageFunction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MLInferenceFunction&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;function_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aquachain-function-ml-inference-dev&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;lambda_&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DockerImageCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_ecr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;tag_or_digest&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;latest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;memory_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# ML models benefit from more memory
&lt;/span&gt;    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;environment&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MODEL_BUCKET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aquachain-models-dev&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MODEL_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wqi/model_v2.joblib&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LOG_LEVEL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INFO&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Grant S3 read access for model download
&lt;/span&gt;&lt;span class="n"&gt;model_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;grant_read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ml_inference_fn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Memory sizing matters here. I started at 512MB and saw ~180ms inference times. Bumping to 1024MB dropped it to ~85ms — Lambda allocates CPU proportionally to memory, so more memory = faster CPU = faster inference. Run a few tests at different memory sizes; the cost difference is often negligible compared to the latency improvement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling Cold Starts
&lt;/h2&gt;

&lt;p&gt;Cold starts for container-based Lambdas are longer than zip-based ones — typically 2–5 seconds for a 500MB image. For AquaChain this is acceptable because inference is triggered asynchronously (the data processing Lambda doesn't wait for the result). But if you need synchronous inference with strict latency SLAs, two options:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Provisioned Concurrency&lt;/strong&gt; — keeps N container instances warm at all times. Eliminates cold starts, but you pay for idle time. Only worth it if your p99 latency requirement is under 500ms and you have consistent traffic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Scheduled warm-up ping&lt;/strong&gt; — an EventBridge rule that invokes the function every 5 minutes with a dummy payload. Cheap, effective for low-traffic functions, but not a guarantee.&lt;/p&gt;

&lt;p&gt;For most ML inference use cases, async invocation + accepting occasional cold starts is the right trade-off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Updating the Model
&lt;/h2&gt;

&lt;p&gt;One of the best things about this setup: updating the model doesn't require a code deployment. You just upload a new &lt;code&gt;model.joblib&lt;/code&gt; to S3 with the same key. The next cold start picks it up automatically.&lt;/p&gt;

&lt;p&gt;For versioned rollouts, use S3 versioning and point the Lambda env var at a specific version ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Upload new model version&lt;/span&gt;
aws s3 &lt;span class="nb"&gt;cp &lt;/span&gt;model_v3.joblib s3://aquachain-models-dev/wqi/model_v2.joblib

&lt;span class="c"&gt;# If you need to roll back, just update the env var to point at the previous version&lt;/span&gt;
aws lambda update-function-configuration &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--function-name&lt;/span&gt; aquachain-function-ml-inference-dev &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--environment&lt;/span&gt; &lt;span class="s2"&gt;"Variables={MODEL_KEY=wqi/model_v1.joblib,MODEL_BUCKET=aquachain-models-dev}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; ap-south-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;p&gt;Running in production on AquaChain:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cold start (image download + model load)&lt;/td&gt;
&lt;td&gt;~2.1s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Warm inference (in-memory cache)&lt;/td&gt;
&lt;td&gt;~85ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Warm inference (first call, /tmp cache)&lt;/td&gt;
&lt;td&gt;~120ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory used&lt;/td&gt;
&lt;td&gt;~310MB of 1024MB allocated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per 1M inferences&lt;/td&gt;
&lt;td&gt;~$0.21&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Compare that to a &lt;code&gt;t3.small&lt;/code&gt; EC2 instance running 24/7: ~$15/month regardless of traffic. At our current inference volume, Lambda costs under $1/month.&lt;/p&gt;

&lt;h2&gt;
  
  
  When NOT to Use Lambda
&lt;/h2&gt;

&lt;p&gt;Serverless ML is not a silver bullet.&lt;/p&gt;

&lt;p&gt;Avoid Lambda if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need ultra-low latency (&amp;lt;50ms)&lt;/li&gt;
&lt;li&gt;You have constant high traffic&lt;/li&gt;
&lt;li&gt;Your model is extremely large (&amp;gt;5GB and slow to load)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In those cases, a dedicated endpoint (SageMaker / ECS / EC2) is a better fit.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;1. Use multi-stage Docker builds.&lt;/strong&gt; The current image includes build tools that aren't needed at runtime. A multi-stage build copies only the installed packages into the final image, reducing image size by 30–40% and speeding up cold starts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Pin the base image digest, not just the tag.&lt;/strong&gt; &lt;code&gt;python:3.11&lt;/code&gt; tags can change. Use the SHA256 digest for reproducible builds in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Add model validation on load.&lt;/strong&gt; Before caching the model, run a quick sanity check — predict on a known input and assert the output is in the expected range. Catches corrupted model files before they serve bad predictions.&lt;/p&gt;

&lt;p&gt;Serverless ML inference isn’t for every system.But for event-driven workloads — like AquaChain — it hits a rare sweet spot:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;low cost, zero idle infrastructure, and production-grade performance. &lt;/li&gt;
&lt;li&gt;If your model doesn’t need to run 24/7, your infrastructure shouldn’t either.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>machinelearning</category>
      <category>aws</category>
      <category>serverless</category>
      <category>devops</category>
    </item>
    <item>
      <title>How I Built a Serverless IoT Pipeline on AWS</title>
      <dc:creator>Karthik K Pradeep</dc:creator>
      <pubDate>Sat, 21 Mar 2026 07:10:20 +0000</pubDate>
      <link>https://dev.to/foldedodin/how-i-built-a-serverless-iot-pipeline-on-aws-49c</link>
      <guid>https://dev.to/foldedodin/how-i-built-a-serverless-iot-pipeline-on-aws-49c</guid>
      <description>&lt;p&gt;Water quality testing normally takes between 24 and 48 hours.&lt;br&gt;
This makes it almost impossible to monitor water quality in real-time, especially in cases where the water has to be clean in real-time.I wanted this time taken down to seconds.&lt;br&gt;
That is why I created a real-time water quality monitoring system using ESP32 sensors, AWS IoT Core, Lambda, and DynamoDB — a production-ready pipeline, not a demo.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Most IoT tutorials go this far:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sending a temperature reading&lt;/li&gt;
&lt;li&gt;Displaying it on a dashboard&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They don’t cover what happens when you need to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Handle 100K+ messages per hour reliably&lt;/li&gt;
&lt;li&gt;Run ML inference on every incoming reading&lt;/li&gt;
&lt;li&gt;Trigger alerts within &amp;lt;5 seconds&lt;/li&gt;
&lt;li&gt;Keep costs under $2 per device/month&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmf1oanwfazk3cohifv07.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmf1oanwfazk3cohifv07.png" alt="Water Quality Testing with ESP32 MicroController"&gt;&lt;/a&gt;&lt;br&gt;
This system is built as a totally serverless, event-driven architecture where each component is triggered by incoming data rather than continuous operation.&lt;br&gt;
It eliminates the need for infrastructure management as there are no EC2 instances, container orchestration layers, or manual scaling configurations.&lt;br&gt;
Each service (IoT Core, Lambda, DynamoDB, and API Gateway) scales independently based on workload, enabling the system to handle variable data ingestion rates efficiently while maintaining low operational overhead.&lt;/p&gt;
&lt;h2&gt;
  
  
  Device Layer: ESP32 + MQTT
&lt;/h2&gt;

&lt;p&gt;Each ESP32 device collects sensor data every 60 seconds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pH (0–14)&lt;/li&gt;
&lt;li&gt;Turbidity (0–1000 NTU)&lt;/li&gt;
&lt;li&gt;TDS — Total Dissolved Solids (0–2000 ppm)&lt;/li&gt;
&lt;li&gt;Temperature (-10°C to 50°C)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example payload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"deviceId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ESP32-ABC123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2024-01-15T10:30:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"readings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"pH"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;7.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"turbidity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;3.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;450&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"temperature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;22.5&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"metadata"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"firmwareVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.1.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"batteryLevel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"signalStrength"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;-45&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why MQTT over HTTP? It's a fraction of the overhead. MQTT keeps a persistent TCP connection, so each message is just the payload no HTTP headers, no TLS handshake per message. At 100K messages/hour that difference matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  AWS IoT Core: Secure Ingestion Layer
&lt;/h2&gt;

&lt;p&gt;IoT Core acts as the managed MQTT broker. Devices connect with X.509 certificates — no username/password, no API keys. Each device gets its own cert, so you can revoke a single compromised device without touching anything else.&lt;br&gt;
The IoT Rule that routes messages to Lambda is dead simple:&lt;br&gt;
&lt;code&gt;SELECT * FROM 'aquachain/devices/+/data'&lt;br&gt;
&lt;/code&gt;&lt;br&gt;
The '+' wildcard matches any device ID, and the IoT Core evaluates this rule for every matching message, invoking the Lambda function. This approach eliminates the need for polling and queues, making it a pure event-driven system.&lt;/p&gt;
&lt;h2&gt;
  
  
  Lambda: Validation and Storage
&lt;/h2&gt;

&lt;p&gt;The data processing Lambda does three things: validate, store, and trigger inference.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lambda_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;device_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;deviceId&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;readings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;readings&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="c1"&gt;# 1. Validate sensor ranges
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;validate_readings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;readings&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invalid readings from &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;device_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extra&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;deviceId&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;device_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;readings&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;readings&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;  &lt;span class="c1"&gt;# Drop the message, don't store garbage
&lt;/span&gt;
    &lt;span class="c1"&gt;# 2. Store in DynamoDB
&lt;/span&gt;    &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Item&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;deviceId&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;device_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;readings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ttl&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utcnow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="c1"&gt;# 3. Trigger ML inference asynchronously
&lt;/span&gt;    &lt;span class="n"&gt;lambda_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;FunctionName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;aquachain-function-ml-inference-dev&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;InvocationType&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Event&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# async — don't wait
&lt;/span&gt;        &lt;span class="n"&gt;Payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth calling out here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Validation is non-negotiable.&lt;/strong&gt; Sensors drift, connectors corrode, and firmware bugs happen. If you store garbage readings, your ML model trains on garbage. I reject anything outside physical bounds — pH can't be 15, temperature can't be -50°C.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. TTL is free data lifecycle management&lt;/strong&gt;. DynamoDB's TTL feature automatically deletes items after a timestamp you set. Raw readings expire after 90 days with zero Lambda invocations and zero cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Async ML invocation&lt;/strong&gt;. I invoke the inference Lambda with InvocationType='Event' so the data processing function returns immediately. The ML inference runs in parallel. This is what keeps the pipeline fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Latency Reality
&lt;/h2&gt;

&lt;p&gt;Here's what the actual CloudWatch data shows for the data processing Lambda:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Duration&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Warm, no DB write&lt;/td&gt;
&lt;td&gt;~2ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Warm, with DynamoDB PutItem&lt;/td&gt;
&lt;td&gt;~150ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold start&lt;/td&gt;
&lt;td&gt;~617ms init + ~2ms execution&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The warm execution with a DynamoDB write averages around 150ms. Cold starts add ~617ms on top. The product aim was &amp;lt;5 seconds from sensor reading to dashboard  we're well inside that. However, if you consistently target sub-100ms Lambda execution, the DynamoDB write is likely your bottleneck. The fix involves making the write asynchronous via SQS, so the Lambda validates and returns in ~2ms, while a separate consumer handles persistence.&lt;/p&gt;

&lt;h2&gt;
  
  
  ML Inference: XGBoost on Lambda
&lt;/h2&gt;

&lt;p&gt;The ML model is an XGBoost classifier that outputs a Water Quality Index (WQI) from 0-100. It is fully contained in Lambda, no SageMaker endpoint to keep warm, no idle costs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lambda_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Load model from S3 (cached in /tmp after first load)
&lt;/span&gt;    &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load_model&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;features&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_features&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;readings&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;wqi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;predict&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;wqi&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;trigger_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;deviceId&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;wqi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;readings&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;wqi&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wqi&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;quality&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;classify_wqi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wqi&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ML model used here is an XGBoost classifier that outputs a &lt;strong&gt;Water Quality Index (WQI)&lt;/strong&gt; ranging from 0-100. It is fully contained in Lambda, no SageMakeThe model caches in /tmp after the initial run; subsequent warm calls skip S3 downloads, keeping inference under 100ms. Speaking of model performance, this model has 99.74% accuracy on the validation set, which sounds great until you see that the data is highly structured and the classes are well-separated. XGBoost is actually the correct choice here, as it handles missing sensor values, trains quickly, and is even interpretable enough that you be able to explain why it flagged a particular reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  DynamoDB: Schema Design for Time-Series
&lt;/h2&gt;

&lt;p&gt;The readings table uses a composite key:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Partition key: deviceId&lt;/li&gt;
&lt;li&gt;Sort key: timestamp
This means all readings for a device are co-located on the same partition, and you can query a time range with a single DynamoDB Query call, no scans, no GSIs needed for the primary access pattern.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;KeyConditionExpression&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;deviceId&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;device_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; 
                           &lt;span class="nc"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;between&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;ScanIndexForward&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# newest first
&lt;/span&gt;    &lt;span class="n"&gt;Limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing I got wrong early on: I was storing floats directly. DynamoDB doesn't support Python floats; it uses Decimal. The fix is a recursive converter before any put_item call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;floats_to_decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;floats_to_decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;floats_to_decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Infrastructure as Code: AWS CDK
&lt;/h2&gt;

&lt;p&gt;Everything is defined in Python CDK. No clicking around the console, no manual resource creation. The IoT rule, Lambda functions, DynamoDB tables, IAM policies are all code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# IoT Rule → Lambda
&lt;/span&gt;&lt;span class="n"&gt;iot_rule&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;iot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CfnTopicRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DataIngestionRule&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;rule_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aquachain_data_ingestion_dev&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;topic_rule_payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;iot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CfnTopicRule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TopicRulePayloadProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;aquachain/devices/+/data&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;iot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CfnTopicRule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ActionProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;lambda_&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;iot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CfnTopicRule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LambdaActionProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;function_arn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;data_processing_fn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;function_arn&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Grant IoT Core permission to invoke Lambda
&lt;/span&gt;&lt;span class="n"&gt;data_processing_fn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_permission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;IoTInvoke&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ServicePrincipal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iot.amazonaws.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;source_arn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;iot_rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attr_arn&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The IAM policy for the data processing Lambda follows least privilege. It can only write to the specific DynamoDB table and invoke the specific ML inference function. Nothing else.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;1. SQS Buffer between IoT Core &amp;amp; Lambda.&lt;/strong&gt; Rule -&amp;gt; Lambda is fine, but throttling on Lambda will cause IoT Core to drop messages. An SQS queue between the two provides a buffer &amp;amp; retry functionality automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Using DynamoDB on-demand since day on&lt;/strong&gt;e. I used provisioned capacity initially, which consumed a lot of time to get the read &amp;amp; write units right. Using on-demand costs a little more per request, but removes the need to think about capacity planning altogether. It’s worth paying a little more considering the nature of IoT data, which can cause spikes in usage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Using structured logging since day one&lt;/strong&gt;. I implemented structured logging in JSON later on, which was a nightmare to add to 30+ Lambda functions. It would have been so much easier to add a logging utility that adds a device ID, request ID, &amp;amp; timestamp to every log line, which would have made querying logs a breeze using CloudWatch Insights.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;p&gt;After running in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;712 Lambda invocations over the past week, zero errors&lt;/li&gt;
&lt;li&gt;Average warm execution: ~83ms&lt;/li&gt;
&lt;li&gt;Cold start init: ~615ms (happens rarely after the function is warm)&lt;/li&gt;
&lt;li&gt;DynamoDB write latency: ~148ms average&lt;/li&gt;
&lt;li&gt;Cost: well under $2/device/month at current scale
In this case, the serverless model really delivers on its promise. There is no infrastructure to babysit, scaling is automatic, and costs scale linearly with usage, not being a flat monthly charge.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;The hardest part of this build was not the AWS services themselves – the documentation is good, and the SDKs are solid. The hardest part was the sensor layer – calibration drift, WiFi reconnection logic, etc.&lt;br&gt;
The cloud pipeline is actually the easy part – as long as you've got the DynamoDB key schema correct upfront, validate at the edge before anything goes into the database, and keep the Lambda functions thin, validate, store, and delegate.&lt;br&gt;
The entire stack – ESP32 firmware, Lambda functions, CDK infrastructure, React dashboard is the AquaChain project. Would be happy to dive deeper into any of these parts.&lt;br&gt;
_&lt;br&gt;
Built with: ESP32, AWS IoT Core, Lambda (Python 3.11), DynamoDB, XGBoost, AWS CDK, React 19_&lt;/p&gt;

</description>
      <category>aws</category>
      <category>iot</category>
      <category>serverless</category>
      <category>backend</category>
    </item>
  </channel>
</rss>
