<?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/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title></title>
    <description>Personal Blog where I write about things I learn or discover.</description>
    <link>https://muhammadraza.me/</link>
    <atom:link href="https://muhammadraza.me/feed.xml" rel="self" type="application/rss+xml"/>
    <pubDate>Sun, 19 Jul 2026 02:40:12 +0000</pubDate>
    <lastBuildDate>Sun, 19 Jul 2026 02:40:12 +0000</lastBuildDate>
    <generator>Jekyll v3.10.0</generator>
    
      <item>
        <title>What Actually Happens During an ECS Rolling Deployment</title>
        <description>
          <![CDATA[
            
            <p>You push a new image, update an ECS service, and the console says <code class="language-plaintext highlighter-rouge">Deployment in progress</code>. Then it sits there.</p>

<p>A new task appears in <code class="language-plaintext highlighter-rouge">PENDING</code>. It changes to <code class="language-plaintext highlighter-rouge">RUNNING</code>, but the old task does not go away. A minute later the old one starts draining. Eventually it disappears and the service returns to steady state. If you only watch the task count, the whole thing looks oddly slow and a little random.</p>

<p>It is neither. Four systems are working on the deployment at the same time:</p>

<ul>
  <li>The ECS scheduler is enforcing minimum and maximum task counts.</li>
  <li>The ECS agent or Fargate runtime is provisioning the task and starting its containers.</li>
  <li>The load balancer is deciding whether the new target should receive traffic.</li>
  <li>The old container is trying to finish requests before ECS kills it.</li>
</ul>

<p>Most of the confusing failures I see come from treating those four systems as one thing. A task can be running but not ready. It can be healthy in ECS but unhealthy in the target group. It can be removed from the load balancer and still spend another minute shutting down.</p>

<p>We are going to follow one deployment from the API call to the final stopped task. At each pause, we will look at which system owns the wait and what condition it needs before the rollout can continue.</p>

<h2 id="the-example-service">The example service</h2>

<p>We will use a small Fargate service behind an Application Load Balancer:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>service: api
desired count: 4
current task definition: api:41
new task definition: api:42
minimumHealthyPercent: 100
maximumPercent: 200
healthCheckGracePeriodSeconds: 60
</code></pre></div></div>

<p>The service begins with four healthy tasks running revision <code class="language-plaintext highlighter-rouge">api:41</code>. Our pipeline registers <code class="language-plaintext highlighter-rouge">api:42</code>, then calls <code class="language-plaintext highlighter-rouge">UpdateService</code> to point the service at the new revision.</p>

<p>The deployment settings create a range:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>minimum healthy = ceil(4 × 100%) = 4
maximum running or pending = floor(4 × 200%) = 8
</code></pre></div></div>

<p>ECS must keep at least four healthy tasks available, but it may temporarily run as many as eight. With enough Fargate or EC2 capacity, the scheduler can start all four replacements before touching the old tasks.</p>

<p>When a rollout behaves strangely, I calculate this range before looking anywhere else.</p>

<h2 id="the-service-revision-changes-first">The service revision changes first</h2>

<p>Registering a task definition does not deploy it. It only creates an immutable blueprint such as <code class="language-plaintext highlighter-rouge">api:42</code>.</p>

<p>Updating the service is what starts the deployment:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws ecs update-service <span class="se">\</span>
  <span class="nt">--cluster</span> production <span class="se">\</span>
  <span class="nt">--service</span> api <span class="se">\</span>
  <span class="nt">--task-definition</span> api:42
</code></pre></div></div>

<p>ECS records the configuration it is leaving and the configuration it is trying to reach. In the current ECS deployment model, these are the source and target service revisions. A service revision includes more than the task definition: it records the workload configuration ECS is attempting to deploy and gives rollback a known previous state.</p>

<p>No customer traffic has moved yet. ECS has only changed desired state:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>source: api:41, four healthy tasks
target: api:42, zero healthy tasks
deployment: IN_PROGRESS
</code></pre></div></div>

<p>The service scheduler now begins reconciling reality with the new declaration.</p>

<h2 id="the-scheduler-finds-room-for-the-new-tasks">The scheduler finds room for the new tasks</h2>

<p>The scheduler does not blindly replace one task at a time. It works inside the range created by <code class="language-plaintext highlighter-rouge">minimumHealthyPercent</code> and <code class="language-plaintext highlighter-rouge">maximumPercent</code>.</p>

<p><code class="language-plaintext highlighter-rouge">minimumHealthyPercent</code> is the availability floor. ECS rounds it up.</p>

<p><code class="language-plaintext highlighter-rouge">maximumPercent</code> is the concurrency ceiling for tasks in <code class="language-plaintext highlighter-rouge">RUNNING</code> or <code class="language-plaintext highlighter-rouge">PENDING</code>. ECS rounds it down.</p>

<p>The same four-task service behaves quite differently as those settings change:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: right">Minimum</th>
      <th style="text-align: right">Maximum</th>
      <th style="text-align: right">Allowed range</th>
      <th>Likely behavior</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right">100%</td>
      <td style="text-align: right">200%</td>
      <td style="text-align: right">4 to 8 tasks</td>
      <td>Start new tasks before stopping old tasks</td>
    </tr>
    <tr>
      <td style="text-align: right">50%</td>
      <td style="text-align: right">100%</td>
      <td style="text-align: right">2 to 4 tasks</td>
      <td>Stop up to two old tasks to make room</td>
    </tr>
    <tr>
      <td style="text-align: right">75%</td>
      <td style="text-align: right">125%</td>
      <td style="text-align: right">3 to 5 tasks</td>
      <td>Replace roughly one task at a time</td>
    </tr>
  </tbody>
</table>

<p>These percentages affect availability, but on ECS with EC2 they also decide how much spare capacity a deployment needs. A service configured for <code class="language-plaintext highlighter-rouge">100/200</code> may run the old and new revisions together. If the cluster is already full, those new tasks have nowhere to go. Fargate hides the hosts, but account quotas, IP availability, and platform capacity can still block placement.</p>

<p>There is also a rounding trap. With a desired count of three and <code class="language-plaintext highlighter-rouge">maximumPercent: 125</code>, the upper limit is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>floor(3 × 1.25) = 3
</code></pre></div></div>

<p>ECS cannot start a fourth task. If <code class="language-plaintext highlighter-rouge">minimumHealthyPercent</code> also prevents it from stopping an old task, the deployment has no legal move. ECS emits a service event telling you that the deployment configuration cannot start or stop a task.</p>

<h2 id="the-first-task-pins-the-image">The first task pins the image</h2>

<p>Task definition <code class="language-plaintext highlighter-rouge">api:42</code> may say:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"image"</span><span class="p">:</span><span class="w"> </span><span class="s2">"123456789012.dkr.ecr.us-east-1.amazonaws.com/api:production"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>That tag is mutable. It can point to a different image tomorrow.</p>

<p>By default, ECS resolves image tags to digests during a deployment so that every task in the service runs identical image content. For a service with multiple tasks, the first new task is used to establish the image digest, and the remaining tasks use that digest.</p>

<p>Overwriting a tag does not change a running task. ECS needs a new deployment before it resolves and launches the new image. Unique tags, preferably the Git SHA, also save a lot of guesswork during an incident.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>api:production                         mutable pointer
api@sha256:8d6c...                     actual image content
</code></pre></div></div>

<p>The task then moves through the early lifecycle:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PROVISIONING → PENDING → ACTIVATING → RUNNING
</code></pre></div></div>

<p>Depending on the launch type and configuration, this includes finding capacity, attaching an ENI, pulling images, creating containers, configuring networking, registering targets, and starting essential containers.</p>

<p><code class="language-plaintext highlighter-rouge">RUNNING</code> is one of the most misleading words in the ECS console. It means the containers are running. It does not mean the application is ready for customer traffic, and ECS may not count the task as healthy yet.</p>

<h2 id="running-is-followed-by-another-wait"><code class="language-plaintext highlighter-rouge">RUNNING</code> is followed by another wait</h2>

<p>A task can be evaluated by two separate health systems:</p>

<ol>
  <li>A container health check defined in the ECS task definition.</li>
  <li>A load-balancer target group health check.</li>
</ol>

<p>If an essential container has an ECS health check and the service uses a load balancer, both must pass before the scheduler counts the task as healthy for the deployment.</p>

<p>A container health check runs inside the container:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"healthCheck"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
      </span><span class="s2">"CMD-SHELL"</span><span class="p">,</span><span class="w">
      </span><span class="s2">"curl -f http://localhost:8080/health || exit 1"</span><span class="w">
    </span><span class="p">],</span><span class="w">
    </span><span class="nl">"interval"</span><span class="p">:</span><span class="w"> </span><span class="mi">10</span><span class="p">,</span><span class="w">
    </span><span class="nl">"timeout"</span><span class="p">:</span><span class="w"> </span><span class="mi">5</span><span class="p">,</span><span class="w">
    </span><span class="nl">"retries"</span><span class="p">:</span><span class="w"> </span><span class="mi">3</span><span class="p">,</span><span class="w">
    </span><span class="nl">"startPeriod"</span><span class="p">:</span><span class="w"> </span><span class="mi">30</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">startPeriod</code> belongs to the container health check. Failed checks during that bootstrap window do not count toward the retry limit. If a check succeeds during the start period, the container becomes healthy and later failures count normally.</p>

<p><code class="language-plaintext highlighter-rouge">healthCheckGracePeriodSeconds</code> is different. It belongs to the ECS service and tells the service scheduler to ignore unhealthy container, load-balancer, or VPC Lattice health status for a period after each task starts.</p>

<p>The two timers sound interchangeable, but they sit at different layers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>container startPeriod
  protects the container health-check retry counter during startup

service health-check grace period
  prevents the ECS scheduler from replacing a new task during startup
</code></pre></div></div>

<p>Neither setting sends traffic to an unhealthy target. The Application Load Balancer still follows its own target health state. The grace period only changes how the ECS scheduler reacts to an unhealthy result.</p>

<p>For a newly registered ALB target, one successful health check is enough to mark it healthy. The target group’s healthy-threshold count applies when a previously unhealthy target is recovering. With the default 30-second health-check interval, even a healthy application may spend noticeable time waiting for the next probe.</p>

<p>Before the first <code class="language-plaintext highlighter-rouge">api:42</code> task counts toward the rollout, all of this must be true:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ECS task state: RUNNING
container health: HEALTHY
ALB target health: healthy
counts toward deployment minimum: yes
receiving customer traffic: yes
</code></pre></div></div>

<p>Now the new revision has proved that it can serve traffic. Until this point, stopping an old task would spend availability on a replacement that had not earned it.</p>

<h2 id="new-tasks-come-in-old-tasks-drain">New tasks come in, old tasks drain</h2>

<p>As new tasks become healthy, the scheduler gains room to remove old ones without crossing the availability floor.</p>

<p>With our <code class="language-plaintext highlighter-rouge">100/200</code> settings, the rollout may look like this:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>time     api:41 healthy     api:42 starting/healthy     total
t0              4                     0                   4
t1              4                     4 starting          8
t2              4                     4 healthy           8
t3              0                     4 healthy           4
</code></pre></div></div>

<p>Do not read that table as a promise that all four tasks move together. The scheduler chooses the batches. Placement capacity, startup time, health results, and throttling can turn the same configuration into a more incremental rollout.</p>

<p>When ECS decides to stop an old task behind an ALB, the task does not jump directly from <code class="language-plaintext highlighter-rouge">RUNNING</code> to <code class="language-plaintext highlighter-rouge">STOPPED</code>.</p>

<p>It moves through the shutdown side of the lifecycle:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>RUNNING → DEACTIVATING → STOPPING → DEPROVISIONING → STOPPED
</code></pre></div></div>

<p>During <code class="language-plaintext highlighter-rouge">DEACTIVATING</code>, ECS deregisters the task from the target group. The target enters draining, and the load balancer stops assigning it new requests while allowing existing connections time to complete according to the target group’s deregistration delay.</p>

<p>Then ECS stops the containers. On Linux, the container receives the signal defined by its image <code class="language-plaintext highlighter-rouge">STOPSIGNAL</code>, which is <code class="language-plaintext highlighter-rouge">SIGTERM</code> by default. ECS waits for the container’s <code class="language-plaintext highlighter-rouge">stopTimeout</code>. If the process is still alive after that window, it receives <code class="language-plaintext highlighter-rouge">SIGKILL</code>.</p>

<p>For graceful shutdown, all three layers need to agree:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ALB deregistration delay
    ≥ longest request or connection you intend to preserve

ECS stopTimeout
    ≥ time the application needs after SIGTERM

application shutdown handler
    stops accepting work and exits before stopTimeout
</code></pre></div></div>

<p>If the application ignores <code class="language-plaintext highlighter-rouge">SIGTERM</code>, no ECS setting can make its shutdown graceful. ECS will eventually kill it.</p>

<p>Long-lived WebSocket connections, streaming responses, background jobs, and queue consumers require special attention. A web server can stop accepting new requests and finish active ones. A worker may need to stop polling, return an in-flight message to the queue, or extend its visibility timeout. Rolling deployment safety is partly an application concern.</p>

<h2 id="ecs-reaches-steady-state">ECS reaches steady state</h2>

<p>The deployment completes after the target revision reaches the desired count and the old revision no longer has active tasks in the rollout.</p>

<p>For our service:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>api:41: 0 running tasks
api:42: 4 running and healthy tasks
desired count: 4
deployment: SUCCESSFUL
</code></pre></div></div>

<p>Your CI system does not perform the rollout. A deployment action usually registers the task definition, updates the service and, if configured, polls ECS until the service stabilizes. That is what the official GitHub action does when <code class="language-plaintext highlighter-rouge">wait-for-service-stability</code> is set to <code class="language-plaintext highlighter-rouge">true</code>. The scheduler still runs the deployment.</p>

<p>So when a pipeline appears frozen, the useful evidence is often somewhere else. ECS may be waiting for a health check, placement capacity, a draining connection, or the circuit-breaker threshold.</p>

<p>Start with ECS service events and deployment details, not the CI runner logs.</p>

<p>Useful commands include:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws ecs describe-services <span class="se">\</span>
  <span class="nt">--cluster</span> production <span class="se">\</span>
  <span class="nt">--services</span> api

aws ecs list-service-deployments <span class="se">\</span>
  <span class="nt">--cluster</span> production <span class="se">\</span>
  <span class="nt">--service</span> api

aws ecs describe-service-deployments <span class="se">\</span>
  <span class="nt">--service-deployment-arns</span> SERVICE_DEPLOYMENT_ARN

aws ecs list-tasks <span class="se">\</span>
  <span class="nt">--cluster</span> production <span class="se">\</span>
  <span class="nt">--service-name</span> api
</code></pre></div></div>

<p>Service deployment history includes the source and target revisions, deployment state, failed-task count, alarm state, timestamps, and rollback details. ECS retains recent deployment history, which is much more useful than reconstructing a rollout from a few console event messages.</p>

<h2 id="what-happens-when-the-new-tasks-fail">What happens when the new tasks fail?</h2>

<p>Failed deployments usually split into two groups. The difference tells you where to start looking.</p>

<h3 id="the-task-never-reaches-running">The task never reaches <code class="language-plaintext highlighter-rouge">RUNNING</code></h3>

<p>The task may have no capacity, fail to pull its image, or start with an execution role that cannot retrieve a secret. ENI attachment failures also happen here. So does an essential container that exits during startup.</p>

<p>These are launch failures. In its first stage, the deployment circuit breaker counts consecutive tasks that fail to reach <code class="language-plaintext highlighter-rouge">RUNNING</code>.</p>

<h3 id="the-task-runs-but-never-becomes-healthy">The task runs but never becomes healthy</h3>

<p>Sometimes the process starts but listens on the wrong port, or the health endpoint returns a failing status. The ALB security group may not reach the task. A slow application can run past its grace period. I have also seen health checks fail because the image did not contain <code class="language-plaintext highlighter-rouge">curl</code>, even though the application itself was fine.</p>

<p>These are health failures. After at least one new task reaches <code class="language-plaintext highlighter-rouge">RUNNING</code>, the circuit breaker moves to its second stage and watches container, load-balancer, and service-discovery health.</p>

<p>The circuit breaker threshold is based on half the desired count, bounded to a minimum of 3 and a maximum of 200:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>threshold = ceil(0.5 × desired count), bounded to [3, 200]
</code></pre></div></div>

<p>For a service with a desired count of one, the threshold is still three. A tiny service may launch the same broken task several times before ECS finally calls the deployment failed.</p>

<p>Enable rollback explicitly:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"deploymentCircuitBreaker"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"enable"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
    </span><span class="nl">"rollback"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"minimumHealthyPercent"</span><span class="p">:</span><span class="w"> </span><span class="mi">100</span><span class="p">,</span><span class="w">
  </span><span class="nl">"maximumPercent"</span><span class="p">:</span><span class="w"> </span><span class="mi">200</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>When the deployment fails, ECS can roll back to the last service revision that completed successfully. CloudWatch alarms cover a different failure mode: the tasks are technically healthy, but latency or error rate gets worse after the release.</p>

<p>I use both because they answer different questions:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>circuit breaker
  catches tasks that cannot launch or become healthy

CloudWatch deployment alarms
  catch applications that are running but behaving badly
</code></pre></div></div>

<p>A <code class="language-plaintext highlighter-rouge">/health</code> endpoint returning <code class="language-plaintext highlighter-rouge">200</code> cannot tell you that checkout latency tripled or every database write is failing. The scheduler only knows what you expose to it.</p>

<h2 id="why-healthy-deployments-still-cause-errors">Why healthy deployments still cause errors</h2>

<p>ECS can execute a perfect rolling deployment and users can still see failures.</p>

<h3 id="the-new-and-old-versions-are-incompatible">The new and old versions are incompatible</h3>

<p>During a rolling deployment, both versions receive traffic. A destructive database migration, incompatible queue payload, or changed cache format can break one revision while the other is still alive.</p>

<p>Database changes should generally follow expand-and-contract:</p>

<ol>
  <li>Add the new schema while keeping the old schema valid.</li>
  <li>Deploy code that can work with both representations.</li>
  <li>Migrate data.</li>
  <li>Remove the old schema in a later deployment.</li>
</ol>

<h3 id="readiness-is-too-shallow">Readiness is too shallow</h3>

<p>Returning <code class="language-plaintext highlighter-rouge">200</code> because the HTTP process started is not enough if the application cannot reach a dependency required for serving requests. But checking every downstream system can also cause a cascading failure by removing all targets during a shared dependency outage.</p>

<p>The useful question is narrower: what condition means this particular task should stop receiving traffic? Write the health check around that contract.</p>

<h3 id="shutdown-is-not-graceful">Shutdown is not graceful</h3>

<p>If the process exits immediately on <code class="language-plaintext highlighter-rouge">SIGTERM</code>, active requests die even though the ALB is draining the target. If it waits longer than <code class="language-plaintext highlighter-rouge">stopTimeout</code>, ECS kills it anyway.</p>

<h3 id="sticky-sessions-or-local-state-hide-the-overlap">Sticky sessions or local state hide the overlap</h3>

<p>Rolling deployments assume tasks are replaceable. Sessions stored only in process memory, local uploads, or singleton background work make replacement unsafe. The scheduler cannot protect state it does not know exists.</p>

<h2 id="the-deployment-settings-i-start-with">The deployment settings I start with</h2>

<p>For a normal stateless HTTP service on Fargate behind an ALB, I start here:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>desired count: at least 2
minimumHealthyPercent: 100
maximumPercent: 200
deployment circuit breaker: enabled with rollback
CloudWatch alarms: 5xx rate and latency, with rollback
health-check grace period: measured startup time plus margin
image tag: immutable Git SHA
container stopTimeout: matched to graceful shutdown behavior
</code></pre></div></div>

<p>I keep the ALB health check cheap and local. I test <code class="language-plaintext highlighter-rouge">SIGTERM</code> handling outside production. I also alarm on <code class="language-plaintext highlighter-rouge">SERVICE_DEPLOYMENT_FAILED</code> through EventBridge because an automatic rollback that nobody notices is still a failed release.</p>

<p>If doubling task count is too expensive or the EC2 cluster cannot hold it, lower <code class="language-plaintext highlighter-rouge">maximumPercent</code> carefully and verify that the scheduler still has room to make progress. Saving temporary capacity is not useful if it creates a deployment deadlock or reduces availability below what the application can tolerate.</p>

<h2 id="a-practical-debugging-order">A practical debugging order</h2>

<p>When a deployment is slow or stuck, inspect it in this order:</p>

<ol>
  <li><strong>Deployment state:</strong> What are the source and target service revisions?</li>
  <li><strong>Scheduler math:</strong> Do the minimum and maximum percentages allow a task to start or stop?</li>
  <li><strong>Service events:</strong> Is placement, capacity, IAM, image pulling, or networking failing?</li>
  <li><strong>Task lifecycle:</strong> Are new tasks stuck in <code class="language-plaintext highlighter-rouge">PENDING</code>, stopping before <code class="language-plaintext highlighter-rouge">RUNNING</code>, or running and then replaced?</li>
  <li><strong>Stopped-task reason:</strong> What did ECS report for the failed task and essential container?</li>
  <li><strong>Target health:</strong> Is the ALB target initial, unhealthy, healthy, or draining? What reason does the target group report?</li>
  <li><strong>Application logs:</strong> Did the process bind the expected port and finish startup?</li>
  <li><strong>Timers:</strong> Are the container start period, service grace period, ALB interval, deregistration delay, and stop timeout consistent?</li>
  <li><strong>Rollback controls:</strong> Is the circuit breaker enabled, and are application alarms attached?</li>
</ol>

<p>The order matters. There is no point staring at application logs if ECS never started the container. Likewise, changing a health endpoint will not fix a target group that cannot reach the task through its security group.</p>

<h2 id="what-to-remember">What to remember</h2>

<p>An ECS rolling deployment is a constrained reconciliation loop:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>declare a new service revision
        ↓
calculate the legal task-count range
        ↓
start tasks from the new revision
        ↓
prove they can run and receive traffic
        ↓
drain traffic from the old tasks
        ↓
give old processes time to exit
        ↓
stop the old revision and reach steady state
</code></pre></div></div>

<p>The scheduler protects task counts. Health checks decide which tasks receive traffic. Draining gives active requests time to finish. The circuit breaker catches a rollout that cannot converge. CloudWatch alarms catch the more annoying case, where the rollout converges and the application still gets worse.</p>

<p>When the ECS console sits on <code class="language-plaintext highlighter-rouge">Deployment in progress</code>, I check three things: the task state, the target state, and whichever timer is active. So far, the pause has always been hiding in one of them.</p>

<h2 id="sources-and-further-reading">Sources and further reading</h2>

<ul>
  <li><a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/deployment-type-ecs.html">Deploy Amazon ECS services by replacing tasks</a></li>
  <li><a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-lifecycle-explanation.html">Amazon ECS task lifecycle</a></li>
  <li><a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/deployment-circuit-breaker.html">How the ECS deployment circuit breaker detects failures</a></li>
  <li><a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-deployment.html">Amazon ECS service deployment history</a></li>
  <li><a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-revision.html">Amazon ECS service revisions</a></li>
  <li><a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/load-balancer-healthcheck.html">Optimize load-balancer health checks for ECS</a></li>
  <li><a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/healthcheck.html">Determine task health with container health checks</a></li>
  <li><a href="https://aws.amazon.com/blogs/containers/automate-rollbacks-for-amazon-ecs-rolling-deployments-with-cloudwatch-alarms/">Automate rollback with CloudWatch alarms</a></li>
</ul>

          ]]>
        </description>
        <pubDate>Mon, 13 Jul 2026 00:00:00 +0000</pubDate>
        <link>https://muhammadraza.me/2026/what-actually-happens-during-an-ecs-rolling-deployment/</link>
        <guid isPermaLink="true">https://muhammadraza.me/2026/what-actually-happens-during-an-ecs-rolling-deployment/</guid>
        
        <category>aws</category>
        
        <category>devops</category>
        
        <category>ecs</category>
        
        <category>containers</category>
        
        
        
        <dc:creator>{&quot;name&quot;=&gt;&quot;Muhammad Raza&quot;}</dc:creator>
        <dc:rights></dc:rights>
      </item>
    
      <item>
        <title>How ECS Actually Works: A Visual Guide for People Who Know Kubernetes</title>
        <description>
          <![CDATA[
            
            <p>Every few months I have the same conversation. A small team, three to eight engineers, is containerizing their app, and someone says “we should use Kubernetes, that’s the industry standard.” Six months later they’re maintaining a small distributed systems platform on the side, and the app they were supposed to ship is still competing for attention with CNI upgrades.</p>

<p>I’ve written before about <a href="/2025/ecs-decisions-that-waste-6-weeks/">the ECS decisions that waste six weeks</a>. This post is the prequel: what ECS actually is, how it maps onto the Kubernetes concepts you already know, and what you stop carrying on your pager when you choose it. There are a few interactive diagrams below. Click around in them; they teach the model faster than prose does.</p>

<p>One thing before we start: this is not a “Kubernetes bad” post. EKS is the right choice for some teams, and I’ll tell you exactly which ones at the end. But I’ve watched too many three-person teams default to EKS because it felt like the serious choice, without anyone explaining what they were signing up to operate.</p>

<h2 id="ecs-is-an-orchestrator-thats-it">ECS is an orchestrator. That’s it.</h2>

<p>Strip away the branding and every container orchestrator does the same job: you declare what should be running, and a control loop makes reality match the declaration. Kubernetes does this. Nomad does this. ECS does this.</p>

<p>ECS just exposes far fewer moving parts to you. Here’s the whole object model. Click each piece:</p>

<style>
/* ---- ecsx shared widget styles (scoped) ---- */
.ecsx{background:#0e131b;border:1px solid #2a3447;border-radius:8px;padding:18px;margin:1.6em 0;
  font-family:"JetBrains Mono",monospace;color:#dbe2ee;font-size:13px;line-height:1.5}
.ecsx *{box-sizing:border-box}
.ecsx-title{font-size:11px;letter-spacing:.18em;color:#7d8aa3;margin-bottom:14px;text-transform:uppercase}
.ecsx button{font-family:inherit;font-size:12px;background:#1a2333;color:#dbe2ee;border:1px solid #36435c;
  border-radius:5px;padding:7px 12px;cursor:pointer;transition:all .15s}
.ecsx button:hover{border-color:#ffb454;color:#ffb454}
.ecsx button:disabled{opacity:.4;cursor:default}
.ecsx-badge{display:inline-block;font-size:10px;padding:2px 8px;border-radius:99px;border:1px solid #4b79c4;
  color:#8db8f8;margin-left:8px;white-space:nowrap}
.ecsx-flex{display:flex;gap:16px;flex-wrap:wrap}
@media(max-width:640px){.ecsx{font-size:12px}}
/* anatomy */
.ecsx-anat-box{border:1.5px solid;border-radius:7px;padding:10px;cursor:pointer;transition:background .15s}
.ecsx-anat-box:hover{background:rgba(255,180,84,.06)}
.ecsx-anat-box.sel{background:rgba(255,180,84,.12)}
.ecsx-anat-label{font-size:11px;letter-spacing:.08em;margin-bottom:8px;font-weight:700}
.ecsx-info{flex:1;min-width:240px;border-left:2px solid #ffb454;padding:4px 0 4px 14px;align-self:center}
.ecsx-info h4{margin:0 0 6px;font-size:14px;color:#ffb454;font-family:inherit}
.ecsx-info p{margin:0;color:#aab4c8;font-size:12.5px}
/* recon */
.ecsx-taskgrid{display:flex;gap:10px;flex-wrap:wrap;min-height:84px;margin:12px 0}
.ecsx-task{width:118px;border:1.5px solid #3e9c5a;border-radius:6px;padding:8px;cursor:pointer;
  transition:opacity .4s, transform .4s}
.ecsx-task .id{font-size:11px;color:#7d8aa3}
.ecsx-task .st{font-size:11px;font-weight:700;margin-top:4px}
.ecsx-task.RUNNING{border-color:#3e9c5a}.ecsx-task.RUNNING .st{color:#79d68a}
.ecsx-task.PROVISIONING{border-color:#b98a3c;animation:ecsxpulse 1s infinite}.ecsx-task.PROVISIONING .st{color:#ffb454}
.ecsx-task.DRAINING{border-color:#5c677c;opacity:.55}.ecsx-task.DRAINING .st{color:#8b96ab}
.ecsx-task.STOPPED{border-color:#c44f5e;opacity:.25;transform:scale(.92)}.ecsx-task.STOPPED .st{color:#ff6b7d}
.ecsx-ver{display:inline-block;font-size:10px;padding:1px 7px;border-radius:99px;margin-top:5px}
.ecsx-ver.v1{background:#16344e;color:#7fc4ff}.ecsx-ver.v2{background:#33234e;color:#c9a6ff}
@keyframes ecsxpulse{50%{background:rgba(255,180,84,.08)}}
.ecsx-log{background:#0a0e15;border:1px solid #232c3d;border-radius:6px;padding:10px 12px;font-size:11.5px;
  height:118px;overflow:hidden;display:flex;flex-direction:column;justify-content:flex-end;color:#94a0b8}
.ecsx-log .t{color:#525e75;margin-right:8px}
.ecsx-log .hl{color:#ffb454}.ecsx-log .ok{color:#79d68a}.ecsx-log .bad{color:#ff6b7d}
.ecsx-svchead{display:flex;gap:18px;flex-wrap:wrap;font-size:12px;color:#aab4c8;margin-bottom:4px}
.ecsx-svchead b{color:#dbe2ee}
/* stack */
.ecsx-cols{display:flex;gap:14px;flex-wrap:wrap;margin-top:12px}
.ecsx-col{flex:1;min-width:230px}
.ecsx-colhead{text-align:center;font-weight:700;font-size:13px;padding:8px;border-bottom:2px solid #36435c;margin-bottom:8px}
.ecsx-cell{border-radius:5px;padding:8px 10px;margin-bottom:6px;font-size:12px;border:1px solid;min-height:54px}
.ecsx-cell .who{font-size:10px;font-weight:700;letter-spacing:.1em;display:block;margin-bottom:2px}
.ecsx-cell.aws{background:rgba(62,156,90,.10);border-color:#2c5e3e}.ecsx-cell.aws .who{color:#79d68a}
.ecsx-cell.you{background:rgba(255,180,84,.10);border-color:#7a5a28}.ecsx-cell.you .who{color:#ffb454}
.ecsx-cell.na{background:rgba(120,130,150,.05);border-color:#2a3447;color:#67738c}.ecsx-cell.na .who{color:#67738c}
.ecsx-score{margin-top:10px;padding:10px 12px;background:#0a0e15;border:1px solid #232c3d;border-radius:6px;
  font-size:12.5px;color:#aab4c8}
.ecsx-score b{color:#ffb454}
.ecsx-toggle{display:inline-flex;border:1px solid #36435c;border-radius:6px;overflow:hidden;margin-left:10px}
.ecsx-toggle button{border:none;border-radius:0;padding:5px 12px;font-size:11px}
.ecsx-toggle button.on{background:#ffb454;color:#1a1206}
</style>

<div class="ecsx" id="ecsx-anatomy">
  <div class="ecsx-title">The entire ECS object model — click anything</div>
  <div class="ecsx-flex">
    <div style="flex:1.4;min-width:280px">
      <div class="ecsx-anat-box" data-k="cluster" style="border-color:#4b79c4">
        <div class="ecsx-anat-label" style="color:#8db8f8">CLUSTER</div>
        <div class="ecsx-anat-box" data-k="service" style="border-color:#3e9c5a">
          <div class="ecsx-anat-label" style="color:#79d68a">SERVICE — web · desired: 3</div>
          <div style="display:flex;gap:8px;flex-wrap:wrap">
            <div class="ecsx-anat-box" data-k="task" style="border-color:#ffb454;flex:1;min-width:110px">
              <div class="ecsx-anat-label" style="color:#ffb454">TASK</div>
              <div class="ecsx-anat-box" data-k="container" style="border-color:#c9a6ff;font-size:11px;color:#c9a6ff">container: app</div>
              <div class="ecsx-anat-box" data-k="container" style="border-color:#c9a6ff;font-size:11px;color:#c9a6ff;margin-top:6px">container: nginx</div>
            </div>
            <div class="ecsx-anat-box" data-k="task" style="border-color:#ffb454;flex:1;min-width:110px">
              <div class="ecsx-anat-label" style="color:#ffb454">TASK</div>
              <div class="ecsx-anat-box" data-k="container" style="border-color:#c9a6ff;font-size:11px;color:#c9a6ff">container: app</div>
              <div class="ecsx-anat-box" data-k="container" style="border-color:#c9a6ff;font-size:11px;color:#c9a6ff;margin-top:6px">container: nginx</div>
            </div>
            <div class="ecsx-anat-box" data-k="task" style="border-color:#ffb454;flex:1;min-width:110px">
              <div class="ecsx-anat-label" style="color:#ffb454">TASK</div>
              <div class="ecsx-anat-box" data-k="container" style="border-color:#c9a6ff;font-size:11px;color:#c9a6ff">container: app</div>
              <div class="ecsx-anat-box" data-k="container" style="border-color:#c9a6ff;font-size:11px;color:#c9a6ff;margin-top:6px">container: nginx</div>
            </div>
          </div>
        </div>
      </div>
      <div class="ecsx-anat-box" data-k="taskdef" style="border-color:#e06c75;margin-top:10px">
        <div class="ecsx-anat-label" style="color:#e06c75">TASK DEFINITION — web:42 <span style="color:#67738c;font-weight:400">(the blueprint the service stamps tasks from)</span></div>
      </div>
    </div>
    <div class="ecsx-info" id="ecsx-anat-info">
      <h4>Click a component</h4>
      <p>Every box on the left has a direct Kubernetes equivalent. Click to see what it is and what it maps to.</p>
    </div>
  </div>
</div>

<script>
(function(){
  var INFO = {
    cluster:  ['Cluster', 'Kubernetes equivalent: cluster',
      'A logical boundary for compute and workloads. Unlike a Kubernetes cluster, there is no control plane living inside it that you can see, version, or break — the scheduler and state store are an AWS regional service. There is nothing to upgrade. Ever.'],
    service:  ['Service', 'Kubernetes equivalent: Deployment + Service',
      'Holds the declaration: "keep N copies of this task definition running, registered behind this load balancer target group." It is the reconciliation loop — it replaces dead tasks, performs rolling deployments, and hooks into autoscaling. One ECS object does what a Deployment, ReplicaSet, and Service do together in Kubernetes.'],
    task:     ['Task', 'Kubernetes equivalent: Pod',
      'One running copy of your workload: one or more containers scheduled together on the same host, sharing a network namespace and an IAM role. With the awsvpc network mode every task gets its own ENI and private IP — same mental model as a pod IP.'],
    container:['Container', 'Kubernetes equivalent: container',
      'Exactly what you think it is. Sidecars work the same way as in a pod — an nginx or log-router container scheduled next to your app container inside the same task.'],
    taskdef:  ['Task Definition', 'Kubernetes equivalent: pod spec (+ a bit of Deployment)',
      'A versioned, immutable JSON document: images, CPU/memory, env vars, ports, volumes, IAM role. Every revision gets a number (web:41, web:42). A deployment is literally "point the service at a new revision." No Helm, no templating layer — which is both the good news and the bad news.'],
  };
  var root = document.getElementById('ecsx-anatomy');
  var info = document.getElementById('ecsx-anat-info');
  root.addEventListener('click', function(e){
    var box = e.target.closest('.ecsx-anat-box');
    if(!box) return;
    e.stopPropagation();
    root.querySelectorAll('.ecsx-anat-box').forEach(function(b){b.classList.remove('sel')});
    box.classList.add('sel');
    var d = INFO[box.dataset.k];
    info.innerHTML = '<h4>'+d[0]+'<span class="ecsx-badge">'+d[1]+'</span></h4><p>'+d[2]+'</p>';
  }, true);
})();
</script>

<p>If you know Kubernetes, the translation table is short enough to memorize over coffee:</p>

<table>
  <thead>
    <tr>
      <th>ECS</th>
      <th>Kubernetes</th>
      <th>What it is</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Cluster</td>
      <td>Cluster</td>
      <td>Logical boundary for compute + workloads</td>
    </tr>
    <tr>
      <td>Service</td>
      <td>Deployment + ReplicaSet + Service</td>
      <td>“Keep N running, behind this LB”</td>
    </tr>
    <tr>
      <td>Task</td>
      <td>Pod</td>
      <td>Co-scheduled containers, shared network + identity</td>
    </tr>
    <tr>
      <td>Task definition</td>
      <td>Pod spec</td>
      <td>Versioned blueprint for a task</td>
    </tr>
    <tr>
      <td>Capacity provider</td>
      <td>Node group / Karpenter</td>
      <td>Where compute comes from</td>
    </tr>
    <tr>
      <td>Fargate</td>
      <td>— (closest: virtual kubelet)</td>
      <td>Serverless compute, no nodes at all</td>
    </tr>
    <tr>
      <td>Task IAM role</td>
      <td>ServiceAccount + IRSA</td>
      <td>Per-workload cloud credentials</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">awsvpc</code> mode</td>
      <td>CNI</td>
      <td>Every task gets its own ENI/IP — not a choice, a default</td>
    </tr>
  </tbody>
</table>

<p>That last column is where the story actually lives. In Kubernetes, “where compute comes from” and “how pods get IPs” and “how workloads get cloud credentials” are all <em>decisions</em> with an ecosystem of competing answers. In ECS they’re defaults. You don’t pick a CNI. You don’t install an IRSA webhook. There’s one way, it’s boring, and it works.</p>

<h2 id="the-reconciliation-loop--same-idea-fewer-layers">The reconciliation loop — same idea, fewer layers</h2>

<p>The core idea both systems share: you declare desired state, a control loop enforces it. This is the part I find people understand instantly once they <em>watch</em> it instead of reading about it.</p>

<p>Below is an ECS service with <code class="language-plaintext highlighter-rouge">desired count: 4</code>. Click a task to kill it, then watch the scheduler notice and replace it. Then hit deploy and watch a rolling deployment do exactly what a Kubernetes Deployment rollout does: bring up new tasks, drain old ones, never drop below healthy.</p>

<div class="ecsx" id="ecsx-recon">
  <div class="ecsx-title">Service reconciliation — click a task to kill it</div>
  <div class="ecsx-svchead">
    <span>service: <b>web</b></span>
    <span>desired: <b>4</b></span>
    <span>running: <b id="ecsx-running">4</b></span>
    <span>revision: <b id="ecsx-rev">web:41</b></span>
  </div>
  <div class="ecsx-taskgrid" id="ecsx-tasks"></div>
  <div style="display:flex;gap:10px;margin-bottom:12px;flex-wrap:wrap">
    <button id="ecsx-kill">⚡ kill a task</button>
    <button id="ecsx-deploy">🚀 deploy web:42</button>
    <button id="ecsx-reset">↺ reset</button>
  </div>
  <div class="ecsx-log" id="ecsx-loglines"></div>
</div>

<script>
(function(){
  var grid = document.getElementById('ecsx-tasks');
  var logEl = document.getElementById('ecsx-loglines');
  var runEl = document.getElementById('ecsx-running');
  var revEl = document.getElementById('ecsx-rev');
  var DESIRED = 4, tasks = [], logs = [], t0 = Date.now(), deploying = false, timers = [];

  function now(){ return ((Date.now()-t0)/1000).toFixed(1)+'s'; }
  function log(msg, cls){
    logs.push('<div><span class="t">'+now()+'</span><span class="'+(cls||'')+'">'+msg+'</span></div>');
    logs = logs.slice(-7); logEl.innerHTML = logs.join('');
  }
  function id(){ return Math.random().toString(16).slice(2,8); }
  function later(fn, ms){ timers.push(setTimeout(fn, ms)); }

  function render(){
    grid.innerHTML = tasks.map(function(t){
      return '<div class="ecsx-task '+t.st+'" data-id="'+t.id+'">'+
        '<div class="id">'+t.id+'</div>'+
        '<div class="st">'+t.st+'</div>'+
        '<span class="ecsx-ver '+t.ver+'">web:'+(t.ver==='v1'?41:42)+'</span></div>';
    }).join('');
    runEl.textContent = tasks.filter(function(t){return t.st==='RUNNING'}).length;
  }

  function spawn(ver, cb){
    var t = { id:id(), ver:ver, st:'PROVISIONING' };
    tasks.push(t); render();
    log('scheduler: starting task <span class="hl">'+t.id+'</span> ('+(ver==='v1'?'web:41':'web:42')+')');
    later(function(){
      t.st = 'RUNNING'; render();
      log('task <span class="hl">'+t.id+'</span> RUNNING — registered with target group','ok');
      if(cb) cb(t);
    }, 1900 + Math.random()*700);
  }

  function reconcile(){
    if (deploying) return;
    var alive = tasks.filter(function(t){return t.st==='RUNNING'||t.st==='PROVISIONING'}).length;
    if (alive < DESIRED){
      log('service web: running ('+alive+') below desired ('+DESIRED+')','hl');
      spawn(tasks.some(function(t){return t.ver==='v2'}) ? 'v2' : 'v1');
    }
  }

  function kill(tid){
    var t = tasks.find(function(x){return x.id===tid && x.st==='RUNNING'});
    if(!t) return;
    t.st='STOPPED'; render();
    log('task <span class="bad">'+t.id+'</span> stopped (essential container exited)','bad');
    later(function(){ tasks = tasks.filter(function(x){return x!==t}); render(); reconcile(); }, 900);
  }

  grid.addEventListener('click', function(e){
    var el = e.target.closest('.ecsx-task'); if(el) kill(el.dataset.id);
  });
  document.getElementById('ecsx-kill').onclick = function(){
    var r = tasks.filter(function(t){return t.st==='RUNNING'});
    if(r.length) kill(r[Math.floor(Math.random()*r.length)].id);
  };

  document.getElementById('ecsx-deploy').onclick = function(){
    if (deploying || tasks.some(function(t){return t.ver==='v2'})) return;
    deploying = true;
    revEl.textContent = 'web:42';
    log('deployment started: web:41 → web:42 (rolling, min healthy 100%)','hl');
    (function step(){
      var olds = tasks.filter(function(t){return t.ver==='v1' && t.st==='RUNNING'});
      if (!olds.length){ deploying=false; log('deployment completed: 4/4 tasks on web:42','ok'); return; }
      spawn('v2', function(){
        var old = tasks.find(function(t){return t.ver==='v1' && t.st==='RUNNING'});
        if (old){
          old.st='DRAINING'; render();
          log('task <span class="hl">'+old.id+'</span> draining connections…');
          later(function(){
            tasks = tasks.filter(function(x){return x!==old}); render();
            log('task '+old.id+' deregistered + stopped');
            step();
          }, 1400);
        } else step();
      });
    })();
  };

  function reset(){
    timers.forEach(clearTimeout); timers=[]; tasks=[]; logs=[]; deploying=false; t0=Date.now();
    revEl.textContent='web:41';
    for (var i=0;i<DESIRED;i++) tasks.push({id:id(), ver:'v1', st:'RUNNING'});
    render(); log('service web: steady state — 4/4 running','ok');
  }
  document.getElementById('ecsx-reset').onclick = reset;
  setInterval(reconcile, 1200);
  reset();
})();
</script>

<p>That’s a Deployment rollout and a ReplicaSet self-heal, except nobody installed anything to get it. There’s no controller manager to version. You get all of this the moment you create a service.</p>

<p>When I help teams ship on ECS, this is where it clicks: you already understand ECS. If you can reason about desired state and reconciliation, the orchestration knowledge transfers completely. What doesn’t transfer is the operational surface area, and that’s the actual argument.</p>

<h2 id="what-you-stop-operating">What you stop operating</h2>

<p>This is the comparison that matters for a small team, and it’s the one nobody draws. The question isn’t which scheduler is smarter. They’re both fine. The question is whose pager each layer lands on.</p>

<p>Toggle ECS between Fargate and EC2 to see the middle ground:</p>

<div class="ecsx" id="ecsx-stack">
  <div class="ecsx-title">Who operates each layer
    <span class="ecsx-toggle"><button id="ecsx-fg" class="on">ECS · Fargate</button><button id="ecsx-ec2">ECS · EC2</button></span>
  </div>
  <div class="ecsx-cols">
    <div class="ecsx-col"><div class="ecsx-colhead" style="color:#8db8f8">EKS</div><div id="ecsx-col-eks"></div></div>
    <div class="ecsx-col"><div class="ecsx-colhead" style="color:#79d68a">ECS <span id="ecsx-mode">· Fargate</span></div><div id="ecsx-col-ecs"></div></div>
  </div>
  <div class="ecsx-score" id="ecsx-score"></div>
</div>

<script>
(function(){
  /* rows: [layer, EKS cell, ECS-Fargate cell, ECS-EC2 cell]; who: aws|you|na */
  var ROWS = [
    ['Control plane (API, scheduler, state store)',
      {who:'aws', txt:'AWS runs it — you pay $0.10/hr per cluster'},
      {who:'aws', txt:'AWS runs it — free'},
      {who:'aws', txt:'AWS runs it — free'}],
    ['Version upgrade treadmill',
      {who:'you', txt:'You initiate + test a cluster upgrade ~every 12–14 months, or pay 6× for extended support'},
      {who:'na',  txt:'Does not exist — there is no version'},
      {who:'na',  txt:'Does not exist — there is no version'}],
    ['Cluster add-ons (CNI, CoreDNS, kube-proxy)',
      {who:'you', txt:'You choose, install, and upgrade them — and they break during cluster upgrades'},
      {who:'na',  txt:'Built in (awsvpc networking). Not configurable, not breakable'},
      {who:'na',  txt:'Built in (awsvpc networking). Not configurable, not breakable'}],
    ['Ingress / load balancing',
      {who:'you', txt:'You install + upgrade the AWS Load Balancer Controller'},
      {who:'aws', txt:'Native ALB target-group integration'},
      {who:'aws', txt:'Native ALB target-group integration'}],
    ['Node OS, AMIs, patching',
      {who:'you', txt:'Yours — managed node groups help, but the reboot schedule is still your problem'},
      {who:'aws', txt:'No nodes. AWS patches the compute under you'},
      {who:'you', txt:'Yours — ASG AMI rotation, drain hooks, the works'}],
    ['Capacity planning + node autoscaling',
      {who:'you', txt:'Karpenter or Cluster Autoscaler — you configure and tune it'},
      {who:'aws', txt:'Per-task. You declare CPU/memory, AWS finds room'},
      {who:'you', txt:'Capacity providers + ASG sizing — bin-packing is back on you'}],
    ['Workload identity (cloud credentials)',
      {who:'you', txt:'RBAC + OIDC provider + IRSA annotations per service account'},
      {who:'aws', txt:'A plain IAM role on the task definition'},
      {who:'aws', txt:'A plain IAM role on the task definition'}],
  ];
  var eksCol = document.getElementById('ecsx-col-eks');
  var ecsCol = document.getElementById('ecsx-col-ecs');
  var score  = document.getElementById('ecsx-score');
  var modeEl = document.getElementById('ecsx-mode');
  var WHO = {aws:'AWS MANAGES', you:'YOU OPERATE', na:'— GONE —'};

  function cell(layer, c){
    return '<div class="ecsx-cell '+c.who+'"><span class="who">'+WHO[c.who]+'</span><b>'+layer+'</b><br>'+c.txt+'</div>';
  }
  function draw(fargate){
    var idx = fargate ? 2 : 3;
    eksCol.innerHTML = ROWS.map(function(r){ return cell(r[0], r[1]); }).join('');
    ecsCol.innerHTML = ROWS.map(function(r){ return cell(r[0], r[idx]); }).join('');
    var ye = ROWS.filter(function(r){return r[1].who==='you'}).length;
    var yc = ROWS.filter(function(r){return r[idx].who==='you'}).length;
    modeEl.textContent = fargate ? '· Fargate' : '· EC2';
    score.innerHTML = 'Layers on <b>your</b> pager — EKS: <b>'+ye+' of '+ROWS.length+'</b> · ECS '+
      (fargate?'on Fargate':'on EC2')+': <b>'+yc+' of '+ROWS.length+'</b>';
    document.getElementById('ecsx-fg').classList.toggle('on', fargate);
    document.getElementById('ecsx-ec2').classList.toggle('on', !fargate);
  }
  document.getElementById('ecsx-fg').onclick = function(){ draw(true); };
  document.getElementById('ecsx-ec2').onclick = function(){ draw(false); };
  draw(true);
})();
</script>

<p>Look at the EKS column. Six of the seven layers are yours. None of them are your product.</p>

<p>The upgrade treadmill deserves special attention because it’s the one that quietly eats small teams. Kubernetes ships about three releases a year, and EKS standard support for each lands around 14 months. That means a recurring, unskippable project roughly once a year, forever: test the control plane upgrade, upgrade the add-ons in the right order, chase whatever deprecated APIs your manifests use, then roll the nodes. Skip it and AWS moves you to extended support at six times the control plane price. For a platform team of 15, that’s Tuesday. For a team of four, it’s a sprint per year spent running to stand still. And there’s a quieter cost on top: you have to stay the kind of team that can do this safely.</p>

<p>ECS doesn’t have a version. I want to make sure that lands. There is no upgrade, no deprecation cycle, no “v1.29 removes the API your ALB controller depends on.” The control plane changed under you a hundred times last year and you never noticed. I have ECS services from 2021 that have never needed a maintenance commit. Infrastructure that doesn’t generate homework is worth more to a small team than anything on the Kubernetes feature list. It’s the same reason I tell teams to <a href="/2025/ecs-decisions-that-waste-6-weeks/">pick boring options everywhere else in the stack</a>: boring means you debug your app, not your platform.</p>

<p>On raw cost, the EKS control plane is about $73 a month per cluster and ECS’s is free, and that’s the least interesting line in the comparison. Run the numbers on engineering time instead. One sprint of one engineer’s time per year on cluster maintenance is $10-20k. The <a href="/2025/aws-cost-optimization-case-study/">biggest AWS savings I’ve ever found</a> came from deleting complexity, not from rightsizing it.</p>

<h2 id="what-you-give-up">What you give up</h2>

<p>If this were one-sided, EKS wouldn’t exist. Here’s what you actually lose.</p>

<p>The big one is the operator ecosystem. Kubernetes has operators for Postgres, Kafka, cert-manager, external-dns, ArgoCD, all debugged by thousands of teams over a decade. ECS has no CRDs and no operator pattern. The AWS answer is “use the managed service”: RDS instead of a Postgres operator, MSK instead of Strimzi. That works right up until you need something AWS doesn’t sell.</p>

<p>Tooling in general follows the same line. Vendors ship a Helm chart, not a task definition. Kustomize, the CNCF landscape, none of it targets ECS. And your deployment layer is AWS-native, so a future move off AWS means rewriting it. Your containers move unchanged, but the wiring around them doesn’t.</p>

<p>There’s also the hiring thing, and I won’t pretend it isn’t real. Engineers want Kubernetes on their CV. ECS knowledge is real orchestration knowledge and the concepts transfer completely, as the diagrams above show, but nobody’s career was ever advanced by the phrase “task definition.”</p>

<p>And ECS has a control ceiling. Custom schedulers, topology spread, network policy, the more exotic probe and init semantics: Kubernetes gives you knobs ECS simply doesn’t have. Most web products never touch them. If yours genuinely does, you’ll feel the ceiling and you’ll resent it.</p>

<h2 id="so-when-is-eks-the-right-call">So when is EKS the right call?</h2>

<p>EKS earns its keep when at least one of these is true:</p>

<ul>
  <li>Someone owns the platform. You have, or are hiring, people whose actual job is cluster operations, so the pager layers above land on a team that exists.</li>
  <li>You’re running stateful infrastructure on-cluster that AWS doesn’t offer as a managed service, and you need the operator ecosystem for it.</li>
  <li>Multi-cloud or on-prem is a real requirement: contractual, regulatory, or your customers deploy your software into their clusters.</li>
  <li>Your team is already fluent. K8s veterans ship faster on EKS than they would learning anything else. The tax is only a tax if you haven’t already paid it.</li>
</ul>

<p>If none of those describe you, and for most sub-ten-engineer teams shipping a web product none do, then Kubernetes isn’t buying you capability. It’s buying you a second job.</p>

<h2 id="the-takeaway">The takeaway</h2>

<p>ECS is not “Kubernetes for beginners.” It’s the same control loop idea with a deliberately smaller operational surface. Same desired state, same reconciliation, same rolling deploys, minus the version treadmill, the add-on stack, and the node fleet. You’ve seen the whole object model in this post. There is no part two where the hidden complexity lives.</p>

<p>Small teams don’t lose because they picked the wrong orchestrator. They lose because their best engineers spent the year operating infrastructure the product didn’t need. Pick the tool that generates the least homework, ship, and revisit when you have the head count to afford opinions.</p>

<p>If you’re starting an ECS build-out, the companion post on <a href="/2025/ecs-decisions-that-waste-6-weeks/">the 5 ECS decisions that waste 6 weeks</a> covers the concrete choices: Fargate vs EC2, service discovery, CI/CD, secrets, and monitoring.</p>

<hr />

<p><em>If this post saved you a meeting, it did its job. I write about AWS, DevOps, and building things from scratch. Subscribe via <a href="/feed.xml">RSS</a>, or find me on <a href="https://twitter.com/muhammad_o7">Twitter</a>.</em></p>

          ]]>
        </description>
        <pubDate>Tue, 09 Jun 2026 00:00:00 +0000</pubDate>
        <link>https://muhammadraza.me/2026/ecs-explained-visually/</link>
        <guid isPermaLink="true">https://muhammadraza.me/2026/ecs-explained-visually/</guid>
        
        <category>aws</category>
        
        <category>devops</category>
        
        <category>kubernetes</category>
        
        
        
        <dc:creator>{&quot;name&quot;=&gt;&quot;Muhammad Raza&quot;}</dc:creator>
        <dc:rights></dc:rights>
      </item>
    
      <item>
        <title>GGUF vs MLX: A Decision Guide, Not Another Benchmark</title>
        <description>
          <![CDATA[
            
            <p>Every few weeks someone downloads the GGUF build and the MLX build of the same model, runs both, screenshots the tokens-per-second counter, and posts it as proof that one format wins. The replies split down the middle. Half the thread says MLX is obviously faster, the other half says the test was rigged.</p>

<p>They are both right, which is the problem. The number on the screen is real and it is also not the number you actually wait for. And the format you should pick was never really about that number anyway.</p>

<p>I have gone through this decision enough times now, on my own machine and for clients standing up local inference, that I want to write down the part nobody puts in the comparison tables: GGUF versus MLX is a five-question decision, and only one of those questions is about speed.</p>

<h2 id="what-you-are-actually-choosing-between">What you are actually choosing between</h2>

<p>GGUF is the file format from the llama.cpp project. One file holds the quantized weights, the tokenizer, the chat template, and the metadata, and any runtime that can load it will run the model. That includes llama.cpp itself, Ollama, LM Studio, KoboldCPP, and a handful of others. It runs on basically everything: CPU, NVIDIA, AMD, Apple Metal, even a Raspberry Pi if you are patient. Portability is the whole point of the format.</p>

<p>MLX is not a file format. It is Apple’s array framework, the rough equivalent of PyTorch built specifically for Apple Silicon. An MLX model is a directory of safetensors files plus a config that the runtime reads directly. You convert and quantize a model in one command with <code class="language-plaintext highlighter-rouge">mlx_lm.convert</code>. The catch is in the name: MLX runs on Apple Silicon and nowhere else.</p>

<p>One thing worth clearing up before we go further, because it shows up in half the comparisons and it is out of date: people say GGUF does clever mixed-precision quantization while MLX is stuck on flat uniform 4-bit. The first half is true. The second half is not. Apple walked through per-layer mixed precision in their WWDC25 session on running large language models with MLX, including the trick of keeping the embedding and output layers at 6-bit while the rest of the model sits at 4-bit. MLX can do it. It is just that most of the MLX builds floating around Hugging Face do not bother, so in practice you often are comparing GGUF’s mixed precision against a uniform MLX quant. Worth knowing when you read someone else’s quality benchmark.</p>

<h2 id="the-number-on-the-screen-is-lying-to-you">The number on the screen is lying to you</h2>

<p>Quick detour, because it poisons most of the benchmarks you will find.</p>

<p>The tokens-per-second figure your runtime prints while text is streaming measures decode speed, the rate at which the model emits new tokens. It does not include prefill, the time the model spends reading your prompt before it says anything. For a chatty exchange with a short prompt that does not matter much. For an agent that stuffs tool output, a chunk of a file, and a system prompt into every turn, prefill is most of what you wait for, and the streaming counter never sees it.</p>

<p>There is a benchmark writeup that made the rounds on r/LocalLLaMA where the author’s UI proudly reported nearly twice the tokens per second on MLX as on GGUF, and then the actual wall-clock time had GGUF finishing first on most of the real tasks. Same machine, same model. The counter was not wrong. It was just answering a different question than the one that mattered.</p>

<p>Keep that in your head for the whole rest of this post. When I say one format is “faster” below, I mean wall-clock on a real workload, not the number that scrolls past while tokens stream.</p>

<h2 id="five-questions-that-actually-decide-it">Five questions that actually decide it</h2>

<h3 id="1-how-big-is-the-model-relative-to-your-ram">1. How big is the model relative to your RAM?</h3>

<p>This is the question that quietly settles a lot of arguments. Token generation is bounded by memory bandwidth, not compute. To emit one token the GPU has to read the entire model out of memory. On an M4 Pro with roughly 273 GB/s of bandwidth, a 4-bit 27B model weighing about 17 GB caps out near 16 tokens per second no matter what software you run. MLX cannot fetch bytes faster than the hardware allows, and neither can llama.cpp.</p>

<p>So for large models, the ones that fill most of your unified memory, the format barely matters for speed. They both hit the same wall. The interesting differences show up on smaller models, under roughly 8 to 14B, where the model fits comfortably and the bottleneck shifts from bandwidth to framework overhead. That is where MLX’s tighter, Apple-specific kernels pull ahead, often in the 15 to 40 percent range on single-user decode, and wider still on very small models that lean hardest on framework efficiency.</p>

<p>Small model, want it snappy: MLX has something real to offer. Big model that barely fits: pick on the other four questions, because speed is a wash.</p>

<h3 id="2-will-this-ever-need-to-run-somewhere-other-than-a-mac">2. Will this ever need to run somewhere other than a Mac?</h3>

<p>If there is any chance the same artifact has to run on a Linux box, a cloud GPU, or a teammate’s non-Apple machine, you want GGUF. The same file moves between all of them. MLX does not leave Apple Silicon, full stop. If you ship MLX as your only build and then need a CUDA fallback, you are re-quantizing under pressure.</p>

<p>This one overrides almost everything else. Portability is not a performance feature, but it is the feature you miss most when it is gone.</p>

<h3 id="3-what-does-your-workload-actually-look-like">3. What does your workload actually look like?</h3>

<p>Not “what model,” but the shape of the traffic. Specifically the ratio of input to output.</p>

<p>Workloads that feed the model a lot and ask for a little (classification, tool-calling agents with short replies, RAG with a big injected context) lean toward GGUF. llama.cpp has more battle-tested prompt caching and FlashAttention, and MLX’s prefix caching has historically been the less reliable of the two, especially on newer hybrid-attention models. When prefill dominates the wall clock, that maturity wins.</p>

<p>Workloads that take a short prompt and generate a lot (summaries, long-form chat, brainstorming) lean toward MLX. Once the model is past prefill and just streaming tokens, MLX’s decode advantage compounds, and the longer the reply the more it pays off.</p>

<p>There is a crossover point that depends on both context size and reply length. With a small prompt, MLX needs a couple hundred output tokens before its faster decode makes up for slower prefill. With a few thousand tokens of context, it needs several hundred more. If your agent’s replies are 150 tokens and its context keeps growing, you are living on the wrong side of that crossover, and GGUF is the better call.</p>

<h3 id="4-do-you-want-to-train-or-just-run">4. Do you want to train, or just run?</h3>

<p>GGUF is an inference format. You download it, you run it, that is the relationship. If you want to fine-tune, you convert back to safetensors, find a GPU, do the work, and convert forward again.</p>

<p>MLX is a full framework. You can fine-tune with LoRA or QLoRA directly on the Mac, merge adapters, and run speculative decoding with a small draft model, all natively. If part of your reason for going local is to actually adapt models and not just serve them, MLX is the only serious option on Apple Silicon, and this question alone can decide the whole thing.</p>

<h3 id="5-how-much-do-you-care-about-ecosystem-and-exact-fit">5. How much do you care about ecosystem and exact fit?</h3>

<p>Two practical edges for GGUF here. First, coverage: every open model gets GGUF builds within hours of release, including the obscure ones. MLX coverage is good for popular models and lags for everything else. Second, granularity. GGUF gives you a long ladder of quant levels, Q4_K_M, Q5_K_M, Q6_K, the I-quants, and so on, so when you have exactly 16 GB to work with you can usually find a quant that fits. MLX builds are mostly published at 4-bit and 8-bit, so you sometimes get a 4-bit that is a hair too small for the quality you want and an 8-bit that will not fit.</p>

<p>The edge on MLX’s side: it tends to get support for new Apple hardware features first, because Apple ships the metal abstraction in MLX before llama.cpp catches up.</p>

<h2 id="the-flowchart">The flowchart</h2>

<p>Put the five questions in order and most decisions fall out in about ten seconds.</p>

<ul>
  <li><strong>Need to run on anything other than Apple Silicon, now or later?</strong> → <strong>GGUF</strong>. Stop here, portability wins.</li>
  <li><strong>Staying on Apple Silicon. Do you want to fine-tune or train on-device?</strong> → <strong>MLX</strong>.</li>
  <li><strong>Inference only. Is your workload short-output and prefill-heavy</strong> (agents, RAG, classification)? → <strong>GGUF</strong>.</li>
  <li><strong>Long outputs, interactive, single user, latency you can feel?</strong> → <strong>MLX</strong>.</li>
  <li><strong>Need a precise quant to fit tight RAM, or running a just-released or obscure model?</strong> → <strong>GGUF</strong>.</li>
  <li><strong>Still undecided?</strong> → <strong>GGUF</strong>. It is the conservative default. Ship it, and A/B an MLX build later if throughput becomes the constraint.</li>
</ul>

<p>The short version: GGUF is what you pick when you are not sure, because it is the one that is hard to regret. MLX is what you pick when you own the hardware, run single-user, and have a specific reason, throughput on long outputs or on-device training, to want it.</p>

<h2 id="once-you-have-picked-pick-a-quant-level">Once you have picked, pick a quant level</h2>

<p>The format is half the decision. The bit width is the other half, and the defaults are good but not always right.</p>

<p>Start at <strong>Q4_K_M</strong> for GGUF or <strong>4-bit</strong> for MLX. Q4_K_M is the community default for a reason. It keeps most tensors at 4-bit, then bumps the quality-sensitive ones to 6-bit: the attention value weights and the feed-forward down-projection, on a portion of the layers. That holds quality better than a flat 4-bit quant at a small size cost. The reported quality loss against FP16 on MMLU is model-dependent but small: well under a point on a big model, creeping up toward a point or so on something under 8B, and a little more again for a uniform 4-bit MLX build. On a 30B-plus model that gap is noise. On something under 8B, especially on coding tasks where attention precision matters, it is visible, and you have two outs: stay on GGUF Q4_K_M, or move to MLX 6-bit, which closes the gap for roughly a 30 percent larger file.</p>

<p>If RAM is genuinely tight, GGUF’s <strong>I-quants</strong> with an importance matrix are the quality-per-byte champions at low bit widths. The cost is slower decode on CPU, so they make more sense when you are squeezing a model onto limited memory than when you are chasing speed.</p>

<p>One rule regardless of format: do not drop below roughly 3-bit without measuring quality on your own task. The aggregate benchmarks stop predicting what you will actually see down there.</p>

<h2 id="two-traps-that-will-flip-your-results">Two traps that will flip your results</h2>

<p><strong>The bf16 trap on M1 and M2.</strong> A lot of MLX builds ship as bf16, and on the M1 and M2 that data type does not get the accelerated path that fp16 does. During prefill those weights run un-accelerated and the penalty multiplies across every input token, which is part of why some “MLX is slow” reports come from older hardware. The fix is a one-minute reconvert with <code class="language-plaintext highlighter-rouge">--dtype float16</code>. If you are on an M1 or M2 and MLX feels sluggish, check this before you blame the format.</p>

<p><strong>Caching is the real variable.</strong> The biggest swings I have seen between runtimes were not about GGUF versus MLX at all, they were about whether prompt and KV caching actually worked for that model on that runtime. A runtime that reprocesses the full conversation every turn will lose to one that caches the prefix, regardless of format. Test caching with your real context lengths before you commit, and do not trust the streaming counter to tell you about it, because it never measures the part that caching fixes.</p>

<h2 id="so-which-one">So which one</h2>

<p>If you want the one-line version: GGUF is the conservative default, and you should reach for it whenever you are uncertain, need portability, or want a specific quant. Reach for MLX when you are locked to Apple Silicon, run single-user interactive workloads with long outputs, or want to fine-tune on the machine you already own.</p>

<p>And if you are choosing this for a team rather than a laptop, treat it as the architecture decision it is. The format you standardize on shapes your model coverage, your fallback options, and your serving setup for as long as the stack lives, and re-quantizing a fleet after the fact is the kind of avoidable week of work I keep getting hired to clean up. Decide it on the five questions, not on a screenshot.</p>

          ]]>
        </description>
        <pubDate>Wed, 03 Jun 2026 00:00:00 +0000</pubDate>
        <link>https://muhammadraza.me/2026/gguf-vs-mlx-decision-guide/</link>
        <guid isPermaLink="true">https://muhammadraza.me/2026/gguf-vs-mlx-decision-guide/</guid>
        
        <category>ai</category>
        
        <category>llm</category>
        
        <category>devops</category>
        
        <category>mac</category>
        
        
        
        <dc:creator>{&quot;name&quot;=&gt;&quot;Muhammad Raza&quot;}</dc:creator>
        <dc:rights></dc:rights>
      </item>
    
      <item>
        <title>Building CodeWiki: Compiling Codebases Into Living Wikis With LLMs</title>
        <description>
          <![CDATA[
            
            <p>Every coding agent session starts from zero. The agent doesn’t know how your code is organized, which files matter, how the pieces connect. It has to rediscover the architecture from scratch. Grep around, read some files, build a mental model, start working. That mental model disappears the moment the session ends.</p>

<p>I kept watching this happen. Ten minutes of exploration before any real work, every single time. If you work across multiple repos or come back to a project after a couple weeks, it’s worse. The agent is essentially reading the codebase for the first time, again.</p>

<p>I wanted to fix this.</p>

<h2 id="the-idea">The idea</h2>

<p>A few weeks ago Karpathy <a href="https://x.com/karpathy/status/2039805659525644595">tweeted</a> about using LLMs to build personal knowledge bases. The workflow: collect raw sources, have an LLM compile them into a structured wiki of markdown files, then query and build on that wiki over time. Every query makes the wiki richer. The knowledge adds up.</p>

<p>The part that stuck with me: he’s not using fancy RAG. The LLM maintains its own index files and summaries, and at his scale (~100 articles, ~400K words) it just works. The LLM reads its own compiled knowledge to answer questions.</p>

<p>Codebases are raw data too. Source files are unstructured information that happens to be executable. What if the LLM compiled a codebase into a wiki the same way, with module overviews, architecture docs, concept articles, and then used that wiki as its starting point for every session?</p>

<p>That’s <a href="https://github.com/mraza007/codewiki">CodeWiki</a>.</p>

<h2 id="how-it-works">How it works</h2>

<p>CodeWiki is a thin Rust CLI called <code class="language-plaintext highlighter-rouge">cw</code> paired with a Claude Code skill. The CLI handles git ops, directory scaffolding, and metadata. The agent does all the actual reading and writing. No API keys, no LLM calls from the CLI. Your agent is the intelligence.</p>

<p>When you run <code class="language-plaintext highlighter-rouge">cw init</code> in a repo, it creates a wiki directory at <code class="language-plaintext highlighter-rouge">~/.codewiki/&lt;project&gt;/</code> with this structure:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>~/.codewiki/my-project/
├── _index.md         # master index
├── _architecture.md  # system overview
├── _patterns.md      # recurring patterns
├── _meta.yaml        # last compiled commit
├── modules/          # one article per module
├── concepts/         # cross-cutting concerns
├── decisions/        # why things are the way they are
├── learnings/        # bugs fixed, patterns discovered
└── queries/          # past Q&amp;A, filed back
</code></pre></div></div>

<p>The first time you start a Claude Code session after init, the skill kicks in. The agent walks your codebase, reads the source files, and writes wiki articles. Module articles describe what each part of the code actually does. Not what it’s supposed to do, what it does. Key files, functions, data flow, connections to other modules.</p>

<p>Concept articles cut across modules. “How does error handling work across the system” or “how does data flow from request to response.” These are the questions that normally require reading eight files across four directories. The wiki answers them in one place.</p>

<h2 id="keeping-it-fresh">Keeping it fresh</h2>

<p>The wiki is only useful if it stays current. Every article has YAML frontmatter with a <code class="language-plaintext highlighter-rouge">source_files</code> field:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="na">title</span><span class="pi">:</span> <span class="s">Authentication Module</span>
<span class="na">type</span><span class="pi">:</span> <span class="s">module</span>
<span class="na">source_files</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">src/auth/middleware.py</span>
  <span class="pi">-</span> <span class="s">src/auth/tokens.py</span>
<span class="na">tags</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">auth</span><span class="pi">,</span> <span class="nv">middleware</span><span class="pi">,</span> <span class="nv">jwt</span><span class="pi">]</span>
<span class="nn">---</span>
</code></pre></div></div>

<p>The CLI tracks which commit the wiki was last compiled against. When you start a new session, <code class="language-plaintext highlighter-rouge">cw status</code> diffs against that commit and cross-references changed files against every article’s <code class="language-plaintext highlighter-rouge">source_files</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ cw status
Changed since last compile (4964c23):
  M src/auth/middleware.py
  M src/auth/tokens.py

Stale articles:
  ! modules/auth.md
</code></pre></div></div>

<p>The agent sees this and knows exactly what to re-read and update. No guessing, no full recompile.</p>

<p>At session end, the agent writes learnings and decisions back into the wiki. Fixed a bug? That becomes <code class="language-plaintext highlighter-rouge">learnings/auth-token-race-condition.md</code>. Made a design decision? That’s <code class="language-plaintext highlighter-rouge">decisions/switched-to-redis-sessions.md</code>. Then it updates <code class="language-plaintext highlighter-rouge">_meta.yaml</code> with the current commit hash.</p>

<p>Next session picks up where this one left off.</p>

<h2 id="the-cli">The CLI</h2>

<p>About 400 lines of Rust. Here are the commands:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cw init                <span class="c"># scaffold wiki for current repo</span>
cw status              <span class="c"># what changed since last compile</span>
cw path                <span class="c"># print wiki path</span>
cw projects            <span class="c"># list all wikis</span>
cw index               <span class="c"># rebuild _index.md from article frontmatter</span>
cw meta update         <span class="c"># record current commit as compiled</span>

cw setup claude-code   <span class="c"># install skill into Claude Code</span>
cw setup codex         <span class="c"># install instructions for Codex</span>
cw setup qmd           <span class="c"># register wiki as QMD search collection</span>
</code></pre></div></div>

<p>The CLI doesn’t make any LLM calls. It handles the things agents are bad at: tracking git state, knowing which files changed, maintaining timestamps. The agent handles what it’s good at: reading code and writing about it.</p>

<h2 id="search-with-qmd">Search with QMD</h2>

<p>For larger wikis, <a href="https://github.com/tobi/qmd">QMD</a> by Tobi Lutke adds proper search. It’s a local search engine for markdown with hybrid BM25 plus vector search plus a small reranker model. Running <code class="language-plaintext highlighter-rouge">cw setup qmd</code> registers your wiki as a searchable collection. The agent can then query the wiki through QMD’s MCP server during a session.</p>

<p>At the scale of most repos people actually work in, you probably don’t need it. A well organized wiki with an index file is enough for the LLM to navigate on its own. But when the wiki gets large, QMD keeps retrieval fast.</p>

<h2 id="viewing-with-obsidian">Viewing with Obsidian</h2>

<p>All wiki articles live at <code class="language-plaintext highlighter-rouge">~/.codewiki/</code>. Open that directory as an Obsidian vault and you get a browsable knowledge graph of all your projects. Articles use <code class="language-plaintext highlighter-rouge">[[backlinks]]</code> so modules connect to each other. The auth article links to <code class="language-plaintext highlighter-rouge">[[database]]</code> and <code class="language-plaintext highlighter-rouge">[[api]]</code>. You never have to write or edit these articles yourself. The agent maintains everything.</p>

<h2 id="why-not-rag">Why not RAG</h2>

<p>Traditional RAG chunks your code, embeds it, retrieves fragments when you ask a question. You get decontextualized snippets and hope the LLM can stitch them together.</p>

<p>CodeWiki is different. The LLM reads the code and writes structured articles about it. The auth article already connects the middleware to the token service to the database layer. That connection doesn’t exist in any single source file. It exists in the compiled understanding.</p>

<p>Karpathy found the same thing with his research wiki. You don’t need vector search over raw data when you have a well organized collection of articles. The LLM reads the index, finds the relevant articles, reads those. Simple and it works.</p>

<h2 id="getting-started">Getting started</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/mraza007/codewiki.git
<span class="nb">cd </span>codewiki
cargo <span class="nb">install</span> <span class="nt">--path</span> <span class="nb">.</span>

<span class="nb">cd </span>your-project
cw init
cw setup claude-code
</code></pre></div></div>

<p>Start a Claude Code session and the skill handles the rest. The project is MIT licensed and on <a href="https://github.com/mraza007/codewiki">GitHub</a>.</p>

          ]]>
        </description>
        <pubDate>Fri, 03 Apr 2026 00:00:00 +0000</pubDate>
        <link>https://muhammadraza.me/2026/building-codewiki-compiling-codebases-into-living-wikis/</link>
        <guid isPermaLink="true">https://muhammadraza.me/2026/building-codewiki-compiling-codebases-into-living-wikis/</guid>
        
        <category>ai</category>
        
        <category>rust</category>
        
        <category>tools</category>
        
        <category>devops</category>
        
        
        
        <dc:creator>{&quot;name&quot;=&gt;&quot;Muhammad Raza&quot;}</dc:creator>
        <dc:rights></dc:rights>
      </item>
    
      <item>
        <title>I Built an Orchestrator That Watches GitHub Issues and Sends Agents to Fix Them</title>
        <description>
          <![CDATA[
            
            <p>I have too many issues and not enough time. Same as everyone. The usual loop is: pick an issue, context switch into it, write the code, open a PR, pick the next one. Do that until the sprint ends or you lose the will.</p>

<p>Coding agents help with this. I can point Claude Code at an issue and let it work while I do something else. But that’s still one agent, one issue, one terminal. If I have 10 issues labeled “agent-ready,” I’m not babysitting 10 terminal tabs.</p>

<p>I wanted something that just watches for new issues and sends agents after them. Then OpenAI released their <a href="https://github.com/openai/symphony/blob/main/SPEC.md">Symphony spec</a>, an orchestrator pattern for their Codex agent. The architecture was solid: poll an issue tracker, dispatch agents into isolated workspaces, reconcile when issues close. But it was built around Codex and Linear, and I use Claude Code and GitHub Issues.</p>

<p>So I took the ideas I liked from Symphony and built my own. That’s <a href="https://github.com/mraza007/baton">Baton</a>.</p>

<h2 id="what-it-does">What it does</h2>

<p>Baton is a Python daemon. You start it in your repo, it polls GitHub Issues matching your configured labels, creates an isolated git worktree per issue, and runs Claude Code CLI as a subprocess. When the agent finishes and opens a PR, Baton releases the claim and grabs the next issue.</p>

<p>One config file. One command. Go do something else.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>WORKFLOW.md -&gt; Orchestrator -&gt; Worker (per issue)
                  |              |
                  |              +-- git worktree create
                  |              +-- hooks (before_run)
                  |              +-- claude -p "&lt;prompt&gt;"
                  |              +-- check issue state
                  |              +-- hooks (after_run)
                  |
                  +-- Poller (gh issue list)
                  +-- Dispatcher (concurrency control)
                  +-- Reconciler (stale run detection)
</code></pre></div></div>

<p>The name comes from relay races. You hand off the baton and the runner goes.</p>

<h2 id="the-config">The config</h2>

<p>Everything lives in <code class="language-plaintext highlighter-rouge">WORKFLOW.md</code>. YAML front matter for configuration, Jinja2 template below for the prompt. Baton reloads this file on every poll cycle, so you can change settings without restarting.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="na">tracker</span><span class="pi">:</span>
  <span class="na">kind</span><span class="pi">:</span> <span class="s">github</span>
  <span class="na">labels</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">agent"</span><span class="pi">]</span>
  <span class="na">exclude_labels</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">blocked"</span><span class="pi">]</span>

<span class="na">polling</span><span class="pi">:</span>
  <span class="na">interval_ms</span><span class="pi">:</span> <span class="m">30000</span>

<span class="na">agent</span><span class="pi">:</span>
  <span class="na">max_concurrent</span><span class="pi">:</span> <span class="m">3</span>
  <span class="na">max_turns</span><span class="pi">:</span> <span class="m">5</span>
  <span class="na">command</span><span class="pi">:</span> <span class="s">claude</span>
  <span class="na">permission_mode</span><span class="pi">:</span> <span class="s">bypassPermissions</span>

<span class="na">hooks</span><span class="pi">:</span>
  <span class="na">before_run</span><span class="pi">:</span> <span class="pi">|</span>
    <span class="s">git fetch origin main &amp;&amp; git rebase origin/main</span>
  <span class="na">timeout_ms</span><span class="pi">:</span> <span class="m">60000</span>
<span class="nn">---</span>

<span class="s">You are an autonomous software engineer working on issue</span> <span class="c1">#{{ issue.number " }}: {{ issue.title " }}.</span>

<span class="pi">{{</span> <span class="nv">issue.body "</span> <span class="pi">}}</span>

<span class="pi">{</span><span class="err">%</span> <span class="nv">if attempt %</span><span class="pi">}</span>
<span class="s">This is continuation attempt {{ attempt " }}. Review what was done and continue.</span>
<span class="pi">{</span><span class="err">%</span> <span class="nv">endif %</span><span class="pi">}</span>

<span class="c1">## Instructions</span>

<span class="s">1. Understand the issue requirements</span>
<span class="s">2. Write clean, well-tested code</span>
<span class="s">3. Run existing tests to make sure nothing breaks</span>
<span class="s">4. Commit your changes with a descriptive message</span>
<span class="s">5. Push the branch and create a pull request linking to</span> <span class="c1">#{{ issue.number " }}</span>
</code></pre></div></div>

<p>Labels filter which issues get picked up. <code class="language-plaintext highlighter-rouge">max_concurrent</code> controls parallel agents. <code class="language-plaintext highlighter-rouge">max_turns</code> is the retry limit per issue. Hooks run shell commands at different points. I use <code class="language-plaintext highlighter-rouge">before_run</code> to rebase on main so the agent starts from fresh code.</p>

<p>The prompt template gets <code class="language-plaintext highlighter-rouge">issue.number</code>, <code class="language-plaintext highlighter-rouge">issue.title</code>, <code class="language-plaintext highlighter-rouge">issue.body</code>, <code class="language-plaintext highlighter-rouge">issue.labels</code>, and <code class="language-plaintext highlighter-rouge">attempt</code> for retries. Standard Jinja2.</p>

<h2 id="why-worktrees">Why worktrees</h2>

<p>Each issue gets its own worktree under <code class="language-plaintext highlighter-rouge">.symphony/worktrees/</code>, with a branch name slugified from the issue title: <code class="language-plaintext highlighter-rouge">baton/fix-login-redirect-42</code>.</p>

<p>I thought about Docker containers and temp directories but worktrees won out. They share the git object database so creating one is almost instant, unlike a full clone. They’re real checkouts, so linters and test runners and build scripts all work without any path hacking. And they’re isolated. If one agent trashes its branch, the others don’t care.</p>

<h2 id="why-gh-cli-instead-of-the-github-api">Why <code class="language-plaintext highlighter-rouge">gh</code> CLI instead of the GitHub API</h2>

<p>Baton shells out to <code class="language-plaintext highlighter-rouge">gh issue list</code> and <code class="language-plaintext highlighter-rouge">gh pr create</code> instead of using PyGitHub or the REST API. Seems odd, but think about setup.</p>

<p>With the API, you need a personal access token. You need to configure it somewhere. You need to handle rate limits.</p>

<p>With <code class="language-plaintext highlighter-rouge">gh</code>, you authenticate once (<code class="language-plaintext highlighter-rouge">gh auth login</code>) and everything on your machine uses the same credentials. No token management in the orchestrator. The tradeoff is speed, but Baton polls every 30 seconds. The overhead of a subprocess call doesn’t matter at that pace.</p>

<h2 id="the-permission-problem">The permission problem</h2>

<p>This tripped me up. Claude Code has permission modes: <code class="language-plaintext highlighter-rouge">default</code> asks for everything, <code class="language-plaintext highlighter-rouge">acceptEdits</code> auto-approves file edits but prompts for shell commands, and <code class="language-plaintext highlighter-rouge">bypassPermissions</code> auto-approves everything.</p>

<p>I started with <code class="language-plaintext highlighter-rouge">acceptEdits</code> because it felt like the right balance. Let the agent write code freely, but make it ask before running commands. Problem: “ask” means a human clicking yes, and in an autonomous orchestrator there’s no human. The agent just blocks forever waiting for a prompt nobody will answer.</p>

<p>I wasted about 20 minutes watching it hang before I figured this out. For autonomous operation you need <code class="language-plaintext highlighter-rouge">bypassPermissions</code>, which maps to <code class="language-plaintext highlighter-rouge">--dangerously-skip-permissions</code>. The flag name is honest about the risk. I’m comfortable with it because the agents run in isolated worktrees on disposable branches, not in my main checkout.</p>

<h2 id="auto-releasing-on-pr-creation">Auto-releasing on PR creation</h2>

<p>My first version had a dumb problem. The agent would finish its work, create a PR on turn 2 of 5, and Baton would keep scheduling continuation turns for the remaining 3. The slot was occupied but nobody was doing anything useful.</p>

<p>The fix: after each worker finishes, check if a PR exists for that issue’s branch. If yes, release the claim immediately and free up the slot. If not, schedule a short retry.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pr_exists</span> <span class="o">=</span> <span class="k">await</span> <span class="bp">self</span><span class="p">.</span><span class="n">tracker</span><span class="p">.</span><span class="n">check_pr_exists</span><span class="p">(</span><span class="n">issue</span><span class="p">.</span><span class="n">number</span><span class="p">)</span>
<span class="k">if</span> <span class="n">pr_exists</span><span class="p">:</span>
    <span class="n">log</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s">"PR_READY #</span><span class="si">{</span><span class="n">issue</span><span class="p">.</span><span class="n">number</span><span class="si">}</span><span class="s"> -- PR found, releasing claim"</span><span class="p">)</span>
    <span class="k">return</span> <span class="s">"pr_created"</span>
<span class="k">return</span> <span class="s">"no_pr"</span>
</code></pre></div></div>

<p>Small change, but it meant the orchestrator stopped wasting time on finished work.</p>

<h2 id="extensibility-through-skills-and-mcp-servers">Extensibility through skills and MCP servers</h2>

<p>Baton itself is deliberately simple. It polls, dispatches, and manages worktrees. The interesting part is what you put in the prompt and what tools you give the agent.</p>

<p>Claude Code supports MCP servers, which means you can wire up external tools and the agent can use them during its run. Baton passes MCP server config through to each worker:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">agent</span><span class="pi">:</span>
  <span class="na">mcp_servers</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">playwright</span>
      <span class="na">command</span><span class="pi">:</span> <span class="s">npx @playwright/mcp@latest</span>
</code></pre></div></div>

<p>That means the agent has access to a headless browser while it works. It can open a page, click around, take screenshots, verify that the UI renders correctly. You don’t have to build that into Baton. You just declare which MCP servers you want and the agent figures out when to use them.</p>

<p>Same idea with CLI tools. If <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a> is installed on the machine, you can tell the agent to use it in the prompt template. “Before creating a PR, open the app with agent-browser and verify the acceptance criteria.” The agent spins up a local server, opens the page, clicks buttons, fills inputs, takes snapshots. All from instructions in WORKFLOW.md, nothing hardcoded in the orchestrator.</p>

<p>Claude Code also has skills, which are reusable prompt fragments that teach the agent specific capabilities. If you have a code review skill or a testing skill installed, the agent can use them during its run. Baton’s config supports a <code class="language-plaintext highlighter-rouge">skills</code> list for this:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">agent</span><span class="pi">:</span>
  <span class="na">skills</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">code-reviewer</span>
    <span class="pi">-</span> <span class="s">accessibility-checker</span>
</code></pre></div></div>

<p>You can also override skills per issue by adding a <code class="language-plaintext highlighter-rouge">## Skills</code> section to the issue body. If one issue needs Playwright but the others don’t, just add it to that issue.</p>

<p>The point is that Baton doesn’t need to know about browsers or test runners or linters. It just needs to dispatch agents with the right config. The prompt and the tools do the rest.</p>

<h2 id="putting-it-together-a-todo-app-from-scratch">Putting it together: a todo app from scratch</h2>

<p>To see all of this working end to end, I had Baton build a todo app. Fresh repo, no code. I created three GitHub issues labeled <code class="language-plaintext highlighter-rouge">baton</code>:</p>

<ol>
  <li>Create basic HTML structure</li>
  <li>Add JavaScript for create/delete</li>
  <li>Add localStorage persistence</li>
</ol>

<p>The WORKFLOW.md prompt told the agent to use agent-browser for verification before opening PRs. I ran <code class="language-plaintext highlighter-rouge">baton start</code> and went to make coffee.</p>

<p>Baton picked up issue #1, created a worktree on <code class="language-plaintext highlighter-rouge">baton/create-basic-todo-app-html-structure-1</code>, and dispatched Claude Code. The agent wrote <code class="language-plaintext highlighter-rouge">index.html</code>, spun up a local server with <code class="language-plaintext highlighter-rouge">npx serve</code>, opened it with agent-browser, confirmed the layout rendered, then committed, pushed, and opened a PR. The PR description included what agent-browser found:</p>

<blockquote>
  <p>Opened <code class="language-plaintext highlighter-rouge">http://localhost:3456</code> and confirmed the page renders correctly.
Ran <code class="language-plaintext highlighter-rouge">agent-browser snapshot -i</code> confirming interactive elements: textbox and button.</p>
</blockquote>

<p>I merged it. The issue auto-closed (the PR had <code class="language-plaintext highlighter-rouge">Closes #1</code>). Baton saw the issue was gone on the next poll, released the slot, and picked up issue #2. Same cycle. Then #3.</p>

<p>Three issues, three PRs, three merges. I didn’t write a line of the todo app. The agent-browser verification wasn’t built into Baton. It was just instructions in the prompt and a CLI tool on my machine.</p>

<h2 id="getting-started">Getting started</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install</span> <span class="nt">-e</span> <span class="nb">.</span>
<span class="nb">cp </span>WORKFLOW.md.example WORKFLOW.md
<span class="c"># Edit WORKFLOW.md: set your labels, tweak the prompt</span>
baton start
</code></pre></div></div>

<p>You need Python 3.11+, Claude Code CLI (<code class="language-plaintext highlighter-rouge">claude</code>), GitHub CLI (<code class="language-plaintext highlighter-rouge">gh</code>) authenticated, and Git.</p>

<p>The code is at <a href="https://github.com/mraza007/baton">github.com/mraza007/baton</a>. MIT licensed. About 10 Python modules, no external services, no databases. State lives in memory with JSON persistence for the status command.</p>

<h2 id="what-i-want-to-add-next">What I want to add next</h2>

<ul>
  <li>A proper TUI instead of <code class="language-plaintext highlighter-rouge">baton status</code> reading a JSON file</li>
  <li>Issue dependency ordering so issue 3 waits for issue 2 if it needs to</li>
  <li>Cost tracking per issue, so I can see what automating the backlog actually costs in tokens</li>
  <li>More trackers besides GitHub Issues (Linear, Jira, GitLab)</li>
</ul>

<p>If you’ve got a repo with a pile of issues sitting there, try pointing Baton at it. Start with one label and <code class="language-plaintext highlighter-rouge">max_concurrent: 1</code>. See what it does. The setup takes about five minutes and the worst case is you get a bad PR that you close. The code is MIT licensed, the whole thing is ten files, and there’s nothing weird in it. Fork it, break it, rip out the parts you don’t like.</p>

<p>If you try it, I want to hear what breaks.</p>

<hr />

<p>I write a newsletter called <a href="https://devconsole.substack.com/">Dev Console</a> where I cover what’s actually happening in AI, minus the hype. New tools, real use cases, stuff I’m building. If this post was interesting, you’ll probably like it.</p>

          ]]>
        </description>
        <pubDate>Fri, 27 Mar 2026 00:00:00 +0000</pubDate>
        <link>https://muhammadraza.me/2026/building-baton-autonomous-agent-orchestrator/</link>
        <guid isPermaLink="true">https://muhammadraza.me/2026/building-baton-autonomous-agent-orchestrator/</guid>
        
        <category>ai</category>
        
        <category>python</category>
        
        <category>tools</category>
        
        <category>automation</category>
        
        
        
        <dc:creator>{&quot;name&quot;=&gt;&quot;Muhammad Raza&quot;}</dc:creator>
        <dc:rights></dc:rights>
      </item>
    
      <item>
        <title>Harness Engineering: The DevOps Skill Nobody Told You About</title>
        <description>
          <![CDATA[
            
            <p>I’ve written before about how <a href="/2026/ai-agents-devops-perspective/">AI agents are just CI pipelines with an LLM plugged in</a>. That post mapped agent concepts to infrastructure patterns you already know. But there’s a discipline forming around the infrastructure side of agents that deserves its own name.</p>

<p>Harness engineering. It’s the practice of building everything around the LLM — the execution environment, tool definitions, safety boundaries, observability, and lifecycle management. The stuff that turns a chatbot into a production system.</p>

<p>If you work in DevOps, you’ve been doing this for years. You just called it something else.</p>

<h2 id="why-harnesses-matter-more-than-models">Why Harnesses Matter More Than Models</h2>

<p>Pick any AI agent demo. Strip out the model. What’s left?</p>

<p>A container or sandbox. A set of callable tools. A loop that reads output and decides what happens next. Logging. Timeouts. Cleanup.</p>

<p>That’s the harness. And it’s where agents succeed or fail. A great model in a bad harness hallucinates, loops forever, leaks secrets, or silently does nothing useful. A decent model in a good harness stays bounded, recovers from errors, and produces auditable results.</p>

<p>DevOps engineers already think this way. You don’t just pick a good application — you build the infrastructure that makes it reliable. Same thing here.</p>

<h2 id="the-five-parts-of-a-harness">The Five Parts of a Harness</h2>

<p>Here’s how I break down harness engineering into components. Each one maps directly to something you’ve built before.</p>

<p><strong>1. Execution environment.</strong> Where does the agent run? A container, a VM, a temporary directory, a git worktree. You need isolation so the agent can’t corrupt shared state. You need reproducibility so runs are consistent. This is the same problem as CI job runners. Docker, Firecracker, nsjail — pick your isolation boundary.</p>

<p><strong>2. Tool definitions.</strong> Tools are the agent’s API surface. Read a file. Run a command. Query a database. Call an endpoint. Each tool needs input validation, output formatting, error handling, and permission scoping. Think of it like designing an API — you wouldn’t expose raw database access through a REST endpoint. Don’t give an agent raw shell access either. The tool layer is your contract.</p>

<p><strong>3. Control loop.</strong> Observe, decide, execute, verify. The loop is what makes an agent an agent instead of a one-shot prompt. Your job as a harness engineer is to decide: how many iterations? What’s the timeout per step? What happens when a tool call fails? When does the loop escalate to a human? This is the same logic you put in health check loops and deployment rollback controllers.</p>

<p><strong>4. Guardrails.</strong> Cost caps. Token limits. Command allowlists. File path restrictions. Rate limiting on external calls. Without guardrails, an agent can burn through your API budget in minutes or write to paths it shouldn’t touch. Every guardrail is a policy decision — same as IAM policies, network rules, and resource quotas you already manage.</p>

<p><strong>5. Observability.</strong> If you can’t see what the agent did, you can’t debug it, audit it, or trust it. Log every tool call, every LLM response, every decision point. Capture diffs, timing, token usage, and cost. This is no different from structured logging in any production system. The difference is that agent traces are longer and less predictable than HTTP request traces, so you need good tooling to navigate them.</p>

<h2 id="where-devops-context-overlaps">Where DevOps Context Overlaps</h2>

<p>Here’s where your existing skills plug in directly.</p>

<p><strong>Infrastructure as code.</strong> Agent harnesses should be declarative and version-controlled. The tool definitions, policies, and environment specs should live in config files, not hardcoded in application logic. When you change a tool’s behavior, that change should be reviewable in a PR.</p>

<p><strong>Pipeline orchestration.</strong> Multi-agent systems look a lot like multi-stage pipelines. One agent does research, passes context to a planning agent, which passes a plan to an implementation agent. You’re managing handoffs, shared artifacts, and failure propagation — the same coordination problem as CI/CD stages.</p>

<p><strong>Incident response.</strong> When an agent goes wrong, you need the same muscle memory. Check the logs. Find the failing step. Understand the input that caused it. Roll back if needed. The debugging workflow is identical.</p>

<p><strong>Security boundaries.</strong> Least privilege applies to agents just like it applies to services. What tools can this agent access? What files can it read? Can it make network calls? Can it spend money? Every agent needs a security boundary, and DevOps engineers already think in terms of boundaries.</p>

<h2 id="getting-started">Getting Started</h2>

<p>If you want to start building harnesses, you don’t need a new framework. Start with what you have.</p>

<p>Take a simple task — say, analyzing a failed CI build. Write a script that collects the logs, sends them to an LLM with a prompt, parses the response, and posts a summary to Slack. That’s a harness. A minimal one, but it has all the components: environment setup, tool use (log collection, Slack posting), a control flow, and output handling.</p>

<p>Then add complexity. Let the LLM decide which logs to fetch. Add a retry loop. Add a cost cap. Add structured logging. Each addition is a harness engineering decision.</p>

<p>You don’t need to learn ML. You don’t need to fine-tune models. You need to build the infrastructure that makes models useful — and that’s the job you already do.</p>

<p>Harness engineering isn’t a new discipline. It’s DevOps applied to a new kind of workload. The sooner you see it that way, the faster you’ll build agents that actually work in production.</p>

          ]]>
        </description>
        <pubDate>Sat, 14 Mar 2026 00:00:00 +0000</pubDate>
        <link>https://muhammadraza.me/2026/harness-engineering-devops-perspective/</link>
        <guid isPermaLink="true">https://muhammadraza.me/2026/harness-engineering-devops-perspective/</guid>
        
        <category>ai</category>
        
        <category>devops</category>
        
        <category>automation</category>
        
        
        
        <dc:creator>{&quot;name&quot;=&gt;&quot;Muhammad Raza&quot;}</dc:creator>
        <dc:rights></dc:rights>
      </item>
    
      <item>
        <title>I Built Local Memory for Coding Agents Because They Keep Forgetting Everything</title>
        <description>
          <![CDATA[
            
            <p>Here’s something that frustrates me about coding agents. They forget everything. Every single session starts from scratch. The agent that spent 45 minutes yesterday figuring out your authentication flow? Gone. The decision to use JWT over sessions? Gone. The bug it found in your ORM’s lazy loading? Gone.</p>

<p>You start a new session and it re-discovers the same patterns. Repeats the same mistakes. Asks the same questions. It’s like working with a brilliant colleague who gets amnesia every night.</p>

<p>I got tired of this. So I built <a href="https://github.com/mraza007/echovault">EchoVault</a> — a local memory system that gives coding agents persistent memory across sessions. No cloud. No API keys. No cost. Just a SQLite database and some Markdown files on your machine.</p>

<h2 id="the-problem-is-real">The Problem Is Real</h2>

<p>I use coding agents daily across multiple client projects. Claude Code, Cursor, Codex — I switch between them depending on the task. Every time I start a session, I’m repeating context that the agent should already know.</p>

<p>“We chose FastAPI over Flask because of async support.”
“The deploy script needs –no-cache or the CSS breaks.”
“Don’t touch the legacy auth module — it’s being replaced next sprint.”</p>

<p>I was copy-pasting this stuff into every session. That’s not how tools should work.</p>

<p>I tried existing solutions. Supermemory announced their MCP and I was tempted, but it saves everything in the cloud. I work with multiple companies as a consultant — I don’t want codebase decisions stored on someone else’s servers. Claude Mem was the first tool I tried, but it was eating too much memory in my sessions and became a bottleneck when running multiple agents at the same time.</p>

<p>So I built my own.</p>

<h2 id="how-echovault-works">How EchoVault Works</h2>

<p>EchoVault runs as an MCP server. When your agent starts a session, it has three tools available:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">memory_context</code> — load prior decisions, bugs, and context for the current project</li>
  <li><code class="language-plaintext highlighter-rouge">memory_search</code> — find specific memories by keyword or semantic similarity</li>
  <li><code class="language-plaintext highlighter-rouge">memory_save</code> — persist a decision, bug fix, pattern, or learning</li>
</ul>

<p>The agent calls these tools like it calls any other tool. No hooks. No shell scripts. No prompt injection. The MCP protocol handles everything.</p>

<p>Here’s what happens in practice:</p>

<p><strong>Session start.</strong> The agent sees <code class="language-plaintext highlighter-rouge">memory_context</code> in its available tools. The tool description says “You MUST call this at session start.” The agent calls it and gets back a list of prior memories for the project. Now it knows what happened yesterday.</p>

<p><strong>During work.</strong> You ask about authentication. The agent calls <code class="language-plaintext highlighter-rouge">memory_search</code> with “authentication” and gets back the decision to use JWT, the bug with token refresh, and the migration plan. It has context before writing a single line of code.</p>

<p><strong>Session end.</strong> The agent just fixed a tricky race condition. The tool description says “You MUST call memory_save before ending any session where you made changes.” It saves the root cause, the fix, and what to watch for.</p>

<p>Next session, that knowledge is there. Every session builds on the last one.</p>

<h2 id="the-architecture">The Architecture</h2>

<p>I kept it simple. The whole system is four things:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>~/.memory/
├── vault/                    # Obsidian-compatible Markdown
│   └── my-project/
│       └── 2026-02-01-session.md
├── index.db                  # SQLite: FTS5 + sqlite-vec
└── config.yaml               # Optional embedding config
</code></pre></div></div>

<p><strong>Markdown vault.</strong> Every memory gets written to a session file — one file per day per project. These are valid Markdown with YAML frontmatter. You can point Obsidian at <code class="language-plaintext highlighter-rouge">~/.memory/vault/</code> and browse your agent’s memory visually. You can read them in any editor. They’re not locked in a proprietary format.</p>

<p><strong>SQLite index.</strong> This is where search happens. FTS5 handles keyword search out of the box — no configuration needed. If you want semantic search (where “authentication” matches a memory titled “JWT token setup”), add an embedding provider. I use Ollama with <code class="language-plaintext highlighter-rouge">nomic-embed-text</code> locally. You can also use OpenAI or OpenRouter if you prefer cloud.</p>

<p><strong>MCP server.</strong> The agent talks to EchoVault through the Model Context Protocol. Three tools, stdio transport, nothing fancy. The server starts when the agent needs it and stops when the session ends. Zero idle cost.</p>

<p><strong>Secret redaction.</strong> Three layers. Explicit <code class="language-plaintext highlighter-rouge">&lt;redacted&gt;</code> tags for things you mark yourself. Pattern detection that catches API keys, passwords, and credentials automatically. And <code class="language-plaintext highlighter-rouge">.memoryignore</code> rules for custom patterns. Nothing sensitive hits disk.</p>

<h2 id="making-agents-actually-save">Making Agents Actually Save</h2>

<p>Here’s the thing about MCP tools — the agent <em>can</em> call them, but will it? Retrieval works well because agents tend to grab context at the start. Saving is the hard part. The agent finishes its work and moves on. It doesn’t naturally think “I should save what I learned.”</p>

<p>The trick is the tool descriptions. When you register an MCP tool, you include a description. Agents read these descriptions and treat them as instructions. So instead of:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>"Save a memory for future sessions. Call this when you make decisions."
</code></pre></div></div>

<p>I wrote:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>"Save a memory for future sessions. You MUST call this before ending
any session where you made changes, fixed bugs, made decisions, or
learned something. This is not optional — failing to save means the
next session starts from zero."
</code></pre></div></div>

<p>That “MUST” language makes a real difference. It’s not 100% reliable — nothing with LLMs is — but agents follow strong tool descriptions much more consistently than passive ones.</p>

<h2 id="cross-agent-memory">Cross-Agent Memory</h2>

<p>One of the things I wanted was a single vault for all my agents. A memory saved by Claude Code should be searchable from Cursor or Codex. They’re all working on the same codebase. Why should they have separate memories?</p>

<p>EchoVault stores everything in one place. The MCP server is the same regardless of which agent connects to it. Setup is one command per agent:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>memory setup claude-code   <span class="c"># writes ~/.claude.json</span>
memory setup cursor        <span class="c"># writes .cursor/mcp.json</span>
memory setup codex         <span class="c"># writes .codex/config.toml + AGENTS.md</span>
memory setup opencode      <span class="c"># writes opencode.json</span>
</code></pre></div></div>

<p>Each agent has its own config format and conventions. Claude Code uses JSON with <code class="language-plaintext highlighter-rouge">mcpServers</code>. Cursor uses the same schema but different file paths. Codex uses TOML with <code class="language-plaintext highlighter-rouge">[mcp_servers]</code>. OpenCode uses JSON with a <code class="language-plaintext highlighter-rouge">mcp</code> key and a different command format (<code class="language-plaintext highlighter-rouge">command</code> as an array instead of separate <code class="language-plaintext highlighter-rouge">command</code> + <code class="language-plaintext highlighter-rouge">args</code>).</p>

<p>I wrote shared helpers so each agent’s setup is just a thin wrapper around <code class="language-plaintext highlighter-rouge">_install_mcp_servers()</code> or <code class="language-plaintext highlighter-rouge">_install_toml_mcp()</code>. Adding a new agent takes maybe 20 lines of code.</p>

<h2 id="what-gets-saved">What Gets Saved</h2>

<p>Not everything should be a memory. Trivial changes don’t need to be persisted. Information that’s obvious from reading the code doesn’t need a memory. The goal is to capture what a future agent wouldn’t know from just looking at the codebase.</p>

<p>Good memories:</p>

<ul>
  <li><strong>Decisions.</strong> “Chose JWT over sessions because the API needs to be stateless.” A future agent reading the code sees JWT but doesn’t know <em>why</em>.</li>
  <li><strong>Bugs.</strong> “The ORM lazy-loads relationships by default, causing N+1 queries in the user list endpoint. Fixed by adding <code class="language-plaintext highlighter-rouge">.options(joinedload(...))</code>. Root cause: SQLAlchemy default behavior.” A future agent won’t hit the same bug.</li>
  <li><strong>Patterns.</strong> “All API endpoints follow the pattern: validate input, check permissions, execute, return response. Don’t add business logic in the route handler.” A future agent follows the existing patterns instead of inventing new ones.</li>
  <li><strong>Context.</strong> “The legacy auth module is being replaced. Don’t modify it — changes go into the new auth service at <code class="language-plaintext highlighter-rouge">src/auth/v2/</code>.” A future agent doesn’t waste time on dead code.</li>
</ul>

<p>Each memory has a title, a “what happened” summary, optional “why” and “impact” fields, tags, and a category. Search returns compact ~50-token summaries. Full details are fetched on demand so context windows don’t get bloated.</p>

<h2 id="the-technical-bits">The Technical Bits</h2>

<p>A few implementation details that might be useful if you’re building something similar.</p>

<p><strong>FTS5 for keyword search.</strong> SQLite’s FTS5 extension is fast and works with zero configuration. No external service needed. It handles stemming, phrase matching, and ranking. For most use cases, this is all you need.</p>

<p><strong>sqlite-vec for semantic search.</strong> When you want “authentication” to match “JWT token rotation”, you need vectors. I use <code class="language-plaintext highlighter-rouge">sqlite-vec</code> to store embeddings right in the same SQLite database. No vector database needed. Embedding providers are pluggable — Ollama for local, OpenAI or OpenRouter for cloud.</p>

<p><strong>Hybrid search.</strong> The search pipeline runs FTS5 first (fast, precise), then semantic search (slower, fuzzy), and merges the results. This gives you the best of both worlds — exact keyword matches and semantic similarity.</p>

<p><strong>TOML parsing with fallbacks.</strong> Codex writes some non-standard TOML — unquoted filesystem paths as table keys, dotted version strings as key names. Standard <code class="language-plaintext highlighter-rouge">tomllib</code> chokes on these. I added a fallback that appends the MCP section directly via string operations when parsing fails. It’s not pretty but it handles real-world config files.</p>

<p><strong>Symlink handling.</strong> Some agents create symlinks in their skill directories. <code class="language-plaintext highlighter-rouge">shutil.rmtree()</code> crashes on symlinks. Small thing but it bit me in production.</p>

<h2 id="setting-it-up">Setting It Up</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install </span>git+https://github.com/mraza007/echovault.git
memory init
memory setup claude-code
</code></pre></div></div>

<p>That’s it. Three commands. The agent has memory now.</p>

<p>If you want semantic search, configure an embedding provider:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>memory config init
<span class="c"># Edit ~/.memory/config.yaml to set your provider</span>
memory reindex
</code></pre></div></div>

<p>For fully local operation with no external API calls, use Ollama:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">embedding</span><span class="pi">:</span>
  <span class="na">provider</span><span class="pi">:</span> <span class="s">ollama</span>
  <span class="na">model</span><span class="pi">:</span> <span class="s">nomic-embed-text</span>
</code></pre></div></div>

<h2 id="what-ive-learned">What I’ve Learned</h2>

<p>Building this taught me a few things about agent tooling.</p>

<p><strong>Tool descriptions are instructions.</strong> Agents read them and follow them. Strong, directive language in tool descriptions is more effective than passive documentation. “You MUST” works better than “You can.”</p>

<p><strong>Local-first matters.</strong> Not because of ideology, but because of practical constraints. Consultants work with multiple clients. Sensitive decisions shouldn’t leave the machine. And when your internet goes out, local tools still work.</p>

<p><strong>MCP is the right abstraction.</strong> Instead of writing agent-specific hooks, skills, and config formats, I write one MCP server and each agent connects to it. When a new agent comes along, I add a setup function for its config format. The memory logic doesn’t change.</p>

<p><strong>Simple storage wins.</strong> Markdown files you can read in any editor. SQLite you can query with any tool. No custom binary formats. No daemon to keep running. The system is completely inspectable and debuggable.</p>

<p>The code is at <a href="https://github.com/mraza007/echovault">github.com/mraza007/echovault</a>. It’s MIT licensed. If you’re tired of your agents forgetting everything, give it a shot.</p>

          ]]>
        </description>
        <pubDate>Tue, 17 Feb 2026 00:00:00 +0000</pubDate>
        <link>https://muhammadraza.me/2026/building-local-memory-for-coding-agents/</link>
        <guid isPermaLink="true">https://muhammadraza.me/2026/building-local-memory-for-coding-agents/</guid>
        
        <category>ai</category>
        
        <category>python</category>
        
        <category>tools</category>
        
        
        
        <dc:creator>{&quot;name&quot;=&gt;&quot;Muhammad Raza&quot;}</dc:creator>
        <dc:rights></dc:rights>
      </item>
    
      <item>
        <title>AI Agents Are Just CI Pipelines With an LLM Plugged In</title>
        <description>
          <![CDATA[
            
            <p>In this post, I’ll show you how to think about AI agents through the infrastructure patterns you already use. Think about your CI runner. It spins up an environment. Runs some steps. Reads files. Runs tests. Captures output. Decides what to do next. Knows when to stop.</p>

<p>Now swap out the hardcoded logic for an LLM. That’s it. That’s an AI agent in simpler terms. The fancy demos want you to think it’s magic. Some brand new thing you need to learn from scratch. It’s not. When you take away the hype, an agent is just a controlled automation loop. The LLM handles the reasoning and everything else is infrastructure you’ve built a hundred times.</p>

<p>Here’s what matters, the agent itself isn’t the hard part but The harness is, the execution environment, tooling, guardrails, and observability. It’s all the important stuff that makes automation work in production.</p>

<p>DevOps engineers have been building harnesses forever. CI runners. Deployment pipelines. Infrastructure automation. The patterns are the same. The skills transfer directly.</p>

<p>So if you’re wondering whether AI agents are worth learning, here’s the short answer. You’re already halfway there.</p>

<h2 id="what-an-agent-actually-looks-like">What an Agent Actually Looks Like</h2>

<p>Let’s forget the marketing hype around AI agents and understand from a DevOps engineer’s point of view, what an agent actually looks like. An AI agent has six parts.</p>

<ol>
  <li>
    <p><code class="language-plaintext highlighter-rouge">An LLM</code>: Now LLM is the most important part of an agent as this acts as a brain. It reads context and decides what to do next. It doesn’t touch anything directly.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">A workspace</code>: Think of it as a sandboxed environment. A cloned repo. A container. A temp directory. Same as any CI job.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">A set of tools</code>: These are the actions it can request. Read a file. Run a command. Call an API. Query logs. The agent doesn’t run these itself. It asks for them.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">A control loop</code>: This is the core pattern. Observe the current state. Decide an action. Execute it. Check the result. Keep going until you’re done.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">Policies and limits</code>: Timeouts. Permission boundaries. Rate limits. Cost caps. Without these, agents can spin forever or do things they shouldn’t.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">A termination condition</code>: The agent needs to know when to stop. Task complete. Error threshold hit. Human review needed. Something has to end the loop.</p>
  </li>
</ol>

<p>Now none of this is new as you’ve built systems with all these components. The only difference is the LLM sitting in the decision seat.</p>

<h2 id="the-harness-does-the-heavy-lifting">The Harness Does the Heavy Lifting</h2>

<p>Everyone focuses on the LLM. They miss the important part. The harness is what makes an agent actually work.</p>

<p>The harness is everything around the model. It spins up the environment. Exposes tools. Executes commands on the agent’s behalf. Captures logs and diffs. Enforces limits. Decides when the loop should stop.</p>

<p>Sound familiar? It should. This is what CI runners do.</p>

<p>GitHub Actions. GitLab runners. Jenkins agents. They all follow the same pattern. Spin up an isolated environment. Run steps. Capture output. Handle success and failure. Clean up.</p>

<p>An agent harness does the exact same thing. The only twist is the steps aren’t hardcoded in YAML. They come from the LLM at runtime.</p>

<p>This is why DevOps engineers are perfect for this work. You already think about isolation, execution, logging, and cleanup. You already build systems that run untrusted code safely. Agent harnesses are the same problem with a new input source.</p>

<h2 id="tool-use-is-the-safety-mechanism">Tool Use Is the Safety Mechanism</h2>

<p>Agents don’t touch systems directly. This matters. The LLM never runs a command itself. Never writes a file itself. It requests actions through tools.</p>

<p>The harness gets the request. Validates it. Executes it in a controlled way. Returns a structured result.</p>

<p>This is how you keep agents safe.</p>

<p>Say the agent wants to run a shell command. The harness can check it against an allowlist. Run it in a sandbox. Set a timeout. Capture stderr. The agent never gets raw shell access.</p>

<p>Same thing for file operations. The agent requests a file write. The harness checks the path. Validates the content. Writes the file and returns confirmation.</p>

<p>You control what tools exist. You control how they behave. You control what the agent can even ask for.</p>

<p>This is the same idea behind least privilege. The agent only gets access to what it needs. The harness enforces the boundary.</p>

<h2 id="the-control-loop-in-practice">The Control Loop in Practice</h2>

<p>The core of any agent is the control loop. It looks like this.</p>

<ol>
  <li>
    <p><code class="language-plaintext highlighter-rouge">Observe</code>: The agent reads the current state. Test output. Log files. Diffs. Error messages. Whatever context it needs.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">Decide</code>: The LLM looks at the state and picks an action. Run another test. Edit a file. Ask for more information. Give up.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">Execute</code>: The harness runs the requested action and returns the result.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">Verify</code>: The agent checks if the action worked. Did the test pass? Did the error go away? Is the task done?</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">Repeat</code>: If the task isn’t complete, go back to observe.</p>
  </li>
</ol>

<p>This loop keeps running until a termination condition hits—success, failure, timeout, max iterations, or human intervention.</p>

<p>You’ve seen this before: build, test, fix, rebuild. CI pipelines do this, deployment rollbacks do this, and health check loops do this.</p>

<p>Agents just make the “decide” step dynamic instead of scripted, and here’s where they actually help in DevOps work.</p>

<p><strong>CI failure analysis.</strong> When a test fails, the agent reads the logs, checks the diff, identifies the cause, and suggests a fix—maybe even applying it and rerunning the test.</p>

<p><strong>Terraform drift detection.</strong> The agent compares actual state to declared state, flags the drift, and proposes a remediation plan while a human approves before anything changes.</p>

<p><strong>Kubernetes manifest review.</strong> The agent checks YAML against best practices (missing resource limits, no liveness probes, exposed secrets) catching the stuff humans miss in review.</p>

<p><strong>Cost anomaly investigation.</strong> When spending spikes, the agent queries cost explorer, correlates with recent deployments, and surfaces the likely cause, saving an hour of digging.</p>

<p><strong>Incident log triage.</strong> Faced with pages of logs, the agent reads them, extracts the relevant lines, and summarizes what went wrong (not replacing the engineer, but getting them to the answer faster).</p>

<p>Notice the pattern: the agent assists and handles the tedious parts while the human stays in control of decisions that matter.</p>

<p>AI agents sound complicated with their new frameworks, new terminology, and new paradigms.</p>

<p>But look past the hype and you’ll see something familiar.</p>

<p>An agent is an automation loop where the LLM picks the next step, the harness executes it safely, tools provide controlled access to systems, and policies keep things bounded.</p>

<p>This is CI/CD architecture, infrastructure thinking, the stuff you already do.</p>

<p>When you read about agent frameworks or watch demos of coding assistants, you now have a lens to see the harness underneath, spot the control loop, and ask the right questions: what tools does it expose, what limits exist, and how does it handle failure?</p>

<p>You don’t need to become an ML engineer to understand agents—you just need to recognize the infrastructure patterns you’ve been using all along.</p>

<p>The LLM is the new part. Everything else is your domain.</p>

          ]]>
        </description>
        <pubDate>Sat, 03 Jan 2026 00:00:00 +0000</pubDate>
        <link>https://muhammadraza.me/2026/ai-agents-devops-perspective/</link>
        <guid isPermaLink="true">https://muhammadraza.me/2026/ai-agents-devops-perspective/</guid>
        
        <category>ai</category>
        
        <category>devops</category>
        
        <category>automation</category>
        
        
        
        <dc:creator>{&quot;name&quot;=&gt;&quot;Muhammad Raza&quot;}</dc:creator>
        <dc:rights></dc:rights>
      </item>
    
      <item>
        <title>AWS Cost Optimization Case Study: How I Cut a Client&apos;s Bill by 50%</title>
        <description>
          <![CDATA[
            
            <p>Last month, a client’s AWS bill hit $5,000 — up 40% from last year with no clear explanation.</p>

<p>After one week of systematic analysis, I cut it to <strong>$2,500/month</strong> — a 50% reduction, saving them <strong>$30,000 annually</strong>. Here’s exactly how I did it, with the scripts you can use.</p>

<h2 id="the-discovery-phase-how-i-found-the-problems">The Discovery Phase: How I Found the Problems</h2>

<p>Before touching anything, I needed to understand the infrastructure. Here’s my systematic approach:</p>

<h3 id="step-1-pull-cost-data-by-service">Step 1: Pull Cost Data by Service</h3>

<p>First, I analyzed their AWS Cost Explorer data to understand where money was going:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws ce get-cost-and-usage <span class="se">\</span>
  <span class="nt">--time-period</span> <span class="nv">Start</span><span class="o">=</span>2024-11-01,End<span class="o">=</span>2024-11-30 <span class="se">\</span>
  <span class="nt">--granularity</span> MONTHLY <span class="se">\</span>
  <span class="nt">--metrics</span> <span class="s2">"BlendedCost"</span> <span class="se">\</span>
  <span class="nt">--group-by</span> <span class="nv">Type</span><span class="o">=</span>DIMENSION,Key<span class="o">=</span>SERVICE
</code></pre></div></div>

<p>This gave me the high-level breakdown. But I needed more detail.</p>

<h3 id="step-2-build-a-resource-inventory">Step 2: Build a Resource Inventory</h3>

<p>I wrote a Python script to scan all resources across regions and identify optimization opportunities:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">boto3</span>

<span class="k">def</span> <span class="nf">scan_ebs_volumes</span><span class="p">():</span>
    <span class="s">"""Find GP2 volumes that should be GP3 and unattached volumes."""</span>
    <span class="n">ec2</span> <span class="o">=</span> <span class="n">boto3</span><span class="p">.</span><span class="n">client</span><span class="p">(</span><span class="s">'ec2'</span><span class="p">)</span>
    <span class="n">volumes</span> <span class="o">=</span> <span class="n">ec2</span><span class="p">.</span><span class="n">describe_volumes</span><span class="p">()[</span><span class="s">'Volumes'</span><span class="p">]</span>

    <span class="n">gp2_volumes</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="n">unattached</span> <span class="o">=</span> <span class="p">[]</span>

    <span class="k">for</span> <span class="n">vol</span> <span class="ow">in</span> <span class="n">volumes</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">vol</span><span class="p">[</span><span class="s">'VolumeType'</span><span class="p">]</span> <span class="o">==</span> <span class="s">'gp2'</span><span class="p">:</span>
            <span class="n">gp2_volumes</span><span class="p">.</span><span class="n">append</span><span class="p">({</span>
                <span class="s">'VolumeId'</span><span class="p">:</span> <span class="n">vol</span><span class="p">[</span><span class="s">'VolumeId'</span><span class="p">],</span>
                <span class="s">'Size'</span><span class="p">:</span> <span class="n">vol</span><span class="p">[</span><span class="s">'Size'</span><span class="p">],</span>
                <span class="s">'State'</span><span class="p">:</span> <span class="n">vol</span><span class="p">[</span><span class="s">'State'</span><span class="p">],</span>
                <span class="s">'MonthlyCost'</span><span class="p">:</span> <span class="n">vol</span><span class="p">[</span><span class="s">'Size'</span><span class="p">]</span> <span class="o">*</span> <span class="mf">0.10</span><span class="p">,</span>  <span class="c1"># GP2 pricing
</span>                <span class="s">'GP3Cost'</span><span class="p">:</span> <span class="n">vol</span><span class="p">[</span><span class="s">'Size'</span><span class="p">]</span> <span class="o">*</span> <span class="mf">0.08</span><span class="p">,</span>      <span class="c1"># GP3 pricing
</span>                <span class="s">'Savings'</span><span class="p">:</span> <span class="n">vol</span><span class="p">[</span><span class="s">'Size'</span><span class="p">]</span> <span class="o">*</span> <span class="mf">0.02</span>
            <span class="p">})</span>

        <span class="k">if</span> <span class="n">vol</span><span class="p">[</span><span class="s">'State'</span><span class="p">]</span> <span class="o">==</span> <span class="s">'available'</span><span class="p">:</span>  <span class="c1"># Not attached
</span>            <span class="n">unattached</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">vol</span><span class="p">)</span>

    <span class="k">return</span> <span class="n">gp2_volumes</span><span class="p">,</span> <span class="n">unattached</span>
</code></pre></div></div>

<p>I built similar functions for:</p>
<ul>
  <li>RDS instances (storage type, utilization, Multi-AZ necessity)</li>
  <li>EC2 instances (generation, Reserved Instance coverage)</li>
  <li>Elastic IPs (attached vs idle)</li>
  <li>EBS snapshots (age, associated volumes)</li>
  <li>S3 buckets (storage class, lifecycle policies)</li>
</ul>

<h3 id="step-3-analyze-cloudwatch-metrics-for-utilization">Step 3: Analyze CloudWatch Metrics for Utilization</h3>

<p>This is critical. Before recommending any right-sizing, I needed data:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">get_instance_utilization</span><span class="p">(</span><span class="n">instance_id</span><span class="p">,</span> <span class="n">days</span><span class="o">=</span><span class="mi">30</span><span class="p">):</span>
    <span class="s">"""Get average CPU utilization over the past N days."""</span>
    <span class="n">cloudwatch</span> <span class="o">=</span> <span class="n">boto3</span><span class="p">.</span><span class="n">client</span><span class="p">(</span><span class="s">'cloudwatch'</span><span class="p">)</span>

    <span class="n">response</span> <span class="o">=</span> <span class="n">cloudwatch</span><span class="p">.</span><span class="n">get_metric_statistics</span><span class="p">(</span>
        <span class="n">Namespace</span><span class="o">=</span><span class="s">'AWS/EC2'</span><span class="p">,</span>
        <span class="n">MetricName</span><span class="o">=</span><span class="s">'CPUUtilization'</span><span class="p">,</span>
        <span class="n">Dimensions</span><span class="o">=</span><span class="p">[{</span><span class="s">'Name'</span><span class="p">:</span> <span class="s">'InstanceId'</span><span class="p">,</span> <span class="s">'Value'</span><span class="p">:</span> <span class="n">instance_id</span><span class="p">}],</span>
        <span class="n">StartTime</span><span class="o">=</span><span class="n">datetime</span><span class="p">.</span><span class="n">now</span><span class="p">()</span> <span class="o">-</span> <span class="n">timedelta</span><span class="p">(</span><span class="n">days</span><span class="o">=</span><span class="n">days</span><span class="p">),</span>
        <span class="n">EndTime</span><span class="o">=</span><span class="n">datetime</span><span class="p">.</span><span class="n">now</span><span class="p">(),</span>
        <span class="n">Period</span><span class="o">=</span><span class="mi">86400</span><span class="p">,</span>  <span class="c1"># Daily
</span>        <span class="n">Statistics</span><span class="o">=</span><span class="p">[</span><span class="s">'Average'</span><span class="p">,</span> <span class="s">'Maximum'</span><span class="p">]</span>
    <span class="p">)</span>

    <span class="k">return</span> <span class="n">response</span><span class="p">[</span><span class="s">'Datapoints'</span><span class="p">]</span>
</code></pre></div></div>

<p>The results were eye-opening:</p>
<ul>
  <li>Two t3.xlarge instances averaging <strong>12% CPU</strong></li>
  <li>RDS storage at <strong>95% free space</strong></li>
  <li>Multiple log groups with <strong>no retention policy</strong> (storing terabytes)</li>
</ul>

<h3 id="step-4-map-dependencies-before-cutting">Step 4: Map Dependencies Before Cutting</h3>

<p>Before deleting anything, I mapped what depended on what:</p>
<ul>
  <li>Which services used which Elastic IPs?</li>
  <li>Which applications wrote to which log groups?</li>
  <li>Which backups were actually needed for compliance?</li>
</ul>

<p>This prevented the classic mistake of breaking production while optimizing costs.</p>

<h2 id="the-starting-point">The Starting Point</h2>

<p>After the discovery phase, here’s what I was working with:</p>

<table>
  <thead>
    <tr>
      <th>Service</th>
      <th>Monthly Cost</th>
      <th>% of Total</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>EC2-Other (EBS, NAT, IPs)</td>
      <td>$1,250</td>
      <td>25%</td>
    </tr>
    <tr>
      <td>RDS</td>
      <td>$750</td>
      <td>15%</td>
    </tr>
    <tr>
      <td>EC2 Compute</td>
      <td>$650</td>
      <td>13%</td>
    </tr>
    <tr>
      <td>CloudWatch</td>
      <td>$500</td>
      <td>10%</td>
    </tr>
    <tr>
      <td>AWS Backup</td>
      <td>$500</td>
      <td>10%</td>
    </tr>
    <tr>
      <td>ECS Fargate</td>
      <td>$400</td>
      <td>8%</td>
    </tr>
    <tr>
      <td>S3</td>
      <td>$250</td>
      <td>5%</td>
    </tr>
    <tr>
      <td>VPC</td>
      <td>$250</td>
      <td>5%</td>
    </tr>
    <tr>
      <td>Everything else</td>
      <td>$450</td>
      <td>9%</td>
    </tr>
    <tr>
      <td><strong>Total</strong></td>
      <td><strong>$5,000</strong></td>
      <td><strong>100%</strong></td>
    </tr>
  </tbody>
</table>

<p>The distribution told me a lot. EC2-related costs (compute + EBS + networking) made up over 38% of the bill. That’s where I started.</p>

<h2 id="phase-1-quick-wins-implemented-same-day">Phase 1: Quick Wins (Implemented Same Day)</h2>

<h3 id="release-idle-elastic-ips--saved-50month">Release Idle Elastic IPs — Saved $50/month</h3>

<p>My inventory script flagged 5 Elastic IPs with no association:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws ec2 describe-addresses <span class="nt">--query</span> <span class="s1">'Addresses[?AssociationId==null]'</span>
</code></pre></div></div>

<p>Someone had provisioned them for test environments that were deleted months ago. Classic ghost infrastructure.</p>

<p><strong>Time to fix:</strong> 5 minutes.</p>

<h3 id="migrate-ebs-gp2-to-gp3--saved-125month">Migrate EBS GP2 to GP3 — Saved $125/month</h3>

<p>The script found 6,000+ GB across multiple EBS volumes still on GP2. GP3 costs <a href="https://aws.amazon.com/ebs/pricing/">20% less</a> <strong>and</strong> provides better baseline performance (3,000 IOPS vs GP2’s variable IOPS based on size).</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws ec2 modify-volume <span class="nt">--volume-id</span> vol-xxx <span class="nt">--volume-type</span> gp3
</code></pre></div></div>

<p>No downtime. Just a CLI command per volume.</p>

<p><strong>Time to fix:</strong> 30 minutes for all volumes.</p>

<h3 id="set-cloudwatch-log-retention--saved-100month">Set CloudWatch Log Retention — Saved $100/month</h3>

<p>My scan found 20+ log groups with no retention policy — storing logs forever:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws logs describe-log-groups <span class="nt">--query</span> <span class="s1">'logGroups[?retentionInDays==null].logGroupName'</span>
</code></pre></div></div>

<p>Set production to 90 days, staging to 30 days.</p>

<p><strong>Time to fix:</strong> 20 minutes.</p>

<h2 id="phase-2-the-big-discoveries">Phase 2: The Big Discoveries</h2>

<h3 id="aws-backup-running-24x-more-often-than-needed--saved-400month">AWS Backup Running 24x More Often Than Needed — Saved $400/month</h3>

<p>This was the most surprising find. When I pulled the backup plan configuration:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws backup get-backup-plan <span class="nt">--backup-plan-id</span> xxx
</code></pre></div></div>

<p>I saw: <strong>hourly backups</strong>. 24 backups per day. For every resource.</p>

<p>The backup storage had grown to $500/month — 10% of their total bill.</p>

<p>I reviewed their recovery requirements (they only needed daily backups with 14-day retention) and reconfigured:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"ScheduleExpression"</span><span class="p">:</span><span class="w"> </span><span class="s2">"cron(0 5 * * ? *)"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"StartWindowMinutes"</span><span class="p">:</span><span class="w"> </span><span class="mi">60</span><span class="p">,</span><span class="w">
  </span><span class="nl">"CompletionWindowMinutes"</span><span class="p">:</span><span class="w"> </span><span class="mi">120</span><span class="p">,</span><span class="w">
  </span><span class="nl">"Lifecycle"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"DeleteAfterDays"</span><span class="p">:</span><span class="w"> </span><span class="mi">14</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>Time to fix:</strong> 1 hour (including testing).</p>

<h3 id="cloudwatch-metric-streams-to-nowhere--saved-400month">CloudWatch Metric Streams to Nowhere — Saved $400/month</h3>

<p>My CloudWatch cost breakdown showed $400/month on “Metric Streams” — 100+ million metric updates going somewhere.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws cloudwatch list-metric-streams
</code></pre></div></div>

<p>Found a stream configured to send data to a third-party monitoring tool. When I asked about it, nobody on the team knew it existed. The integration had been set up by a previous contractor and was never used.</p>

<p>This is a perfect example of ghost infrastructure that accumulates over time.</p>

<h3 id="rds-over-provisioned-by-95">RDS Over-Provisioned by 95%</h3>

<p>My RDS analysis showed all instances had massive storage allocated. The CloudWatch metrics told the real story:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws cloudwatch get-metric-statistics <span class="se">\</span>
  <span class="nt">--namespace</span> AWS/RDS <span class="se">\</span>
  <span class="nt">--metric-name</span> FreeStorageSpace <span class="se">\</span>
  <span class="nt">--dimensions</span> <span class="nv">Name</span><span class="o">=</span>DBInstanceIdentifier,Value<span class="o">=</span>production-db <span class="se">\</span>
  <span class="nt">--start-time</span> 2024-11-01T00:00:00Z <span class="se">\</span>
  <span class="nt">--end-time</span> 2024-11-30T23:59:59Z <span class="se">\</span>
  <span class="nt">--period</span> 86400 <span class="se">\</span>
  <span class="nt">--statistics</span> Average
</code></pre></div></div>

<p><strong>Result:</strong> 95% free space across all databases.</p>

<p>RDS storage can only be increased, not decreased. But I migrated all instances from GP2 to GP3 storage — same price, better performance.</p>

<p>For the next database refresh, I recommended right-sized storage instead of the default massive allocations.</p>

<p><strong>Saved:</strong> $150/month</p>

<h2 id="phase-3-infrastructure-improvements">Phase 3: Infrastructure Improvements</h2>

<h3 id="nat-gateway-consolidation--saved-125month">NAT Gateway Consolidation — Saved $125/month</h3>

<p>My VPC analysis showed NAT Gateways in every AZ across multiple regions costing $500/month combined. After reviewing their architecture and traffic patterns, they only needed half of them.</p>

<h3 id="ecs-task-right-sizing--saved-250month">ECS Task Right-Sizing — Saved $250/month</h3>

<p>The ECS service scan found:</p>
<ul>
  <li>A staging service constantly failing health checks and restarting (consuming resources 24/7 while accomplishing nothing)</li>
  <li>Legacy services still running in production that nobody was using</li>
</ul>

<p>These issues relate directly to the <a href="/2025/ecs-decisions-that-waste-6-weeks/">ECS architectural decisions</a> that often waste weeks of engineering time. Plus, enabled Fargate Spot for fault-tolerant workloads (70% savings on those tasks).</p>

<h3 id="s3-lifecycle-policies--saved-150month">S3 Lifecycle Policies — Saved $150/month</h3>

<p>My S3 bucket analysis showed backup buckets had grown to 10+ TB with no lifecycle policy. Old backups were stored in Standard tier forever.</p>

<p>Added a policy to transition to Glacier after 90 days:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"Rules"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"ID"</span><span class="p">:</span><span class="w"> </span><span class="s2">"archive-old-backups"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"Status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Enabled"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"Transitions"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="p">{</span><span class="w">
          </span><span class="nl">"Days"</span><span class="p">:</span><span class="w"> </span><span class="mi">90</span><span class="p">,</span><span class="w">
          </span><span class="nl">"StorageClass"</span><span class="p">:</span><span class="w"> </span><span class="s2">"GLACIER"</span><span class="w">
        </span><span class="p">}</span><span class="w">
      </span><span class="p">]</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="reserved-instances-for-stable-workloads--saved-500month">Reserved Instances for Stable Workloads — Saved $500/month</h3>

<p>My EC2 coverage analysis showed zero Reserved Instance coverage on instances running 24/7.</p>

<p>I helped them purchase <a href="https://aws.amazon.com/savingsplans/compute-pricing/">Compute Savings Plans</a> covering their steady-state workloads. Immediate 30-40% savings on compute.</p>

<h3 id="ec2-instance-right-sizing--saved-250month">EC2 Instance Right-Sizing — Saved $250/month</h3>

<p>The utilization data was clear: multiple instances running at 10-15% CPU.</p>

<p>Downsized t3.xlarge instances to t3.large where utilization data supported it. Same workload, half the cost.</p>

<h2 id="the-results">The Results</h2>

<table>
  <thead>
    <tr>
      <th>Category</th>
      <th>Monthly Savings</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Reserved Instances / Savings Plans</td>
      <td>$500</td>
    </tr>
    <tr>
      <td>AWS Backup (hourly → daily)</td>
      <td>$400</td>
    </tr>
    <tr>
      <td>CloudWatch Metric Streams</td>
      <td>$400</td>
    </tr>
    <tr>
      <td>ECS cleanup + Fargate Spot</td>
      <td>$250</td>
    </tr>
    <tr>
      <td>EC2 right-sizing</td>
      <td>$250</td>
    </tr>
    <tr>
      <td>S3 lifecycle policies</td>
      <td>$150</td>
    </tr>
    <tr>
      <td>RDS improvements</td>
      <td>$150</td>
    </tr>
    <tr>
      <td>EBS GP2 → GP3</td>
      <td>$125</td>
    </tr>
    <tr>
      <td>NAT Gateway consolidation</td>
      <td>$125</td>
    </tr>
    <tr>
      <td>CloudWatch log retention</td>
      <td>$100</td>
    </tr>
    <tr>
      <td>Idle Elastic IPs</td>
      <td>$50</td>
    </tr>
    <tr>
      <td><strong>Total Monthly Savings</strong></td>
      <td><strong>$2,500</strong></td>
    </tr>
  </tbody>
</table>

<p><strong>From $5,000/month to $2,500/month — exactly 50% reduction.</strong></p>

<p>Over a year, that’s <strong>$30,000 back in their pocket</strong>.</p>

<h2 id="the-methodology">The Methodology</h2>

<p>Here’s the systematic approach I use for every cost optimization engagement:</p>

<h3 id="1-get-the-data-first">1. Get the Data First</h3>

<p>Before making any changes, I pull:</p>
<ul>
  <li>AWS Cost Explorer data (by service, by tag, over time)</li>
  <li>CloudWatch metrics for utilization</li>
  <li>Resource inventory across all regions</li>
</ul>

<h3 id="2-find-the-ghosts">2. Find the Ghosts</h3>

<p>“Ghost infrastructure” costs more than you think:</p>
<ul>
  <li>Unused Elastic IPs</li>
  <li>Detached EBS volumes</li>
  <li>Empty S3 buckets accumulating requests</li>
  <li>Log groups with infinite retention</li>
  <li>Metric Streams nobody monitors</li>
  <li>Test environments that outlived their purpose</li>
</ul>

<h3 id="3-right-size-ruthlessly">3. Right-Size Ruthlessly</h3>

<p>Check actual utilization before committing to Reserved Instances:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># EC2 CPU utilization over 30 days</span>
aws cloudwatch get-metric-statistics <span class="se">\</span>
  <span class="nt">--namespace</span> AWS/EC2 <span class="se">\</span>
  <span class="nt">--metric-name</span> CPUUtilization <span class="se">\</span>
  <span class="nt">--dimensions</span> <span class="nv">Name</span><span class="o">=</span>InstanceId,Value<span class="o">=</span>i-xxx <span class="se">\</span>
  <span class="nt">--start-time</span> <span class="si">$(</span><span class="nb">date</span> <span class="nt">-d</span> <span class="s2">"30 days ago"</span> +%Y-%m-%dT%H:%M:%S<span class="si">)</span> <span class="se">\</span>
  <span class="nt">--end-time</span> <span class="si">$(</span><span class="nb">date</span> +%Y-%m-%dT%H:%M:%S<span class="si">)</span> <span class="se">\</span>
  <span class="nt">--period</span> 3600 <span class="se">\</span>
  <span class="nt">--statistics</span> Average
</code></pre></div></div>

<p>If a t3.xlarge averages 15% CPU, you’re paying for 85% idle capacity.</p>

<h3 id="4-modernize-storage">4. Modernize Storage</h3>

<p>GP2 → GP3 is almost always worth it:</p>
<ul>
  <li>20% cheaper at baseline</li>
  <li>Better performance (3,000 IOPS baseline)</li>
  <li>Zero downtime migration</li>
</ul>

<h3 id="5-review-backup-policies">5. Review Backup Policies</h3>

<p>Backups grow silently. Questions to ask:</p>
<ul>
  <li>How often do you actually need backups?</li>
  <li>How long do you really need to keep them?</li>
  <li>Are you backing up dev/test environments at production frequency?</li>
</ul>

<h2 id="what-this-looks-like-over-time">What This Looks Like Over Time</h2>

<table>
  <thead>
    <tr>
      <th>Metric</th>
      <th>Before</th>
      <th>After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Monthly spend</td>
      <td>$5,000</td>
      <td>$2,500</td>
    </tr>
    <tr>
      <td>Annual spend</td>
      <td>$60,000</td>
      <td>$30,000</td>
    </tr>
    <tr>
      <td><strong>Annual savings</strong></td>
      <td>—</td>
      <td><strong>$30,000</strong></td>
    </tr>
  </tbody>
</table>

<p>The best part? None of these changes affected performance or reliability. Most improved it.</p>

<h2 id="common-patterns-i-see">Common Patterns I See</h2>

<p>After doing this for multiple clients, patterns emerge:</p>

<ol>
  <li><strong>Metric Streams nobody monitors</strong> — $100-400/month just disappearing</li>
  <li><strong>Hourly backups for daily restore needs</strong> — 24x the storage cost</li>
  <li><strong>GP2 volumes from years ago</strong> — never migrated to GP3</li>
  <li><strong>Multi-AZ staging databases</strong> — paying for HA nobody needs</li>
  <li><strong>NAT Gateways in every AZ</strong> — when one or two would suffice</li>
  <li><strong>Logs kept forever</strong> — “just in case”</li>
  <li><strong>No Reserved Instances</strong> — paying full on-demand for 24/7 workloads</li>
  <li><strong>Over-provisioned everything</strong> — “it might need it someday”</li>
</ol>

<hr />

<h2 id="need-help-with-your-aws-bill">Need Help With Your AWS Bill?</h2>

<p>I do AWS cost optimization as part of my DevOps consulting practice. If your AWS bill feels too high or you just want a second pair of eyes on your infrastructure, let’s talk.</p>

<p><strong><a href="https://calendly.com/muhammad-07/30-minute-meeting">Book a free 30-minute call</a></strong> — I’ll review your current setup and tell you where I see opportunities.</p>

<hr />

<p><em>Have questions about any of these optimizations? Drop a comment below or reach out on <a href="https://twitter.com/muhammad_o7">Twitter/X</a>.</em></p>


          ]]>
        </description>
        <pubDate>Sat, 27 Dec 2025 00:00:00 +0000</pubDate>
        <link>https://muhammadraza.me/2025/aws-cost-optimization-case-study/</link>
        <guid isPermaLink="true">https://muhammadraza.me/2025/aws-cost-optimization-case-study/</guid>
        
        <category>aws</category>
        
        <category>devops</category>
        
        <category>case-study</category>
        
        
        
        <dc:creator>{&quot;name&quot;=&gt;&quot;Muhammad Raza&quot;}</dc:creator>
        <dc:rights></dc:rights>
      </item>
    
      <item>
        <title>The 5 ECS Decisions That Waste 6 Weeks (And What to Pick Instead)</title>
        <description>
          <![CDATA[
            
            <p>I’ve been helping Python teams deploy to AWS for the past 2 years now. The pattern is always the same: a team has a working FastAPI or Django app running perfectly on their laptops with <code class="language-plaintext highlighter-rouge">docker-compose up</code>, and then someone says “let’s put this in ECS.” Six weeks later, they’re still arguing about whether to use Fargate or EC2.</p>

<p>The problem isn’t that ECS is hard. The problem is that teams treat infrastructure decisions like they’re permanent. They’re not.</p>

<p>Last year I worked with a startup that spent 5 weeks evaluating container orchestration options. Five weeks. They had a working app. They had paying customers waiting. But the engineering team was stuck in an endless loop of “what if we need to scale?” and “shouldn’t we future-proof this?”</p>

<p>They launched on Fargate. It took 3 days once they stopped debating.</p>

<p>Here are the 5 decisions that waste the most time and what I tell every client to pick.</p>

<h2 id="fargate-vs-ec2-just-use-fargate">Fargate vs EC2: Just Use Fargate</h2>

<p>This one wastes more time than all the others combined.</p>

<p>I get it. EC2 looks cheaper on paper. You can run the numbers, build a spreadsheet, show that at 50 containers you’ll save $400/month with EC2. The finance person gets excited. Someone mentions spot instances. Now you’re three meetings deep into capacity planning for traffic you don’t have yet.</p>

<p>Here’s what actually happens with EC2: you spend a week figuring out instance types, another week on auto-scaling groups, then you hit some weird issue where your containers won’t place because the bin-packing algorithm can’t find space, and suddenly your “cheaper” option has eaten two sprints of engineering time.</p>

<p>Fargate just works. You tell it how much CPU and memory you need, and it runs your container. No instances to manage, no patching, no capacity planning.</p>

<p>“But it’s more expensive!”</p>

<p>Sure. Maybe 20-30% more at scale. But you’re not at scale. You’re trying to ship. And even if Fargate costs you an extra $200/month right now, that’s nothing compared to the $30k+ in engineering salaries you’re burning while debating this.</p>

<p>Python apps especially benefit from Fargate. Your Django app with Celery workers is memory-heavy and I/O bound. You’re not doing CPU-intensive work. Fargate lets you right-size memory without playing Tetris with EC2 instance types.</p>

<p>Pick Fargate. When you’re running 200 containers 24/7 and have real cost data, revisit. Until then, move on.</p>

<h2 id="ecs-service-discovery-use-an-internal-alb">ECS Service Discovery: Use an Internal ALB</h2>

<p>When your services need to talk to each other, AWS gives you three options: Cloud Map, internal ALB, or Service Connect. I’ve seen teams spend weeks evaluating all three, setting up proof-of-concepts, reading whitepapers.</p>

<p>Just use an internal ALB.</p>

<p>I know, it’s not great. It’s a load balancer. It’s been around forever. But that’s exactly why you should use it:</p>

<ul>
  <li>It gives you a stable DNS name your services can call</li>
  <li>Health checks work out of the box</li>
  <li>You get access logs for debugging</li>
  <li>Every developer on your team already understands HTTP</li>
</ul>

<p>Your FastAPI service calls <code class="language-plaintext highlighter-rouge">http://api-internal.yourdomain.local/users</code> and it just works. No service mesh. No Envoy sidecars. No DNS caching gotchas.</p>

<p>Cloud Map is fine, but I’ve debugged too many issues where services couldn’t find each other because of DNS TTL problems. Service Connect is powerful, but now you’re operating a service mesh. Do you really want to be debugging Envoy proxy configuration when your actual problem is a database query?</p>

<p>The internal ALB is boring. Boring is good. Boring means you’re debugging your application code instead of your infrastructure.</p>

<h2 id="cicd-for-ecs-use-github-actions">CI/CD for ECS: Use GitHub Actions</h2>

<p>I’m gonna be honest here: if your code is on GitHub, use GitHub Actions. Don’t overthink this.</p>

<p>“But CodePipeline is AWS-native!”</p>

<p>Yes, and it requires you to set up a pipeline with Source, Build, and Deploy stages, configure IAM roles for each stage, create buildspec files, and wire everything together. It’s more YAML for the same result.</p>

<p>“But Jenkins gives us more control!”</p>

<p>It’s 2025. Please don’t set up a Jenkins server. You’ll spend more time maintaining Jenkins than deploying your app.</p>

<p>GitHub Actions has an official AWS action that handles ECS deployments:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Deploy to ECS</span>
  <span class="na">uses</span><span class="pi">:</span> <span class="s">aws-actions/amazon-ecs-deploy-task-definition@v1</span>
  <span class="na">with</span><span class="pi">:</span>
    <span class="na">task-definition</span><span class="pi">:</span> <span class="s">task-definition.json</span>
    <span class="na">service</span><span class="pi">:</span> <span class="s">my-service</span>
    <span class="na">cluster</span><span class="pi">:</span> <span class="s">my-cluster</span>
    <span class="na">wait-for-service-stability</span><span class="pi">:</span> <span class="no">true</span>
</code></pre></div></div>

<p>That’s the whole thing. It registers your task definition, updates the service, and waits for the deployment to stabilize. AWS maintains it. It works.</p>

<p>Your deployment workflow lives in your repo, your team already knows GitHub Actions from running tests, and you’re not managing another piece of infrastructure. If you want to understand how these pipeline runners actually work internally, I wrote a deep dive on <a href="/2025/building-cicd-pipeline-runner-python/">building a CI/CD pipeline runner from scratch in Python</a>.</p>

<p>If you’re on GitLab, use GitLab CI. If you’re on Bitbucket, use Bitbucket Pipelines. The point is: use whatever’s already integrated with your code. Don’t add complexity.</p>

<h2 id="ecs-secrets-management-use-ssm-parameter-store">ECS Secrets Management: Use SSM Parameter Store</h2>

<p>Where do you store your database passwords and API keys?</p>

<p>Not in your task definition. I’ve seen that. Please don’t.</p>

<p>The two real options are SSM Parameter Store and Secrets Manager. Teams debate this endlessly because Secrets Manager has automatic rotation and sounds more “enterprise.”</p>

<p>Here’s the thing: SSM Parameter Store is free, integrates natively with ECS, and handles 99% of use cases.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"secrets"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"DATABASE_URL"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"valueFrom"</span><span class="p">:</span><span class="w"> </span><span class="s2">"arn:aws:ssm:us-east-1:123456789:parameter/myapp/database_url"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Your Python app reads <code class="language-plaintext highlighter-rouge">os.environ['DATABASE_URL']</code> like it does locally. No SDK, no code changes.</p>

<p>Secrets Manager costs $0.40 per secret per month and is worth it if you need automatic rotation for RDS credentials. But you probably don’t need that on day one. Start with SSM, migrate specific secrets to Secrets Manager later if you need rotation.</p>

<p>And please, don’t set up HashiCorp Vault unless you have compliance requirements that specifically mandate it. You’re now operating a distributed system just to store passwords. That’s not simplifying your life.</p>

<h2 id="ecs-logging-and-monitoring-use-cloudwatch">ECS Logging and Monitoring: Use CloudWatch</h2>

<p>Every team wants to evaluate Datadog, New Relic, Honeycomb, and then maybe self-host Prometheus and Grafana “for cost savings.”</p>

<p>Stop. Use CloudWatch.</p>

<p>Add this to your task definition:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"logConfiguration"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"logDriver"</span><span class="p">:</span><span class="w"> </span><span class="s2">"awslogs"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"options"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"awslogs-group"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/ecs/my-service"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"awslogs-region"</span><span class="p">:</span><span class="w"> </span><span class="s2">"us-east-1"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"awslogs-stream-prefix"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ecs"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Done. Your container logs go to CloudWatch. You can query them with Log Insights. Enable Container Insights and you get CPU/memory metrics. Set up a few alarms. You now have better observability than 80% of startups.</p>

<p>Datadog is genuinely good software. I like it. But it costs $15+ per host per month from day one, and you need to manage another vendor relationship. You can add it later when you actually need distributed tracing or APM.</p>

<p>Self-hosted observability is a trap. I’ve seen teams spend months building ELK stacks and Prometheus clusters. That’s infrastructure work that doesn’t ship features. Unless you have a dedicated platform team, don’t volunteer for this.</p>

<h2 id="the-actual-pattern-here">The Actual Pattern Here</h2>

<p>Look at what I recommended:</p>

<ul>
  <li>Fargate over EC2</li>
  <li>Internal ALB over Cloud Map or Service Connect</li>
  <li>GitHub Actions over CodePipeline or Jenkins</li>
  <li>SSM over Secrets Manager or Vault</li>
  <li>CloudWatch over Datadog or self-hosted</li>
</ul>

<p>Every single choice optimizes for the same thing: <strong>less stuff to manage</strong>.</p>

<p>Yes, some of these cost slightly more money. Yes, some of them are less flexible. But they all share one property: they let you ship faster and debug easier.</p>

<p>And here’s what nobody puts in their architecture decision records: all of these choices are reversible.</p>

<ul>
  <li>Fargate to EC2? Task definitions work on both.</li>
  <li>ALB to Service Connect? Just DNS changes.</li>
  <li>SSM to Secrets Manager? Same integration pattern.</li>
  <li>CloudWatch to Datadog? Add the agent, keep CloudWatch as backup.</li>
</ul>

<p>The “wrong” choice costs you maybe a few hundred dollars a month in inefficiency. The debate about the “right” choice costs you weeks of engineering time.</p>

<h2 id="what-ive-actually-seen-happen">What I’ve Actually Seen Happen</h2>

<p>Teams that follow this advice ship in about a week:</p>

<ul>
  <li>Day 1-2: Fargate cluster up, first service running</li>
  <li>Day 3: ALB routing traffic, services talking to each other</li>
  <li>Day 4: GitHub Actions deploying on push to main</li>
  <li>Day 5: Secrets in SSM, logs in CloudWatch, basic alarms set up</li>
</ul>

<p>Week 2: Building features.</p>

<p>Teams that “do it right” are still having meetings about networking topology in week 6.</p>

<p>I’ve watched startups run out of runway while their infrastructure was still “almost ready.” I’ve seen senior engineers burn out on DevOps work instead of building the product that got them excited in the first place.</p>

<p>Your Python app on Fargate with CloudWatch logs isn’t going to fall over at 1,000 users. Probably not at 10,000. By the time scale is actually a problem, you’ll have the traffic data and revenue to solve it properly.</p>

<p>Ship first. Optimize later.</p>

<hr />

<p><strong>If you found this helpful, share it on X and tag me <a href="https://twitter.com/muhammad_o7">@muhammad_o7</a></strong> - I’d love to hear about your ECS deployment experiences. You can also connect with me on <a href="https://www.linkedin.com/in/muhammad-raza-07/">LinkedIn</a>.</p>

<p><strong>Need Help?</strong> I’m available for AWS and DevOps consulting. If you’re stuck in ECS decision paralysis or need help getting to production faster, reach out via <a href="mailto:muhammadraza0047@gmail.com">email</a> or DM me on <a href="https://twitter.com/muhammad_o7">X/Twitter</a>.</p>

          ]]>
        </description>
        <pubDate>Thu, 18 Dec 2025 00:00:00 +0000</pubDate>
        <link>https://muhammadraza.me/2025/ecs-decisions-that-waste-6-weeks/</link>
        <guid isPermaLink="true">https://muhammadraza.me/2025/ecs-decisions-that-waste-6-weeks/</guid>
        
        <category>aws</category>
        
        <category>devops</category>
        
        <category>python</category>
        
        
        
        <dc:creator>{&quot;name&quot;=&gt;&quot;Muhammad Raza&quot;}</dc:creator>
        <dc:rights></dc:rights>
      </item>
    
  </channel>
</rss>
