<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ automation - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ automation - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Sun, 24 May 2026 09:05:04 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/automation/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Self-Hosted WhatsApp Bot with n8n and WAHA ]]>
                </title>
                <description>
                    <![CDATA[ WhatsApp is where your many of your customers likely already are. For support tickets, order updates, booking reminders, and lead qualification, a WhatsApp channel often converts several times better  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-self-hosted-whatsapp-bot-with-n8n-and-waha/</link>
                <guid isPermaLink="false">6a01e032fca21b0d4b2bb4c1</guid>
                
                    <category>
                        <![CDATA[ whatsapp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ n8n ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ self-hosted ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ אחיה כהן ]]>
                </dc:creator>
                <pubDate>Mon, 11 May 2026 13:57:06 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/28affe4d-9359-4cbb-a311-a2ee9d0829c0.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>WhatsApp is where your many of your customers likely already are. For support tickets, order updates, booking reminders, and lead qualification, a WhatsApp channel often converts several times better than email.</p>
<p>But the official WhatsApp Business Cloud API can be slow to onboard, template-restricted for proactive messages, and priced per conversation — which adds up fast at scale.</p>
<p>There's another path: you can run your own WhatsApp HTTP gateway on a small server, connect it to a workflow engine, and keep every message — inbound and outbound — inside infrastructure you control. No monthly conversation fees, no template approvals for routine replies, no third-party middleman holding your customer data.</p>
<p>In this tutorial, you'll build exactly that. By the end, you'll have a WhatsApp bot that:</p>
<ul>
<li><p>Receives every incoming message through a webhook</p>
</li>
<li><p>Routes messages through an n8n workflow</p>
</li>
<li><p>Replies automatically based on keywords, AI, or any API call you want</p>
</li>
<li><p>Runs entirely on your own server, using two open-source tools</p>
</li>
</ul>
<p>You'll use <strong>WAHA</strong> (WhatsApp HTTP API) as the gateway, and <strong>n8n</strong> as the workflow engine. Both run in Docker, both are free for self-hosting, and together they cover everything from a simple auto-reply to a full CRM integration.</p>
<h2 id="heading-table-of-contents">Table of contents</h2>
<ul>
<li><p><a href="#heading-what-youll-learn">What You'll Learn</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-a-note-on-which-whatsapp-account-to-use">A Note on Which WhatsApp Account to Use</a></p>
</li>
<li><p><a href="#heading-waha-vs-the-official-whatsapp-business-cloud-api">WAHA vs the official WhatsApp Business Cloud API</a></p>
</li>
<li><p><a href="#heading-part-1-understanding-waha">Part 1: Understanding WAHA</a></p>
</li>
<li><p><a href="#heading-part-2-running-waha-with-docker">Part 2: Running WAHA with Docker</a></p>
</li>
<li><p><a href="#heading-part-3-starting-a-whatsapp-session">Part 3: Starting a WhatsApp session</a></p>
</li>
<li><p><a href="#heading-part-4-running-n8n">Part 4: Running n8n</a></p>
</li>
<li><p><a href="#heading-part-5-creating-the-webhook-trigger-in-n8n">Part 5: Creating the Webhook Trigger in n8n</a></p>
</li>
<li><p><a href="#heading-part-6-wiring-waha-to-n8n">Part 6: Wiring WAHA to n8n</a></p>
</li>
<li><p><a href="#heading-part-7-building-the-first-auto-reply">Part 7: Building the first auto-reply</a></p>
</li>
<li><p><a href="#heading-part-8-a-second-example-proactive-booking-confirmations">Part 8: A Second Example — Proactive Booking Confirmations</a></p>
</li>
<li><p><a href="#heading-part-9-going-to-production">Part 9: Going to Production</a></p>
</li>
<li><p><a href="#heading-common-pitfalls">Common Pitfalls</a></p>
</li>
<li><p><a href="#heading-where-to-go-next">Where to Go Next</a></p>
</li>
</ul>
<h2 id="heading-what-youll-learn">What You'll Learn</h2>
<ul>
<li><p>How WAHA works under the hood and when to use it instead of the official Cloud API</p>
</li>
<li><p>How to run WAHA and n8n side by side with Docker Compose</p>
</li>
<li><p>How to scan the QR code and bind a WhatsApp account to your gateway</p>
</li>
<li><p>How to connect WAHA's webhook to an n8n workflow</p>
</li>
<li><p>How to build a keyword-based auto-reply bot</p>
</li>
<li><p>How to send proactive confirmations from a separate workflow</p>
</li>
<li><p>How to harden the setup for production (HTTPS, API keys, rate limits, Queue Mode)</p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>A Linux server (any VPS works — 2 GB of RAM is enough for a small bot)</p>
</li>
<li><p>Docker and Docker Compose installed</p>
</li>
<li><p>A public hostname with DNS pointing at the server, or an ngrok tunnel for local testing</p>
</li>
<li><p>A WhatsApp account you're willing to dedicate to the bot (more on that below)</p>
</li>
<li><p>Basic familiarity with JSON and HTTP requests</p>
</li>
</ul>
<p>You don't need prior n8n experience. If you can drag a box and wire it to another box, you can build the flow.</p>
<h2 id="heading-a-note-on-which-whatsapp-account-to-use">A Note on Which WhatsApp Account to Use</h2>
<p>WAHA works by running an actual WhatsApp Web session inside a headless Chromium process. It logs in as a real account — the same way you would open web.whatsapp.com in your browser. Meta doesn't officially endorse this approach for commercial use at scale, and heavy volume from a single number can lead to a ban.</p>
<p>For that reason, use a dedicated number for the bot. Don't use your personal WhatsApp. Get a second SIM, eSIM, or a VoIP number that supports WhatsApp activation. Keep outbound volume reasonable, and you'll be fine for most small-business use cases.</p>
<p>If you plan to send thousands of marketing messages per day, switch to the official WhatsApp Business Cloud API — that's what it exists for. This tutorial is aimed at the middle ground: support bots, order updates, booking confirmations, and similar conversational flows where you need real-time control without enterprise pricing.</p>
<h2 id="heading-waha-vs-the-official-whatsapp-business-cloud-api">WAHA vs the official WhatsApp Business Cloud API</h2>
<p>Before writing any code, it helps to understand when each option is the right fit.</p>
<table>
<thead>
<tr>
<th>Dimension</th>
<th>WAHA (self-hosted)</th>
<th>WhatsApp Cloud API (Meta)</th>
</tr>
</thead>
<tbody><tr>
<td>Onboarding</td>
<td>Scan a QR code — ready in minutes</td>
<td>Business verification, app review — days to weeks</td>
</tr>
<tr>
<td>Cost</td>
<td>Server cost only</td>
<td>Per-conversation pricing</td>
</tr>
<tr>
<td>Template approval</td>
<td>Not needed</td>
<td>Required for proactive messages outside the 24-hour window</td>
</tr>
<tr>
<td>Session model</td>
<td>One WhatsApp Web session per Core container</td>
<td>Native API, no web session</td>
</tr>
<tr>
<td>Risk</td>
<td>Account ban possible at high unsolicited volume</td>
<td>Rate limits but no ban for normal use</td>
</tr>
<tr>
<td>Vendor lock-in</td>
<td>None — pure open source</td>
<td>Tied to Meta's API and pricing</td>
</tr>
<tr>
<td>Best for</td>
<td>Support bots, small-team workflows, internal tools</td>
<td>High-volume marketing, regulated industries, &gt;100k monthly messages</td>
</tr>
</tbody></table>
<p>Neither is strictly better. If you run a support team for a small business, WAHA is often the pragmatic choice. If you're a bank sending millions of transactional messages, you want the Cloud API. Many teams run both — WAHA for conversational support, Cloud API for bulk transactional traffic.</p>
<h2 id="heading-part-1-understanding-waha">Part 1: Understanding WAHA</h2>
<p>WAHA is an open-source project that wraps WhatsApp Web behind a clean REST API. You <code>POST /api/sendText</code> with a chat ID and a message, and WAHA sends it. You configure a webhook URL, and WAHA <code>POST</code>s to that URL every time a message arrives.</p>
<p>Under the hood, WAHA spawns a Chromium instance, opens WhatsApp Web, and uses an engine (<code>whatsapp-web.js</code>, <code>NOWEB</code>, or <code>GOWS</code>) to automate the session. Your code doesn't see any of that complexity — you just see an HTTP API.</p>
<p>The project ships in two flavors:</p>
<ul>
<li><p><strong>WAHA Core</strong> — free, MIT licensed, one active session per container, community support.</p>
</li>
<li><p><strong>WAHA Plus</strong> — commercial license, multi-session support, priority support, and access to advanced endpoints.</p>
</li>
</ul>
<p>For most developers building a single bot, Core is enough. You can always upgrade later.</p>
<p>Official docs live at <a href="https://waha.devlike.pro/">waha.devlike.pro</a>. Keep that open in another tab — we'll reference specific endpoints as we go.</p>
<h2 id="heading-part-2-running-waha-with-docker">Part 2: Running WAHA with Docker</h2>
<p>Create a fresh directory for the project:</p>
<pre><code class="language-bash">mkdir whatsapp-bot &amp;&amp; cd whatsapp-bot
</code></pre>
<p>Create a <code>docker-compose.yml</code> file:</p>
<pre><code class="language-yaml">services:
  waha:
    image: devlikeapro/waha:latest
    container_name: waha
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      - WAHA_DASHBOARD_ENABLED=true
      - WAHA_DASHBOARD_USERNAME=admin
      - WAHA_DASHBOARD_PASSWORD=change-me-now
      - WHATSAPP_API_KEY=super-secret-key-change-me
      - WHATSAPP_DEFAULT_ENGINE=WEBJS
    volumes:
      - ./waha-sessions:/app/.sessions
</code></pre>
<p>A few things to notice:</p>
<ul>
<li><p>The dashboard username and password protect the web UI at <code>http://your-server:3000</code>. Always change the defaults before you expose the port publicly.</p>
</li>
<li><p><code>WHATSAPP_API_KEY</code> is the key every HTTP request to WAHA must include in the <code>X-Api-Key</code> header. Treat it like a database password.</p>
</li>
<li><p><code>WHATSAPP_DEFAULT_ENGINE=WEBJS</code> uses the mature <code>whatsapp-web.js</code> engine. WAHA also supports <code>NOWEB</code> and <code>GOWS</code> engines with different trade-offs — WEBJS is the safest default for a first deployment.</p>
</li>
<li><p>The volume mount persists the session across restarts. Without it, every container rebuild forces you to scan the QR code again.</p>
</li>
</ul>
<p>Start the container:</p>
<pre><code class="language-bash">docker compose up -d
docker compose logs -f waha
</code></pre>
<p>Within about 20 seconds WAHA finishes booting. Visit <code>http://your-server:3000</code> and log in with the dashboard credentials.</p>
<h2 id="heading-part-3-starting-a-whatsapp-session">Part 3: Starting a WhatsApp session</h2>
<p>WAHA calls each WhatsApp account a "session." You can have one session at a time on WAHA Core.</p>
<p>From the dashboard, click <strong>Start New Session</strong> and name it <code>default</code>. WAHA displays a QR code.</p>
<p>On your phone:</p>
<ol>
<li><p>Open WhatsApp.</p>
</li>
<li><p>Tap the three-dot menu (Android) or Settings (iOS).</p>
</li>
<li><p>Tap Linked Devices → Link a Device.</p>
</li>
<li><p>Point the camera at the QR code on your screen.</p>
</li>
</ol>
<p>Within a few seconds the dashboard shows <code>WORKING</code> status. Your session is live.</p>
<p>You can also do this over the API. Start the session (<code>default</code> is the session name, encoded in the URL path):</p>
<pre><code class="language-bash">curl -X POST http://your-server:3000/api/sessions/default/start \
  -H "X-Api-Key: super-secret-key-change-me"
</code></pre>
<p>The call is idempotent — if the session is already running, nothing happens.</p>
<p>Fetch the QR as a PNG:</p>
<pre><code class="language-bash">curl http://your-server:3000/api/default/auth/qr \
  -H "X-Api-Key: super-secret-key-change-me" \
  -H "Accept: image/png" \
  --output qr.png
</code></pre>
<p>Scan and you're in.</p>
<p>Test that the session works by sending a message to yourself:</p>
<pre><code class="language-bash">curl -X POST http://your-server:3000/api/sendText \
  -H "X-Api-Key: super-secret-key-change-me" \
  -H "Content-Type: application/json" \
  -d '{
    "session": "default",
    "chatId": "15555550123@c.us",
    "text": "Hello from WAHA!"
  }'
</code></pre>
<p>Replace <code>15555550123</code> with your own number (country code plus number, no <code>+</code>, no spaces, no dashes). The <code>@c.us</code> suffix marks it as an individual chat. Groups use <code>@g.us</code>.</p>
<p>If the message lands on your phone — congratulations. The gateway works.</p>
<h2 id="heading-part-4-running-n8n">Part 4: Running n8n</h2>
<p>Add an <code>n8n</code> service to your <code>docker-compose.yml</code> alongside WAHA:</p>
<pre><code class="language-yaml">services:
  waha:
    # ... existing config

  n8n:
    image: n8nio/n8n:latest
    container_name: n8n
    restart: unless-stopped
    ports:
      - "5678:5678"
    environment:
      - N8N_HOST=n8n.example.com
      - N8N_PORT=5678
      - N8N_PROTOCOL=https
      - WEBHOOK_URL=https://n8n.example.com/
      - GENERIC_TIMEZONE=UTC
    volumes:
      - ./n8n-data:/home/node/.n8n
</code></pre>
<p>Replace <code>n8n.example.com</code> with your real domain. For purely local testing, set:</p>
<pre><code class="language-yaml">- N8N_HOST=localhost
- N8N_PROTOCOL=http
- WEBHOOK_URL=http://localhost:5678/
</code></pre>
<p>If you want to test webhooks from your laptop without a server, run <code>ngrok http 5678</code> in another terminal and use the ngrok HTTPS URL as <code>WEBHOOK_URL</code>. n8n uses <code>WEBHOOK_URL</code> to tell external services where to POST — get this wrong and your webhooks will 404.</p>
<p>Start the stack:</p>
<pre><code class="language-bash">docker compose up -d
</code></pre>
<p>Visit <code>http://your-server:5678</code>. On the first visit, n8n walks you through creating an owner account (email and password). Every subsequent visit requires that login. For extra safety in production, put n8n behind a reverse proxy with an allow-list or an additional auth layer — we'll set that up later.</p>
<h2 id="heading-part-5-creating-the-webhook-trigger-in-n8n">Part 5: Creating the Webhook Trigger in n8n</h2>
<p>Click Create Workflow. You'll see an empty canvas.</p>
<p>Add a Webhook node and configure it:</p>
<ul>
<li><p><strong>HTTP Method</strong>: POST</p>
</li>
<li><p><strong>Path</strong>: <code>whatsapp</code> (this becomes part of the URL)</p>
</li>
<li><p><strong>Response Mode</strong>: Respond Immediately</p>
</li>
<li><p><strong>Response Data</strong>: First Entry JSON</p>
</li>
</ul>
<p>Click Listen for Test Event. n8n shows you two URLs: a test URL and a production URL. Copy the production URL. It looks like this:</p>
<pre><code class="language-plaintext">https://n8n.example.com/webhook/whatsapp
</code></pre>
<p>Not <code>webhook-test</code> — that one only fires while the editor is open. You want <code>webhook</code>.</p>
<h2 id="heading-part-6-wiring-waha-to-n8n">Part 6: Wiring WAHA to n8n</h2>
<p>WAHA can POST to a webhook on every WhatsApp event. Tell it where to send those events.</p>
<p>In the WAHA dashboard, open your session and set the webhook URL. Or do it over the API:</p>
<pre><code class="language-bash">curl -X PUT http://your-server:3000/api/sessions/default \
  -H "X-Api-Key: super-secret-key-change-me" \
  -H "Content-Type: application/json" \
  -d '{
    "config": {
      "webhooks": [
        {
          "url": "https://n8n.example.com/webhook/whatsapp",
          "events": ["message", "session.status"]
        }
      ]
    }
  }'
</code></pre>
<p>The <code>message</code> event fires on every inbound message. <code>session.status</code> fires when the session connects, disconnects, or reconnects — which is useful for alerting when your bot goes down.</p>
<p>Test it. From another phone, send a WhatsApp message to your bot's number. Head back to the n8n editor. Within a second or two the webhook node lights up with the event data.</p>
<p>The payload looks roughly like this:</p>
<pre><code class="language-json">{
  "event": "message",
  "session": "default",
  "payload": {
    "id": "false_15555550123@c.us_3EB0...",
    "from": "15555550123@c.us",
    "body": "Hello",
    "timestamp": 1713801234,
    "fromMe": false
  }
}
</code></pre>
<p>Everything you need is in <code>payload</code>: who sent it (<code>from</code>), what they said (<code>body</code>), and when (<code>timestamp</code>).</p>
<h2 id="heading-part-7-building-the-first-auto-reply">Part 7: Building the first auto-reply</h2>
<p>A bot that only listens is boring. Let's make it answer.</p>
<p>You'll build a tiny keyword router: if the user sends <code>hi</code> or <code>hello</code>, the bot greets them. If they send <code>price</code>, it sends a pricing message. Anything else gets a fallback.</p>
<p>After the Webhook node, add a Switch node.</p>
<p>Configure the Switch node:</p>
<ul>
<li><p><strong>Mode</strong>: Expression</p>
</li>
<li><p><strong>Value</strong>: <code>{{ $json.payload.body.toLowerCase().trim() }}</code></p>
</li>
<li><p>Add routing rules:</p>
<ul>
<li><p>Rule 1: equals <code>hi</code> — output 0</p>
</li>
<li><p>Rule 2: equals <code>hello</code> — output 0</p>
</li>
<li><p>Rule 3: equals <code>price</code> — output 1</p>
</li>
<li><p>Fallback output: 2</p>
</li>
</ul>
</li>
</ul>
<p>After the Switch, add three HTTP Request nodes, one per output.</p>
<p>Configure each HTTP Request node identically, except for the body text:</p>
<ul>
<li><p><strong>Method</strong>: POST</p>
</li>
<li><p><strong>URL</strong>: <code>http://waha:3000/api/sendText</code> (inside the Docker network you can reach WAHA by its service name. From outside use the full public URL)</p>
</li>
<li><p><strong>Send Headers</strong>: on</p>
<ul>
<li><p><code>X-Api-Key</code>: <code>super-secret-key-change-me</code></p>
</li>
<li><p><code>Content-Type</code>: <code>application/json</code></p>
</li>
</ul>
</li>
<li><p><strong>Send Body</strong>: on</p>
<ul>
<li><p><strong>Body Content Type</strong>: JSON</p>
</li>
<li><p><strong>Specify Body</strong>: Using JSON</p>
</li>
</ul>
</li>
</ul>
<p>For the greeting node, the JSON body is:</p>
<pre><code class="language-json">{
  "session": "default",
  "chatId": "={{ $('Webhook').item.json.payload.from }}",
  "text": "Hi! I'm the bot. Send 'price' to see pricing, or anything else for help."
}
</code></pre>
<p>For the pricing node:</p>
<pre><code class="language-json">{
  "session": "default",
  "chatId": "={{ $('Webhook').item.json.payload.from }}",
  "text": "Our plans start at $49/month. Reply 'sales' to talk to a human."
}
</code></pre>
<p>For the fallback:</p>
<pre><code class="language-json">{
  "session": "default",
  "chatId": "={{ $('Webhook').item.json.payload.from }}",
  "text": "I didn't catch that. Try 'hi' or 'price'."
}
</code></pre>
<p>The <code>={{ ... }}</code> syntax is an n8n expression — at runtime it pulls values from earlier nodes.</p>
<p>Connect the Switch outputs to their matching HTTP Request nodes. Save the workflow. Click Activate in the top-right.</p>
<p>Send <code>hi</code> to your bot from any phone. It should reply within a second.</p>
<p>Congratulations — you have a WhatsApp bot running entirely on your own infrastructure.</p>
<h2 id="heading-part-8-a-second-example-proactive-booking-confirmations">Part 8: A Second Example — Proactive Booking Confirmations</h2>
<p>Auto-reply is useful. Proactive outbound is where the value really compounds. Here's a second workflow that sends a booking confirmation whenever a new row lands in a database.</p>
<p>Create a second workflow in n8n. Use one of these triggers:</p>
<ul>
<li><p><strong>Schedule Trigger</strong> — poll a database every minute for new rows</p>
</li>
<li><p><strong>Webhook Trigger</strong> — listen for a notification from your booking system</p>
</li>
<li><p><strong>Database Trigger</strong> (Postgres, MySQL, Supabase) — react to inserts in real time</p>
</li>
</ul>
<p>For this example, use a Schedule Trigger set to every minute, followed by a Postgres <strong>Execute Query</strong> node that reads pending confirmations:</p>
<pre><code class="language-sql">SELECT id, customer_phone, service_name, booking_time
FROM bookings
WHERE confirmation_sent = false
LIMIT 20;
</code></pre>
<p>After the Postgres node, add an HTTP Request node pointing to the same WAHA <code>sendText</code> endpoint you used earlier. The body:</p>
<pre><code class="language-json">{
  "session": "default",
  "chatId": "={{ $json.customer_phone }}@c.us",
  "text": "Hi! Your booking for {{ \(json.service_name }} on {{ \)json.booking_time }} is confirmed. Reply 'change' to reschedule."
}
</code></pre>
<p>Finally, add a second Postgres node that marks the booking as sent:</p>
<pre><code class="language-sql">UPDATE bookings
SET confirmation_sent = true, confirmation_sent_at = NOW()
WHERE id = {{ $json.id }};
</code></pre>
<p>Activate the workflow. Every minute, n8n pulls pending bookings, sends a WhatsApp confirmation, and marks them done.</p>
<p>This pattern generalizes. Replace the SQL with a call to Shopify for order confirmations, Stripe for receipt messages, or Calendly for appointment reminders. The WhatsApp layer stays the same — only the source of truth changes.</p>
<h2 id="heading-part-9-going-to-production">Part 9: Going to Production</h2>
<p>The setup above works, but it's not yet production-ready. Here's what to harden before you point real customers at it.</p>
<h3 id="heading-1-put-everything-behind-https">1. Put Everything Behind HTTPS</h3>
<p>Never expose n8n or WAHA directly on plain HTTP. Put a reverse proxy in front. Caddy is the easiest choice because it handles Let's Encrypt automatically.</p>
<p>A minimal <code>Caddyfile</code>:</p>
<pre><code class="language-plaintext">n8n.example.com {
    reverse_proxy n8n:5678
}

waha.example.com {
    reverse_proxy waha:3000
}
</code></pre>
<p>Run Caddy as another service in the same Docker Compose. TLS certificates are issued and renewed automatically.</p>
<h3 id="heading-2-rotate-the-api-keys">2. Rotate the API Keys</h3>
<p>Don't ship <code>super-secret-key-change-me</code> to production. Generate a real key:</p>
<pre><code class="language-bash">openssl rand -hex 32
</code></pre>
<p>Put it in a <code>.env</code> file, reference it as <code>${WHATSAPP_API_KEY}</code> in <code>docker-compose.yml</code>, and add <code>.env</code> to your <code>.gitignore</code>.</p>
<h3 id="heading-3-rate-limit-outbound-messages">3. Rate-limit Outbound Messages</h3>
<p>WhatsApp bans accounts that send too many messages too fast. A safe outbound rate for a fresh number is well under 20 messages per minute. For bursty replies, add an n8n Wait node between sends, or queue outgoing messages through a small custom function node that sleeps between requests.</p>
<h3 id="heading-4-scale-n8n-with-queue-mode">4. Scale n8n with Queue Mode</h3>
<p>By default, n8n runs everything in a single process. That's fine for low volume. For higher throughput, switch to Queue Mode:</p>
<ul>
<li><p>Add a Redis container.</p>
</li>
<li><p>Run one <code>n8n</code> main container (the web UI and webhook receiver).</p>
</li>
<li><p>Run one or more <code>n8n-worker</code> containers that pull jobs from the queue.</p>
</li>
</ul>
<p>Queue Mode is documented at <a href="https://docs.n8n.io/hosting/scaling/queue-mode/">docs.n8n.io/hosting/scaling/queue-mode/</a>. Setup adds two environment variables (<code>EXECUTIONS_MODE=queue</code>, <code>QUEUE_BULL_REDIS_HOST=redis</code>) and decouples incoming webhooks from workflow execution. The webhook responds in milliseconds while workers chew through the queue in the background.</p>
<h3 id="heading-5-monitor-the-session">5. Monitor the Session</h3>
<p>WhatsApp Web sessions drop. The phone loses connection, WhatsApp rotates security tokens, or your server reboots. Catch those drops early.</p>
<p>Subscribe to the <code>session.status</code> webhook event in WAHA. When status becomes <code>FAILED</code> or <code>STOPPED</code>, route it to an n8n workflow that posts to Slack, sends an email, or pages you. The faster you know, the faster you recover.</p>
<p>For overall uptime, point something like Uptime Kuma at <code>GET /api/sessions/default</code> on WAHA. If WAHA reports <code>WORKING</code>, you're fine. Anything else triggers an alert.</p>
<h3 id="heading-6-back-up-the-sessions-volume">6. Back Up the Sessions Volume</h3>
<p>The <code>waha-sessions</code> directory contains the logged-in state. If you lose it, you have to scan the QR code again — possibly from a phone that's no longer handy. Back it up nightly. A simple cron job with <code>tar</code> and <code>rclone</code> to S3-compatible storage is plenty.</p>
<h3 id="heading-7-add-a-live-agent-handoff">7. Add a Live-Agent Handoff</h3>
<p>Not every conversation should stay with the bot. When a user types <code>human</code> — or when your intent classifier can't answer confidently — hand off to a real agent.</p>
<p>Chatwoot is a solid open-source option: it has a dedicated WhatsApp channel, agent inbox, team assignment, and conversation history. The handoff is an n8n branch that stops processing bot replies and forwards the message stream to Chatwoot's API.</p>
<h2 id="heading-common-pitfalls">Common Pitfalls</h2>
<p>A few issues catch almost everyone on their first production deploy.</p>
<h3 id="heading-webhooks-timing-out">Webhooks Timing Out</h3>
<p>WAHA gives your webhook a few seconds to respond. If your n8n workflow is slow (calling an LLM, hitting a remote API), the webhook times out and WAHA retries, potentially causing duplicate replies.</p>
<p>Fix: make the webhook return <code>200</code> immediately and offload the slow work. In n8n, set the Webhook node's Response Mode to <em>Using Respond to Webhook Node</em>, add a Respond to Webhook node as the first step with a <code>200</code> and empty body, then do the heavy lifting after that.</p>
<h3 id="heading-duplicate-messages">Duplicate Messages</h3>
<p>WAHA delivers the same <code>message</code> event more than once in edge cases (phone comes back online, session reconnects). Store the <code>payload.id</code> somewhere — Redis, a database, or n8n's static data store — and drop any ID you've already processed.</p>
<h3 id="heading-messages-arriving-out-of-order">Messages Arriving Out of Order</h3>
<p>The webhook is async, and n8n may parallelize executions. If ordering matters — for example, in a multi-step conversation — key a queue by the sender's <code>chatId</code> and process each sender serially.</p>
<h3 id="heading-sessions-disconnecting-after-a-phone-restart">Sessions Disconnecting After a Phone Restart</h3>
<p>Normal WhatsApp Web behavior. WAHA auto-reconnects, but occasionally the linked-devices list needs a manual refresh. If a session refuses to come back, stop the WAHA container, delete that session's folder under <code>waha-sessions/</code>, start the container again, and rescan the QR.</p>
<h3 id="heading-your-number-gets-banned">Your Number Gets Banned</h3>
<p>The single biggest cause is rate: a new number blasting hundreds of messages an hour gets flagged fast. Warm up a number slowly — send a normal, human-like volume for the first week. Don't send to strangers unsolicited. Prefer inbound-driven replies over outbound pushes wherever you can.</p>
<h3 id="heading-the-wrong-chat-id-format">The Wrong Chat ID Format</h3>
<p>WhatsApp individual chats use <code>&lt;number&gt;@c.us</code> and groups use <code>&lt;groupId&gt;@g.us</code>. Don't include the <code>+</code> or spaces in the number. If WAHA returns a 404 when sending, the chat ID is almost always the problem.</p>
<h2 id="heading-where-to-go-next">Where to Go Next</h2>
<p>You now have the foundation. The same two-service stack supports almost any bot you can imagine — you're only limited by what you can build in an n8n workflow.</p>
<p>Some natural next steps:</p>
<ul>
<li><p><strong>Plug in AI replies:</strong> Add an OpenAI or Anthropic node after the Webhook, pass the user's message through it with a short system prompt, and send the response back through WAHA. Cap conversation length to prevent runaway token usage.</p>
</li>
<li><p><strong>Integrate a CRM:</strong> Look up the caller's <code>chatId</code> in HubSpot, Pipedrive, or your own database before deciding how to reply. Segment responses by customer tier.</p>
</li>
<li><p><strong>Send proactive notifications:</strong> Appointment reminders, shipping updates, payment receipts, abandoned-cart nudges. Keep the content transactional and expected — unsolicited marketing blasts are the fastest way to a ban.</p>
</li>
<li><p><strong>Log every conversation:</strong> Add a Postgres or Supabase node after the Webhook to persist messages for analytics and customer history. Your future self (and your support team) will thank you.</p>
</li>
<li><p><strong>Add media handling:</strong> WAHA exposes <code>sendImage</code>, <code>sendFile</code>, and <code>sendVoice</code> endpoints. Teach the bot to accept photos for support tickets, or send invoices as PDFs directly inside the chat.</p>
</li>
</ul>
<p>The WhatsApp layer stays the same. Everything interesting happens upstream in the workflow.</p>
<p><em>If you want to see production examples of n8n and WAHA running at scale — or you need a similar automation built for your business — I'm the founder of Achiya Automation, where we ship WhatsApp, n8n, and Chatwoot integrations. You can find more at</em> <a href="https://achiya-automation.com"><em>achiya-automation.com</em></a><em>.</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Reclaim Your Time – Master Automation with Zapier ]]>
                </title>
                <description>
                    <![CDATA[ Do you ever spend a lot of time doing small repetitive tasks like copying data from an email into a spreadsheet or manually moving files between folders. We just posted a new course on the freeCodeCam ]]>
                </description>
                <link>https://www.freecodecamp.org/news/reclaim-your-time-master-automation-with-zapier/</link>
                <guid isPermaLink="false">69e79069e4367278145b1128</guid>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ youtube ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Beau Carnes ]]>
                </dc:creator>
                <pubDate>Tue, 21 Apr 2026 14:57:45 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5f68e7df6dfc523d0a894e7c/80b2d07a-dc5c-4b73-a50c-5c4b5c462a74.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Do you ever spend a lot of time doing small repetitive tasks like copying data from an email into a spreadsheet or manually moving files between folders.</p>
<p>We just posted a new course on the <a href="http://freeCodeCamp.org">freeCodeCamp.org</a> YouTube channel, led by instructor and developer Estafania, that will help you leverage the power of automation to help with all your tasks.</p>
<p>Zapier is a no-code platform that allows you to connect and share information between the applications you use every day. The core philosophy is simple: "If this happens, then do that".</p>
<ul>
<li><p><strong>The Trigger:</strong> This is the "If this happens" part. It's a specific event in one app (like receiving a new lead in a form).</p>
</li>
<li><p><strong>The Action:</strong> This is the "do that" part. This is the task Zapier performs automatically in another app (like sending a Slack notification or adding a row to a Google Sheet).</p>
</li>
</ul>
<p>This four-hour course takes you from a complete beginner to an advanced user. You will start by setting up a free account and learning the basic building blocks of a "Zap". As you progress, you will dive into modern, AI-enhanced features.</p>
<p>And for people looking to bridge the gap between AI and development, the course concludes with a deep dive into Model Context Protocol (MCP). You will learn how to set up an MCP server to share information from your apps with AI clients like Visual Studio Code and the Gemini CLI. This allows you to interact with your Google Calendar or GitHub repositories directly through an AI interface.</p>
<p>Watch the full course on <a href="https://youtu.be/-leIp449qXA">the freeCodeCamp.org YouTube channel</a> (4-hour watch).</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/-leIp449qXA" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Local SEO Audit Agent with Browser Use and Claude API ]]>
                </title>
                <description>
                    <![CDATA[ Every digital marketing agency has someone whose job involves opening a spreadsheet, visiting each client URL, checking the title tag, meta description, and H1, noting broken links, and pasting everyt ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-local-seo-audit-agent-with-browser-use-and-claude-api/</link>
                <guid isPermaLink="false">69cb09249fffa747409f133f</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python 3 ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ claude.ai ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Daniel Nwaneri ]]>
                </dc:creator>
                <pubDate>Mon, 30 Mar 2026 23:37:08 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/98f8eb73-bfe2-4990-b41a-1997a35134f2.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every digital marketing agency has someone whose job involves opening a spreadsheet, visiting each client URL, checking the title tag, meta description, and H1, noting broken links, and pasting everything into a report. Then doing it again next week.</p>
<p>That work is deterministic. An agent can do it.</p>
<p>In this tutorial, you'll build a local SEO audit agent from scratch using Python, Browser Use, and the Claude API. The agent visits real pages in a visible browser window, extracts SEO signals using Claude, checks for broken links asynchronously, handles edge cases with a human-in-the-loop pause, and writes a structured report — all resumable if interrupted.</p>
<p>By the end, you'll have a working agent you can run against any list of URLs. It costs less than $0.01 per URL to run.</p>
<h2 id="heading-what-youll-build">What You'll Build</h2>
<p>A seven-module Python agent that:</p>
<ul>
<li><p>Reads a URL list from a CSV file</p>
</li>
<li><p>Visits each URL in a real Chromium browser (not a headless scraper)</p>
</li>
<li><p>Extracts title, meta description, H1s, and canonical tag via Claude API</p>
</li>
<li><p>Checks for broken links asynchronously using httpx</p>
</li>
<li><p>Detects edge cases (404s, login walls, redirects) and pauses for human input</p>
</li>
<li><p>Writes results to <code>report.json</code> incrementally — safe to interrupt and resume</p>
</li>
<li><p>Generates a plain-English <code>report-summary.txt</code> on completion</p>
</li>
</ul>
<p>The full code is on GitHub at <a href="https://github.com/dannwaneri/seo-agent">dannwaneri/seo-agent</a>.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Python 3.11 or higher</p>
</li>
<li><p>An Anthropic API key (get one at console.anthropic.com)</p>
</li>
<li><p>Windows, macOS, or Linux</p>
</li>
<li><p>Basic familiarity with Python and the command line</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-why-browser-use-instead-of-a-scraper">Why Browser Use Instead of a Scraper</a></p>
</li>
<li><p><a href="#heading-project-structure">Project Structure</a></p>
</li>
<li><p><a href="#heading-setup">Setup</a></p>
</li>
<li><p><a href="#heading-module-1-state-management">Module 1: State Management</a></p>
</li>
<li><p><a href="#heading-module-2-browser-integration">Module 2: Browser Integration</a></p>
</li>
<li><p><a href="#heading-module-3-claude-extraction-layer">Module 3: Claude Extraction Layer</a></p>
</li>
<li><p><a href="#heading-module-4-broken-link-checker">Module 4: Broken Link Checker</a></p>
</li>
<li><p><a href="#heading-module-5-human-in-the-loop">Module 5: Human-in-the-Loop</a></p>
</li>
<li><p><a href="#heading-module-6-report-writer">Module 6: Report Writer</a></p>
</li>
<li><p><a href="#heading-module-7-the-main-loop">Module 7: The Main Loop</a></p>
</li>
<li><p><a href="#heading-running-the-agent">Running the Agent</a></p>
</li>
<li><p><a href="#heading-scheduling-for-agency-use">Scheduling for Agency Use</a></p>
</li>
<li><p><a href="#heading-what-the-results-look-like">What the Results Look Like</a></p>
</li>
</ol>
<h2 id="heading-why-browser-use-instead-of-a-scraper">Why Browser Use Instead of a Scraper</h2>
<p>The standard approach to SEO auditing is to fetch page HTML with <code>requests</code> and parse it with BeautifulSoup. That works on static pages. It breaks on JavaScript-rendered content, misses dynamically injected meta tags, and fails entirely on authenticated pages.</p>
<p>Browser Use (84,000+ GitHub stars, MIT license) takes a different approach. It controls a real Chromium browser, reads the DOM after JavaScript executes, and exposes the page through Playwright's accessibility tree. The agent sees what a human would see.</p>
<p>The practical difference: a requests-based scraper might miss a meta description injected by a React component. Browser Use won't.</p>
<p>The other difference worth naming: Browser Use reads pages semantically. A Playwright script breaks when a button's CSS class changes from <code>btn-primary</code> to <code>button-main</code>. Browser Use identifies it's still a "Submit" button and acts accordingly. The extraction logic lives in the Claude prompt, not in brittle CSS selectors.</p>
<h2 id="heading-project-structure">Project Structure</h2>
<pre><code class="language-plaintext">seo-agent/
├── index.py          # Main audit loop
├── browser.py        # Browser Use / Playwright page driver
├── extractor.py      # Claude API extraction layer
├── linkchecker.py    # Async broken link checker
├── hitl.py           # Human-in-the-loop pause logic
├── reporter.py       # Report writer
├── state.py          # State persistence (resume on interrupt)
├── input.csv         # Your URL list
├── requirements.txt
├── .env.example
└── .gitignore
</code></pre>
<h2 id="heading-setup">Setup</h2>
<p>Create a project folder and install dependencies:</p>
<pre><code class="language-bash">mkdir seo-agent &amp;&amp; cd seo-agent
pip install browser-use anthropic playwright httpx
playwright install chromium
</code></pre>
<p>Create <code>input.csv</code> with your URLs:</p>
<pre><code class="language-plaintext">url
https://example.com
https://example.com/about
https://example.com/contact
</code></pre>
<p>Create <code>.env.example</code>:</p>
<pre><code class="language-plaintext">ANTHROPIC_API_KEY=your-key-here
</code></pre>
<p>Set your API key as an environment variable before running:</p>
<pre><code class="language-bash"># macOS/Linux
export ANTHROPIC_API_KEY="sk-ant-..."

# Windows PowerShell
$env:ANTHROPIC_API_KEY = "sk-ant-..."
</code></pre>
<p>Create <code>.gitignore</code>:</p>
<pre><code class="language-plaintext">state.json
report.json
report-summary.txt
.env
__pycache__/
*.pyc
</code></pre>
<h2 id="heading-module-1-state-management">Module 1: State Management</h2>
<p>The agent needs to track which URLs it has already audited. If the run is interrupted — power cut, keyboard interrupt, network error — it should resume from where it stopped, not start over.</p>
<p><code>state.py</code> handles this with a flat JSON file:</p>
<pre><code class="language-python">import json
import os

STATE_FILE = os.path.join(os.path.dirname(__file__), "state.json")

_DEFAULT_STATE = {"audited": [], "pending": [], "needs_human": []}


def load_state() -&gt; dict:
    if not os.path.exists(STATE_FILE):
        save_state(_DEFAULT_STATE.copy())
    with open(STATE_FILE, encoding="utf-8") as f:
        return json.load(f)


def save_state(state: dict) -&gt; None:
    with open(STATE_FILE, "w", encoding="utf-8") as f:
        json.dump(state, f, indent=2)


def is_audited(url: str) -&gt; bool:
    return url in load_state()["audited"]


def mark_audited(url: str) -&gt; None:
    state = load_state()
    if url not in state["audited"]:
        state["audited"].append(url)
    save_state(state)


def add_to_needs_human(url: str) -&gt; None:
    state = load_state()
    if url not in state["needs_human"]:
        state["needs_human"].append(url)
    save_state(state)
</code></pre>
<p>The design is intentional: <code>mark_audited()</code> is called immediately after a URL is processed and written to the report. If the agent crashes mid-run, it loses at most one URL's work.</p>
<h2 id="heading-module-2-browser-integration">Module 2: Browser Integration</h2>
<p><code>browser.py</code> does the actual page navigation. It uses Playwright directly (which Browser Use installs as a dependency) to open a visible Chromium window, navigate to the URL, capture HTTP status and redirect information, and extract the raw SEO signals from the DOM.</p>
<p>The key design decisions:</p>
<p><strong>Visible browser, not headless.</strong> Set <code>headless=False</code> so you can watch the agent work. This matters for the demo and for debugging.</p>
<p><strong>Status capture via response listener.</strong> Playwright raises an exception on 4xx/5xx responses, but the <code>on("response", ...)</code> handler fires before the exception. We capture status there.</p>
<p><strong>2-second delay between visits.</strong> Prevents triggering rate limiting or bot detection on agency client sites.</p>
<p>Here is the core navigation function:</p>
<pre><code class="language-python">import asyncio
import sys
import time
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout

TIMEOUT = 20_000  # 20 seconds


def fetch_page(url: str) -&gt; dict:
    result = {
        "final_url": url,
        "status_code": None,
        "title": None,
        "meta_description": None,
        "h1s": [],
        "canonical": None,
        "raw_links": [],
    }

    first_status = {"code": None}

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=False)
        page = browser.new_page()

        def on_response(response):
            if first_status["code"] is None:
                first_status["code"] = response.status

        page.on("response", on_response)

        try:
            page.goto(url, wait_until="domcontentloaded", timeout=TIMEOUT)
            result["status_code"] = first_status["code"] or 200
            result["final_url"] = page.url

            # Extract SEO signals from DOM
            result["title"] = page.title() or None
            result["meta_description"] = page.evaluate(
                "() =&gt; { const m = document.querySelector('meta[name=\"description\"]'); "
                "return m ? m.getAttribute('content') : null; }"
            )
            result["h1s"] = page.evaluate(
                "() =&gt; Array.from(document.querySelectorAll('h1')).map(h =&gt; h.innerText.trim())"
            )
            result["canonical"] = page.evaluate(
                "() =&gt; { const c = document.querySelector('link[rel=\"canonical\"]'); "
                "return c ? c.getAttribute('href') : null; }"
            )
            result["raw_links"] = page.evaluate(
                "() =&gt; Array.from(document.querySelectorAll('a[href]'))"
                ".map(a =&gt; a.href).filter(Boolean).slice(0, 100)"
            )

        except PlaywrightTimeout:
            result["status_code"] = first_status["code"] or 408
        except Exception as exc:
            print(f"[browser] Error: {exc}", file=sys.stderr)
            result["status_code"] = first_status["code"]
        finally:
            browser.close()

    time.sleep(2)
    return result
</code></pre>
<p>A few things worth noting:</p>
<p>The <code>raw_links</code> cap at 100 is deliberate. DEV.to profile pages have hundreds of links — you don't need all of them for broken link detection.</p>
<p>The <code>wait_until="domcontentloaded"</code> setting is faster than <code>networkidle</code> and sufficient for meta tag extraction. JavaScript-rendered content needs the DOM to be ready, not all network requests to complete.</p>
<h2 id="heading-module-3-claude-extraction-layer">Module 3: Claude Extraction Layer</h2>
<p><code>extractor.py</code> takes the raw page snapshot from <code>browser.py</code> and calls Claude to produce a structured SEO audit result.</p>
<p>This is where most tutorials go wrong. They either write complex parsing logic in Python (fragile) or ask Claude for a free-form response and try to parse prose (unreliable). The right approach: give Claude a strict JSON schema and tell it to return nothing else.</p>
<p><strong>The prompt engineering that makes this reliable:</strong></p>
<pre><code class="language-python">import json
import os
import sys
from datetime import datetime, timezone
import anthropic

MODEL = "claude-sonnet-4-20250514"
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))


def _strip_fences(text: str) -&gt; str:
    """Remove accidental markdown code fences from Claude's response."""
    text = text.strip()
    if text.startswith("```"):
        lines = text.splitlines()
        # Drop opening fence
        lines = lines[1:] if lines[0].startswith("```") else lines
        # Drop closing fence
        if lines and lines[-1].strip() == "```":
            lines = lines[:-1]
        text = "\n".join(lines).strip()
    return text


def extract(snapshot: dict) -&gt; dict:
    if not os.environ.get("ANTHROPIC_API_KEY"):
        raise OSError("ANTHROPIC_API_KEY is not set.")

    prompt = f"""You are an SEO auditor. Analyze this page snapshot and return ONLY a JSON object.
No prose. No explanation. No markdown fences. Raw JSON only.

Page data:
- URL: {snapshot.get('final_url')}
- Status code: {snapshot.get('status_code')}
- Title: {snapshot.get('title')}
- Meta description: {snapshot.get('meta_description')}
- H1 tags: {snapshot.get('h1s')}
- Canonical: {snapshot.get('canonical')}

Return this exact schema:
{{
  "url": "string",
  "final_url": "string",
  "status_code": number,
  "title": {{"value": "string or null", "length": number, "status": "PASS or FAIL"}},
  "description": {{"value": "string or null", "length": number, "status": "PASS or FAIL"}},
  "h1": {{"count": number, "value": "string or null", "status": "PASS or FAIL"}},
  "canonical": {{"value": "string or null", "status": "PASS or FAIL"}},
  "flags": ["array of strings describing specific issues"],
  "human_review": false,
  "audited_at": "ISO timestamp"
}}

PASS/FAIL rules:
- title: FAIL if null or length &gt; 60 characters
- description: FAIL if null or length &gt; 160 characters  
- h1: FAIL if count is 0 (missing) or count &gt; 1 (multiple)
- canonical: FAIL if null
- flags: list every failing field with a clear description
- audited_at: use current UTC time in ISO 8601 format"""

    response = client.messages.create(
        model=MODEL,
        max_tokens=1000,
        messages=[{"role": "user", "content": prompt}],
    )

    raw = response.content[0].text
    clean = _strip_fences(raw)

    try:
        return json.loads(clean)
    except json.JSONDecodeError as exc:
        print(f"[extractor] JSON parse error: {exc}", file=sys.stderr)
        return _error_result(snapshot, str(exc))


def _error_result(snapshot: dict, reason: str) -&gt; dict:
    return {
        "url": snapshot.get("final_url", ""),
        "final_url": snapshot.get("final_url", ""),
        "status_code": snapshot.get("status_code"),
        "title": {"value": None, "length": 0, "status": "ERROR"},
        "description": {"value": None, "length": 0, "status": "ERROR"},
        "h1": {"count": 0, "value": None, "status": "ERROR"},
        "canonical": {"value": None, "status": "ERROR"},
        "flags": [f"Extraction error: {reason}"],
        "human_review": True,
        "audited_at": datetime.now(timezone.utc).isoformat(),
    }
</code></pre>
<p>Two things make this reliable in production:</p>
<p>First, <code>_strip_fences()</code> handles the case where Claude wraps its response in <code>```json</code> fences despite being told not to. This happens occasionally with Sonnet and consistently breaks <code>json.loads()</code> if you don't handle it.</p>
<p>Second, the <code>_error_result()</code> fallback means the agent never crashes on a bad Claude response — it logs the error and marks the URL for human review, then continues to the next URL.</p>
<p><strong>Cost:</strong> Claude Sonnet 4 is priced at \(3 per million input tokens and \)15 per million output tokens. A typical page snapshot is around 500 input tokens; the structured JSON response is around 300 output tokens. That works out to roughly \(0.006 per URL — about \)0.12 for a 20-URL audit.</p>
<h2 id="heading-module-4-broken-link-checker">Module 4: Broken Link Checker</h2>
<p><code>linkchecker.py</code> takes the <code>raw_links</code> list from the browser snapshot and checks same-domain links for broken status using async HEAD requests.</p>
<p>The design choices:</p>
<ul>
<li><p><strong>Same-domain only.</strong> Checking every external link on a page would take minutes and isn't what agency clients need. Filter to links on the same domain as the page being audited.</p>
</li>
<li><p><strong>HEAD requests, not GET.</strong> Faster, lower bandwidth, sufficient for status code detection.</p>
</li>
<li><p><strong>Cap at 50 links.</strong> Pages like DEV.to article listings have hundreds of internal links. Checking all of them would dominate the runtime.</p>
</li>
<li><p><strong>Concurrent requests via asyncio.</strong> All links are checked in parallel, not sequentially.</p>
</li>
</ul>
<pre><code class="language-python">import asyncio
import logging
from urllib.parse import urlparse
import httpx

CAP = 50
TIMEOUT = 5.0
logger = logging.getLogger(__name__)


def _same_domain(link: str, final_url: str) -&gt; bool:
    if not link:
        return False
    lower = link.strip().lower()
    if lower.startswith(("#", "mailto:", "javascript:", "tel:", "data:")):
        return False
    try:
        page_host = urlparse(final_url).netloc.lower()
        parsed = urlparse(link)
        return parsed.scheme in ("http", "https") and parsed.netloc.lower() == page_host
    except Exception:
        return False


async def _check_link(client: httpx.AsyncClient, url: str) -&gt; tuple[str, bool]:
    try:
        resp = await client.head(url, follow_redirects=True, timeout=TIMEOUT)
        return url, resp.status_code != 200
    except Exception:
        return url, True  # Timeout or connection error = broken


async def _run_checks(links: list[str]) -&gt; list[str]:
    async with httpx.AsyncClient() as client:
        results = await asyncio.gather(*[_check_link(client, url) for url in links])
    return [url for url, broken in results if broken]


def check_links(raw_links: list[str], final_url: str) -&gt; dict:
    same_domain = [l for l in raw_links if _same_domain(l, final_url)]

    capped = len(same_domain) &gt; CAP
    if capped:
        logger.warning("Page has %d same-domain links — capping at %d.", len(same_domain), CAP)
        same_domain = same_domain[:CAP]

    broken = asyncio.run(_run_checks(same_domain))

    return {
        "broken": broken,
        "count": len(broken),
        "status": "FAIL" if broken else "PASS",
        "capped": capped,
    }
</code></pre>
<h2 id="heading-module-5-human-in-the-loop">Module 5: Human-in-the-Loop</h2>
<p>This is the part most automation tutorials skip. What happens when the agent hits a login wall? A page that returns 403? A URL that redirects to a "Subscribe to continue reading" page?</p>
<p>Most scripts either crash or silently skip. Neither is acceptable in an agency context.</p>
<p><code>hitl.py</code> handles this with two functions: one that detects whether a pause is needed, and one that handles the pause itself.</p>
<pre><code class="language-python">from state import add_to_needs_human

LOGIN_KEYWORDS = {"login", "sign in", "sign-in", "access denied", "log in", "unauthorized"}
REDIRECT_CODES = {301, 302, 307, 308}


def should_pause(snapshot: dict) -&gt; bool:
    code = snapshot.get("status_code")

    # Navigation failed entirely
    if code is None:
        return True

    # Non-200, non-redirect
    if code != 200 and code not in REDIRECT_CODES:
        return True

    # Login wall detection
    title = (snapshot.get("title") or "").lower()
    h1s = [h.lower() for h in (snapshot.get("h1s") or [])]

    if any(kw in title for kw in LOGIN_KEYWORDS):
        return True
    if any(kw in h1 for kw in LOGIN_KEYWORDS for h1 in h1s):
        return True

    return False


def pause_reason(snapshot: dict) -&gt; str:
    code = snapshot.get("status_code")
    if code is None:
        return "Navigation failed (None status)"
    if code != 200 and code not in REDIRECT_CODES:
        return f"Unexpected status code: {code}"
    return "Possible login wall detected"


def pause_and_prompt(url: str, reason: str) -&gt; str:
    print(f"\n⚠️  HUMAN REVIEW NEEDED")
    print(f"   URL:    {url}")
    print(f"   Reason: {reason}")
    print(f"   Options: [s] skip  [r] retry  [q] quit\n")

    while True:
        choice = input("Your choice: ").strip().lower()
        if choice in ("s", "r", "q"):
            return {"s": "skip", "r": "retry", "q": "quit"}[choice]
        print("   Enter s, r, or q.")
</code></pre>
<p>The <code>should_pause()</code> function catches four cases: navigation failure, unexpected HTTP status, login keywords in the title, and login keywords in H1 tags. The login keyword check is what catches "Please sign in to continue" pages that return 200 but are effectively inaccessible.</p>
<p>In <code>--auto</code> mode (for scheduled runs), the main loop skips the <code>pause_and_prompt()</code> call and automatically handles these cases by logging the URL to <code>needs_human[]</code> in state and continuing.</p>
<h2 id="heading-module-6-report-writer">Module 6: Report Writer</h2>
<p><code>reporter.py</code> writes results incrementally. This is important: results are written after each URL is audited, not batched at the end. If the run is interrupted, you don't lose completed work.</p>
<pre><code class="language-python">import json
import os
from datetime import datetime, timezone

REPORT_JSON = os.path.join(os.path.dirname(__file__), "report.json")
REPORT_TXT = os.path.join(os.path.dirname(__file__), "report-summary.txt")


def _load_report() -&gt; list:
    if not os.path.exists(REPORT_JSON):
        return []
    with open(REPORT_JSON, encoding="utf-8") as f:
        return json.load(f)


def write_result(result: dict) -&gt; None:
    """Append or update a result in report.json."""
    entries = _load_report()
    url = result.get("url", "")

    # Update existing entry if URL already present (handles retries)
    for i, entry in enumerate(entries):
        if entry.get("url") == url:
            entries[i] = result
            break
    else:
        entries.append(result)

    with open(REPORT_JSON, "w", encoding="utf-8") as f:
        json.dump(entries, f, indent=2, ensure_ascii=False)


def _is_overall_pass(result: dict) -&gt; bool:
    fields = ["title", "description", "h1", "canonical"]
    for field in fields:
        if result.get(field, {}).get("status") not in ("PASS",):
            return False
    if result.get("broken_links", {}).get("status") == "FAIL":
        return False
    return True


def write_summary() -&gt; None:
    entries = _load_report()
    passed = sum(1 for e in entries if _is_overall_pass(e))

    lines = []
    for entry in entries:
        overall = "PASS" if _is_overall_pass(entry) else "FAIL"
        failed_fields = [
            f for f in ["title", "description", "h1", "canonical", "broken_links"]
            if entry.get(f, {}).get("status") == "FAIL"
        ]
        suffix = f" [{', '.join(failed_fields)}]" if failed_fields else ""
        lines.append(f"{entry.get('url', 'unknown'):&lt;60} | {overall}{suffix}")

    lines.append("")
    lines.append(f"{passed}/{len(entries)} URLs passed")

    with open(REPORT_TXT, "w", encoding="utf-8") as f:
        f.write("\n".join(lines))
</code></pre>
<p>The deduplication in <code>write_result()</code> handles retries cleanly. If a URL is retried after a human reviews a login wall and authenticates, the new result replaces the old one rather than creating a duplicate entry.</p>
<h2 id="heading-module-7-the-main-loop">Module 7: The Main Loop</h2>
<p><code>index.py</code> wires everything together. It reads the URL list, loads state, skips already-audited URLs, and runs the audit loop.</p>
<pre><code class="language-python">import csv
import os
import sys
import time
import argparse

from state import load_state, is_audited, mark_audited, add_to_needs_human
from browser import fetch_page
from extractor import extract
from linkchecker import check_links
from hitl import should_pause, pause_reason, pause_and_prompt
from reporter import write_result, write_summary

INPUT_CSV = os.path.join(os.path.dirname(__file__), "input.csv")


def read_urls(path: str) -&gt; list[str]:
    with open(path, newline="", encoding="utf-8") as f:
        return [row["url"].strip() for row in csv.DictReader(f) if row.get("url", "").strip()]


def run(auto: bool = False):
    if not os.environ.get("ANTHROPIC_API_KEY"):
        print("Error: ANTHROPIC_API_KEY environment variable is not set.")
        sys.exit(1)

    urls = read_urls(INPUT_CSV)
    pending = [u for u in urls if not is_audited(u)]

    print(f"Starting audit: {len(pending)} pending, {len(urls) - len(pending)} already done.\n")

    total = len(urls)

    try:
        for i, url in enumerate(pending, start=1):
            position = urls.index(url) + 1
            print(f"[{position}/{total}] {url}", end=" -&gt; ", flush=True)

            # Browser navigation
            snapshot = fetch_page(url)

            # Human-in-the-loop check
            if should_pause(snapshot):
                reason = pause_reason(snapshot)

                if auto:
                    print(f"AUTO-SKIPPED ({reason})")
                    add_to_needs_human(url)
                    mark_audited(url)
                    continue

                action = pause_and_prompt(url, reason)
                if action == "quit":
                    print("Exiting.")
                    break
                elif action == "skip":
                    add_to_needs_human(url)
                    mark_audited(url)
                    continue
                # "retry" falls through to re-fetch below
                snapshot = fetch_page(url)

            # Claude extraction
            result = extract(snapshot)

            # Broken link check
            links = check_links(snapshot.get("raw_links", []), snapshot.get("final_url", url))
            result["broken_links"] = links

            # Write result immediately
            write_result(result)
            mark_audited(url)

            overall = "PASS" if all(
                result.get(f, {}).get("status") == "PASS"
                for f in ["title", "description", "h1", "canonical"]
            ) and links["status"] == "PASS" else "FAIL"

            print(overall)

    except KeyboardInterrupt:
        print("\n\nInterrupted. Progress saved. Re-run to continue.")
        return

    write_summary()
    passed = sum(
        1 for e in [r for r in []]
        if all(e.get(f, {}).get("status") == "PASS" for f in ["title", "description", "h1", "canonical"])
    )
    print(f"\nAudit complete. Report saved to report.json and report-summary.txt")


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--auto", action="store_true", help="Auto-skip URLs requiring human review")
    args = parser.parse_args()
    run(auto=args.auto)
</code></pre>
<p>The <code>KeyboardInterrupt</code> handler is the resume mechanism. When you press Ctrl+C, the handler prints a message and exits cleanly. Because <code>mark_audited()</code> is called after <code>write_result()</code> for each URL, the next run skips everything already processed.</p>
<h2 id="heading-running-the-agent">Running the Agent</h2>
<p>Interactive mode (pauses on edge cases):</p>
<pre><code class="language-bash">python index.py
</code></pre>
<p>Auto mode (skips edge cases, adds to <code>needs_human[]</code>):</p>
<pre><code class="language-bash">python index.py --auto
</code></pre>
<p>When it runs, you'll see the browser window open for each URL and the terminal print progress:</p>
<pre><code class="language-plaintext">Starting audit: 7 pending, 0 already done.

[1/7] https://example.com -&gt; PASS
[2/7] https://example.com/about -&gt; FAIL
[3/7] https://example.com/contact -&gt; AUTO-SKIPPED (Unexpected status code: 404)
...
Audit complete. Report saved to report.json and report-summary.txt
</code></pre>
<p>To resume after an interruption:</p>
<pre><code class="language-bash">python index.py --auto
# Starting audit: 4 pending, 3 already done.
</code></pre>
<h2 id="heading-scheduling-for-agency-use">Scheduling for Agency Use</h2>
<p>For recurring weekly audits, create a batch file and schedule it with Windows Task Scheduler.</p>
<p>Create <code>run-audit.bat</code>:</p>
<pre><code class="language-batch">@echo off
set ANTHROPIC_API_KEY=your-key-here
cd /d C:\Users\yourname\Desktop\seo-agent
python index.py --auto
</code></pre>
<p>In Windows Task Scheduler:</p>
<ol>
<li><p>Create a new Basic Task</p>
</li>
<li><p>Set the trigger to Weekly, Monday at 7:00 AM</p>
</li>
<li><p>Set the action to "Start a program"</p>
</li>
<li><p>Browse to your <code>run-audit.bat</code> file</p>
</li>
</ol>
<p>Check <code>report-summary.txt</code> on Monday morning. URLs in <code>needs_human[]</code> in <code>state.json</code> need manual review — login walls, paywalls, or pages that returned unexpected status codes.</p>
<p>For macOS/Linux, use cron:</p>
<pre><code class="language-bash"># Run every Monday at 7am
0 7 * * 1 cd /path/to/seo-agent &amp;&amp; ANTHROPIC_API_KEY=your-key python index.py --auto
</code></pre>
<h2 id="heading-what-the-results-look-like">What the Results Look Like</h2>
<p>I ran this agent against seven of my own published pages across Hashnode, freeCodeCamp, and DEV.to. Every single one failed.</p>
<pre><code class="language-plaintext">https://hashnode.com/@dannwaneri                    | FAIL [h1]
https://freecodecamp.org/news/claude-code-skill     | FAIL [description]
https://freecodecamp.org/news/stop-letting-ai-guess | FAIL [description]
https://freecodecamp.org/news/rag-system-handbook   | FAIL [title, description]
https://freecodecamp.org/news/author/dannwaneri     | FAIL [description]
https://dev.to/dannwaneri/gatekeeping-panic         | FAIL [title]
https://dev.to/dannwaneri/production-rag-system     | FAIL [title]

0/7 URLs passed
</code></pre>
<p>The freeCodeCamp description issues are partly platform-level — freeCodeCamp's template sometimes truncates or omits meta descriptions for article listing pages. The DEV.to title issues are mine. Article titles that work as headlines often exceed 60 characters in the <code>&lt;title&gt;</code> tag.</p>
<p>A note on the 60-character title rule: this is a display threshold, not a ranking penalty. Google indexes titles of any length. The 60-character guideline reflects approximately how many characters fit in a desktop SERP result before truncation. Titles over 60 characters often still rank — they just get cut off in search results, which can hurt click-through rate. The agent flags display risk, not a ranking violation.</p>
<h2 id="heading-next-steps">Next Steps</h2>
<p>The agent as built handles the core SEO audit workflow. Obvious extensions:</p>
<ul>
<li><p><strong>Performance metrics</strong> — add a Lighthouse or PageSpeed Insights API call per URL</p>
</li>
<li><p><strong>Structured data validation</strong> — check for JSON-LD schema markup and validate it</p>
</li>
<li><p><strong>Email delivery</strong> — send <code>report-summary.txt</code> via SMTP after the run completes</p>
</li>
<li><p><strong>Multi-client support</strong> — separate <code>input.csv</code> files per client, separate report directories</p>
</li>
</ul>
<p>The full code including all seven modules is at <a href="https://github.com/dannwaneri/seo-agent">dannwaneri/seo-agent</a>. Clone it, add your URLs, and run it.</p>
<p><em>If you found this useful, I write about practical AI agent setups for developers and agencies at</em> <a href="https://dev.to/dannwaneri"><em>DEV.to/@dannwaneri</em></a><em>. The DEV.to companion piece covers the design decisions behind the agent — why HITL matters, why Browser Use over scrapers, and what the audit results mean for your own published content.</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Find Any File on Windows Like a Linux User (using Windows Powershell) ]]>
                </title>
                <description>
                    <![CDATA[ Sometimes you might struggle to find a file or program when you have no idea where it could be saved or installed. And the Windows user interface may not always give you the results you want. If that' ]]>
                </description>
                <link>https://www.freecodecamp.org/news/find-any-file-on-windows-like-a-linux-user/</link>
                <guid isPermaLink="false">69c44ce410e664c5daef3e59</guid>
                
                    <category>
                        <![CDATA[ Windows ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Powershell ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Scripting ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ performance ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Piotr &quot;NotBlackMagic&quot; Opoka ]]>
                </dc:creator>
                <pubDate>Wed, 25 Mar 2026 16:00:00 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/527c9267-0583-49c4-9e90-89abcf186b9d.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Sometimes you might struggle to find a file or program when you have no idea where it could be saved or installed. And the Windows user interface may not always give you the results you want. If that's the case for you, you're in the right place.</p>
<p><code>Get-ChildItem</code> (also known as <code>gci</code>, <code>ls</code>, <code>dir</code> ) is a very powerful command. And one of its most iconic uses is to find/search for a file. It's more precise and more reliable than Windows Explorer. It even has better filtering options that show the results that are more relevant to you.</p>
<p>In this tutorial, you'll learn how to use <code>gci</code> and how to combine it with other commands so that it becomes an even more powerful tool. Remember to enable copy-pasting in Windows PowerShell, so it's easier for you to follow along. You can see how to enable it <a href="https://notblackmagic.hashnode.dev/enable-copy-pasting-in-windows-powershell-cli-in-3-steps">here</a>.</p>
<h3 id="heading-what-well-cover">What we'll cover:</h3>
<ol>
<li><p><a href="#heading-1-basic-explanation-of-the-get-childitem-command">Basic explanation of the Get-ChildItem command</a></p>
<ul>
<li><a href="#heading-most-used-examples-of-searching-by-gci-command">Most used examples of searching by gci command</a></li>
</ul>
</li>
<li><p><a href="#heading-2-setup-for-other-more-complex-examples">Setup for other more complex examples</a></p>
</li>
<li><p><a href="#heading-3-when-is-the-path-option-not-needed">When is the -Path option not needed?</a></p>
</li>
<li><p><a href="#heading-4-advanced-searching-combining-getchildren-with-the-whereobject-command">Advanced Searching – Combining Get-ChildItem with the Where-Object Command</a></p>
<ul>
<li><p><a href="#heading-41-how-to-search-through-only-a-particular-directory">4.1. How to search through only a particular directory</a></p>
</li>
<li><p><a href="#heading-42-how-to-search-while-excluding-a-particular-directory">4.2. How to search while excluding a particular directory</a></p>
</li>
<li><p><a href="#heading-43-searching-only-1-directory-from-many-with-exactly-the-same-name">4.3 Searching only 1 directory from many with exactly the same name</a></p>
</li>
<li><p><a href="#heading-44-filter-how-deep-how-many-folders-in-you-want-to-search-for-the-file">4.4 Filter how deep (how many folders in) you want to search for the file</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-5-how-to-search-through-hidden-files">How to Search Through Hidden Files</a></p>
</li>
<li><p><a href="#heading-6-how-can-you-know-all-the-properties-that-you-can-use-as-a-filter">How can you know all the properties that you can use as a filter?</a></p>
<ul>
<li><a href="#heading-how-to-retrieve-only-1-desired-property">How to retrieve only 1 desired property</a></li>
</ul>
</li>
<li><p><a href="#heading-7-i-dont-know-the-files-name-but-i-know-whats-inside-it-how-do-i-find-the-file-by-its-content">I don't know the file’s name, but I know what's inside it. How do I find the file by its content?</a></p>
</li>
<li><p><a href="#heading-8-i-cant-see-the-full-path-how-do-i-fix-this">I can't see the full path - how do I fix this?</a></p>
</li>
<li><p><a href="#heading-9-hard-to-read-open-the-results-in-the-text-editor-of-your-choice">Hard to read? Open the results in the text editor of your choice</a></p>
</li>
<li><p><a href="#heading-10-summary-the-ultimate-commands-for-searching-and-finding-whatever-you-need">Summary - the ultimate commands for searching and finding whatever you need</a></p>
</li>
</ol>
<h2 id="heading-1-basic-explanation-of-the-get-childitem-command">1. Basic Explanation of the <code>Get-ChildItem</code> Command</h2>
<p>Let's take a look at the example searching script to understand how it works:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Path "C:\path to\your directory\" -Filter "*whatImLookingFor*"
</code></pre>
<p><code>Get-ChildItem</code> (aliases: <code>dir</code>, <code>ls</code>, <code>gci</code>) lists the content of a folder or directory just like the Linux <code>ls</code> command does.</p>
<p>This command works by searching every single file and directory <strong>in the path specified.</strong> It shows you everything it found that <strong>matches the filter</strong>. It doesn't mean that this command doesn't look everywhere else – because it does.</p>
<p>So you specify the path that is the parent (folder), which means that every folder and file under it is its child. If you know some CSS and JavaScript, treat it the same way that these languages do.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/0bd18776-72bc-46be-bbaa-b616c5ce1c3a.png" alt="Picture: a visual explanation of -depth and -recurse parameters. It shows &quot;Documents&quot; folder at the bottom, which is tagged both as Parent and Depth 0. It points upwords to its child folders and a child file. Those are tagged as Depth 0 children of our Documents folder. They are simultaneously tagged as Depth 1 parents, so files and folders. to which they are pointing upwards, are their Depth 1 children." style="display:block;margin:0 auto" width="821" height="656" loading="lazy">

<p>If you don't use <code>-Recurse</code> or <code>-Depth</code>, then the command works only in your current directory (parent Depth level 0) and searches for its children inside that directory (children Depth level 0).</p>
<p>If you use <code>-Recurse</code>, then the <code>gci</code> will search for what you want on ALL LEVELS. But by using<code>-Depth</code>, you can specify how deep you want it to look for a file/folder.</p>
<p>To recurse means "to repeat an operation". So, <code>-Recurse</code> means that <code>gci</code> will repeat the search for your file or folder in every child element of the <em>"Documents"</em> directory, and every directory inside it, all levels deep.</p>
<p>All of these files and folders are children of your <em>"Documents"</em> folder. If you delete the folder, you delete everything inside it too.</p>
<p><code>-Filter</code> filters the output of the command to only show what matches the filter (examples of how to use filter are further in the article).</p>
<p><code>-Path</code> tells where the command should be looking for files (by using "C:\", for example, you're telling it to look at the very basis of your computer). If you want to search in certain directory it would look like this:</p>
<pre><code class="language-powershell">Get-ChildItem -Path "C:\path to\your directory\"
</code></pre>
<p>OR</p>
<pre><code class="language-powershell">Get-ChildItem -Path "~\Documents\path to\your directory\"
</code></pre>
<p><code>~\</code> here is a shorthand for "inside current user's folder" or <strong>"C:\Users\YourUsername"</strong>.</p>
<p>Next, we can specify whether we'd like to look for a <strong>file</strong> or a <strong>folder</strong>, so we have fewer results to look at:</p>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Recurse -Filter "*whatImLookingFor*" -File
</code></pre>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Recurse -Filter "*whatImLookingFor*" -Directory
</code></pre>
<p>You might be wondering how you can stop the search if it takes too long. When you're using <code>-Recurse,</code> the output that you'll get might become quite overwhelming, especially if you didn't specify your command enough (more about that in <a href="#heading-3-when-is-the-path-option-not-needed">step 3</a> and <a href="#heading-4-advanced-searching-combining-getchildren-with-the-whereobject-command">step 4</a>). Luckily, you can stop any command in PowerShell after starting it with <strong>Ctrl + C</strong> OR <strong>Ctrl + Z</strong> OR <strong>Ctrl + X</strong>. All of them should work.</p>
<h2 id="heading-most-used-examples-of-searching-by-gci-command">Most Used Examples of Searching by <code>gci</code> Command</h2>
<p>Here are some handy examples of searching scripts that you can use:</p>
<p><strong>Example #1</strong>: search for all executive files on your PC (remember that you can stop this command with one of shortcuts, like <strong>Ctrl + C</strong>):</p>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Recurse -Filter "*.exe" -File
</code></pre>
<p>REMEMBER:<br>In order to paste commands into the PowerShell, you have to first enable it. <a href="https://notblackmagic.hashnode.dev/enable-copy-pasting-in-windows-powershell-cli-in-3-steps">Here's how</a>.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768406611072/f23a475e-071d-42d6-b300-f442d7f926c9.png" alt="Picture: gci command pasted into PowerShell." style="display:block;margin:0 auto" width="1108" height="645" loading="lazy">

<p>This command will show you a very long list of executable files and their folders (as shown in the image below).</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768406617447/c787e9a7-b2b8-4149-84fa-1320f94c7e48.png" alt="Picture: used gci command shows all the executable files it can find." style="display:block;margin:0 auto" width="933" height="542" loading="lazy">

<p>These lists might be so long that it's impossible to find anything in them. That's why you'll learn how to use more advanced techniques of filtering in <a href="#heading-4-advanced-searching-combining-getchildren-with-the-whereobject-command">step 4</a> to see fewer unnecessary results that don't fit your criteria.</p>
<p><strong>Example #2</strong>: search for an executable file that has <em>"notepad"</em> in its name (or search for any program you need, basically):</p>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Recurse -Filter "notepad*.exe" -File
</code></pre>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768406643047/06e37fba-04d8-4806-839f-19982cd011ea.png" alt="Picture: gci command showing all executable &quot;notepad&quot; files." style="display:block;margin:0 auto" width="1100" height="568" loading="lazy">

<p>One of the results will show you the location of the file you want:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768406956329/e7582c25-7c96-4b01-aa36-8660d68a4d37.png" alt="Picture: gci command showing the path to the found executable file." style="display:block;margin:0 auto" width="560" height="106" loading="lazy">

<p>In our case it's the <code>C:\Windows\System32</code> folder.</p>
<p>You can mix it however you want! Thanks to that command, you don't have to remember much about your file and it will still work.</p>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Recurse -Filter "n*pad*.*xe"
</code></pre>
<p>So what if you see some errors while scanning the whole system. Should you worry?</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768406995588/3b23deb1-1530-4fa7-8f52-427386bc37e9.png" alt="Picture: gci command showing error messages while searching for files." style="display:block;margin:0 auto" width="823" height="287" loading="lazy">

<p>It's ok! Sometimes you might get lots of errors. They will most likely occur when a script scours the system folders/files. If you want to get rid of them, add <code>-ErrorAction SilentlyContinue</code>, like you see here:</p>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Recurse -Filter "notepad*.exe" -File -ErrorAction SilentlyContinue
</code></pre>
<p>You can try it now ;)</p>
<h2 id="heading-2-setup-for-other-more-complex-examples">2. Setup for Other More Complex Examples</h2>
<p>Now, let's look at even more use cases for this command. But first, we'll create a space where I can show you examples.</p>
<p>First, create new folder inside your <em>"Documents"</em> folder. Let's call it <em>"Items"</em>.</p>
<p>Inside it, create two text documents. Name one of them <em>"Item 1- Green Bracelet"</em> and the other <em>"Item 2- Blue Bracelet"</em> (Yes, make sure you write the first letter of each word in <strong>UPPER CASE</strong>).</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768407060476/eeb559d5-2627-4674-a780-e23f4e67f5a9.png" alt="Picture: example setup of files inside &quot;Items&quot; folder inside &quot;Documents folder&quot;." style="display:block;margin:0 auto" width="1135" height="288" loading="lazy">

<p>Copy these files now.</p>
<p>Go one folder back (you can use the <strong>Ctrl + UpArrow</strong> shortcut ) and create another folder next to <em>"Items"</em> called <em>"More items"</em>:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768407053259/06d23003-aaa5-457b-8356-af10327d3436.png" alt="Picture: example setup. New &quot;More items&quot; folder created next to the &quot;Items&quot; folder." style="display:block;margin:0 auto" width="1059" height="288" loading="lazy">

<p>Paste the copied files inside the "More items" folder and change their names, so they have only <strong>lower case</strong> letters (<em>"item 1- green bracelet"</em> and <em>"item 2- blue bracelet"</em> ).</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768407154078/314b476a-8062-4d77-82f0-0ce76cf4c39b.png" alt="Picture: example setup. All files inside &quot;More items&quot; folder have names with only lowercase letters." style="display:block;margin:0 auto" width="1071" height="287" loading="lazy">

<p>PRO TIP:<br>You can click once on a file with your mouse and then type the <strong>F2</strong> key on your keyboard in order to change their names.</p>
<h3 id="heading-3-when-is-the-path-option-not-needed">3. When is the <code>-Path</code> option not needed?</h3>
<p>You don't have to specify the path every time. You can always just move to the desired directory with the <code>cd</code> (change directory) command.</p>
<p>This command will move you to your <code>Documents</code> folder:</p>
<pre><code class="language-powershell">cd ~\Documents\
</code></pre>
<p>Now, you should be able to see PowerShell pointing to your <code>Documents</code> folder on the left of the screen:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/1cd597e0-ae5e-4ea4-b066-b573a3cc2b4b.png" alt="Picture: PowerShell pointing to the Documents folder." style="display:block;margin:0 auto" width="485" height="139" loading="lazy">

<p>If you don't see this, then you can use double quotes <code>" "</code>, like in this command:</p>
<pre><code class="language-powershell">cd "~\Documents\"
</code></pre>
<p>Make sure that PowerShell is pointing to our desired folder. Now, the searching command looks like this without the <code>-Path</code> option:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*item*" -File
</code></pre>
<p>Pretty simple, right?</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768407625727/e83b7cb3-9bdc-420b-b0ae-28fa9a5ceb42.png" alt="Picture: you can first use &quot;cd&quot; command to move to the directory you want. Then you don't have to use  &quot;Path&quot; option in your &quot;gci&quot; command." style="display:block;margin:0 auto" width="593" height="311" loading="lazy">

<p>As you can see in the image above, we first moved to our desired directory, so later we could perform the search inside it without specifying the <code>-Path</code> option/parameter.</p>
<p>But the <code>-Path</code> option is very useful, either when you're creating a script or you want to search for something without moving away from the current directory:</p>
<pre><code class="language-powershell">Get-ChildItem -Path ~\Documents\ -Recurse -Filter "*item*" -File
</code></pre>
<pre><code class="language-powershell">Get-ChildItem -Path ~\Documents\ -Recurse -Filter "*item*" -Directory
</code></pre>
<p>Here's an example. I'm inside the <code>System32</code> folder and I want to know whether the thing I'm looking for is inside the <code>Documents</code> folder without moving in there:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768407360312/60915425-8432-40c3-a395-a59e1b363667.png" alt="Picture: &quot;gci&quot; command can looks for a file in a specific directory without moving us to this directory. All thanks to &quot;Path&quot; option." style="display:block;margin:0 auto" width="640" height="205" loading="lazy">

<p>And it really is there!</p>
<p>From now on, because you already know what the <code>-Path</code> option is being used for, I won't be using it unless it's necessary.</p>
<h2 id="heading-4-advanced-searching-combining-get-childitem-with-the-where-object-command">4. Advanced Searching – Combining <code>Get-ChildItem</code> with the <code>Where-Object</code> Command</h2>
<p>Sometimes you might have several folders named exactly the same, but they're in different places. You might want to exclude them based on their content, which folder they are in, or based on their<code>-Depth</code> level (see the graphic with the explanation about <code>-Depth</code> level in <a href="#heading-1-basic-explanation-of-the-get-childitem-command">step 1</a>). That's what we're going to cover in the next few points.</p>
<p>For this part of the tutorial, make sure you've gone through <a href="#heading-2-setup-for-other-more-complex-examples">step 2</a> (but you can skip step 3 if you want).</p>
<h3 id="heading-41-searching-through-only-a-particular-directory">4.1. Searching through only a particular directory</h3>
<p>Let's say that we're now looking for the bracelets that we created in <strong>step 2</strong>. But, we want to see the results from only one folder. For that, we'll use case-sensitive search (<code>-clike</code>) to get only our preferred results. But <code>-clike</code> doesn't work with <code>gci</code> alone. We need to apply another filter with the <code>Where-Object { }</code> command:</p>
<pre><code class="language-powershell">Get-ChildItem -Path ~\Documents\ -Recurse -Filter "*item*" |   
Where-Object { $_.Name -clike "*Item*" }
</code></pre>
<p>OR (clearer version, without the <code>-Path</code> option):</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*item*" |   
Where-Object { $_.Name -clike "*Item*" }
</code></pre>
<p>Let's review what's going on here:</p>
<ul>
<li><p><code>Get-ChildItem -Recurse -Filter "*item*"</code> searches for all files and folders with "item" in their name</p>
</li>
<li><p><code>|</code> – the "pipe" symbol is used to get the output of the previous command (the list of all files and folders filtered by <code>gci</code>) and send it to the next command (<code>Where-Object</code> is applying another filter to what is already filtered by <code>gci</code>).</p>
</li>
<li><p><code>Where-Object { }</code> is the command used for filtering the lists of objects. The filter is being specified inside the <code>{ }</code> curly brackets.</p>
</li>
<li><p><code>\(_</code> refers to all the separate objects. Treat it as <em>"ForEachObjectFromList".</em> And treat the whole sequence after the <code>|</code> as <em>"FindObjectsFromList that have a name with 'Item' "</em>.<br><code>\)_</code> is very often used with <code>Where-Object</code>, but also with some other commands.</p>
</li>
<li><p><code>.Name</code> – we choose a Name property to get from every object.</p>
</li>
<li><p><code>-clike</code> finds a match that is 100% correct. All letters must be the exact same case as the phrase we specified. <code>c</code> stands for "case sensitive" and it checks every letter to see if it's <strong>upper case</strong> or <strong>lower case</strong>.</p>
</li>
</ul>
<p>So, <code>Where-Object { $_.Name -clike "*Item*" }</code> is a filter that takes the <code>Name</code> parameter of every object from the list (created by <code>gci</code>) and checks with <code>-clike</code> if any <code>Name</code> has the word "Item" in it.</p>
<p>As you can see in the image below, now we'll get only the files with <strong>upper case</strong> names in our result:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768408072301/d2ffaae0-f688-40c6-9c1e-4ddd37159146.png" alt="command looking for file in specific directory case-sensitive" style="display:block;margin:0 auto" width="697" height="391" loading="lazy">

<p>IMPORTANT:<br><code>-like</code> alone means that we're looking for a certain pattern, no matter what case the letters are. The <code>c</code> in <code>-clike</code> means that we look for the thing with exactly the same capitalization of the letters (both upper and lower case, hence the <em>"c"</em>).</p>
<p>If you want to see the files <strong>without the upper case</strong> first letter, you can do that by changing "*Item*" from our current command to "*item*":</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*item*" |   
Where-Object { $_.Name -clike "*item*" }
</code></pre>
<p>Let's try it out!</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768407562860/84b82488-8ee5-4b23-ac8f-831d9c586c41.png" alt="Picture: command looking for files with only lowercase letters in their names" style="display:block;margin:0 auto" width="747" height="321" loading="lazy">

<h3 id="heading-42-how-to-search-while-excluding-a-particular-directory">4.2. How to search while excluding a particular directory</h3>
<p>In <strong>step 4.1</strong> we learned how to search only for files/folders with specific case-sensitive names in them. After applying only two changes to our previous code, we can exclude certain directories from our search.</p>
<p>Here's our starting command once again:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*item*" |   
Where-Object { $_.Name -clike "*Item*" }
</code></pre>
<h4 id="heading-change-1">Change #1</h4>
<p>In the example above, <code>-clike</code> shows only files/folders <strong>including</strong> specific phrase in their names. If we change it to <code>-cnotlike</code>, we'll <strong>exclude</strong> from the search all files/folders with that specific phrase in their name.</p>
<p>Now our code looks like this:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*item*" |   
Where-Object { $_.Name -cnotlike "*Item*" }
</code></pre>
<h4 id="heading-change-2">Change #2</h4>
<p>After the first change, <code>Where-Object { \(_.Name -cnotlike "*Item*" }</code> only excludes the names, not full paths. In order to avoid that, we need to exclude an actual path to these files. We can do that by changing <code>\)_.Name</code> to <code>$_.FullName</code>, which checks for a certain phrase in the whole path to the file <strong>and</strong> in the file's name.</p>
<p>Now, your command should look like this:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*item*" |   
Where-Object { $_.FullName -cnotlike "*Item*" }
</code></pre>
<p>We excluded the "Items" folder from our search. You should now be able to see the files only from the "More items" directory. Try it out yourself!</p>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/547801a8-a825-4607-9dbe-42e3c4af238e.png" alt="Picture: excluding part of path with FullName -cnotlike." style="display:block;margin:0 auto" width="1054" height="371" loading="lazy">

<p>What if you want to exclude the "More items" directory instead? Just change the phrase inside the filter to something like this:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*green*" -File |   
Where-Object { $_.FullName -cnotlike "*More*" }
</code></pre>
<p>We also changed the name of the file from "*item*" to "*green*" in our <code>gci</code> search (first line of code). That's why now we'll see only one bracelet in our result list:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768408276671/f4043a3f-ba14-4ff4-be60-ad5c3e321003.png" alt="command looking for file with exclusion case-sensitive" style="display:block;margin:0 auto" width="1000" height="182" loading="lazy">

<p>The <code>gci</code> command has two filters applied. First, it searches for files with phrase "green" in their names. The second filter is the "Where-Object" command, which <strong>excludes</strong> anything that has the word "More" in its path. In our case, the "More items" folder got excluded.</p>
<p>We don't even need the case-sensitive filter in our case. The command will work the same when we <strong>exclude</strong> just a <strong>lowercase</strong> word "more". So let's change <code>-cnotlike "*More*"</code> to <code>-notlike "*more*"</code> and see if it's true:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*green*" -File |   
Where-Object { $_.FullName -notlike "*more*" }
</code></pre>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768408250133/f6dac0bd-fc74-4c24-8b4b-9ba780683956.png" alt="Picture: case-sensitive search working the same in current example as a not case-sensitive search." style="display:block;margin:0 auto" width="836" height="188" loading="lazy">

<p>As you can see, the result is the same! Despite different cases of the letters, we still got the right <strong>keyword</strong>. So, case-sensitive search isn't always needed&nbsp;– only when you want to be very specific.</p>
<p>Sometimes, being too specific might be bad and make your code not work as intended. To see what I mean, let's look at the example below. Let's apply case-sensitive search once again, but to our unchanged, lowercase keyword "more" and see if it still works:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*green*" -File |   
Where-Object { $_.FullName -cnotlike "*more*" }
</code></pre>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768408273177/ee37dcf7-8720-44b7-aa04-1c09e1cfbe52.png" alt="Picture: case-sensitive search doesn't filter out anything now, because it's too specific. &quot;More items&quot; folder omits the filter now." style="display:block;margin:0 auto" width="1011" height="271" loading="lazy">

<p>Case-sensitive search doesn't filter out anything now, because it's too specific. Both the "Items" and "More items" folders omit the filter now.</p>
<h4 id="heading-faq">FAQ:</h4>
<p>If the <code>Where-Object</code> command is what actually filters the output for us, shouldn't we drop (delete) the <code>-Filter</code> option from <code>gci</code>?</p>
<p>No, we should still use the <code>-Filter</code> option, because it already separates around 99% of the possible files, so the <code>Where-Object</code> command has to work roughly only on 1% of the objects. It makes this part of the command AT LEAST 100 times faster (more often 100,000 times or even faster).</p>
<p>You can try using this command in <code>-Path C:/</code> with and without the <code>-Filter</code> option. In my case, using the <code>-Filter</code> shortened the time needed for the whole sequence of commands to finish from 16 seconds to 8 seconds (first 7.99 seconds is used by <code>gci</code>, so that's why the time got shortened only by a half). That's what we call ✨<em>optimization</em>✨ :D</p>
<h3 id="heading-43-searching-only-1-directory-from-many-with-exactly-the-same-name">4.3 Searching only 1 directory from many with exactly the same name</h3>
<p>We've learned how to search for a phrase anywhere inside the path of a file. But what if we want to search inside exactly the "More items" folder? For that, we'll use the <code>-match</code> filter (which works similarly to the <code>-like</code> filter).</p>
<p>Our phrase will also use "\", instead of "\". This is because "\" is the symbol for a folder, but alone in programming it also has some other features, which we don't want.</p>
<p>This command will look for a match for the "More items" folder in the path of every file from the list. Then, it will show you this file if it matches.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/028f1187-e371-4496-8152-df778337a465.png" alt="Picture: &quot;gci&quot; with a filter for an exact folder." style="display:block;margin:0 auto" width="908" height="155" loading="lazy">

<p>What if we want to check for two folders, one next to the other, simultaneously? Very easy! Just connect them with the sign for a folder "\". Here, the command will search inside the "More items" folder only if it's inside the "Documents" folder:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/6faef3fa-e2a8-46ff-bd5c-7c9cc27c1ab8.png" alt="Picture: searching for &quot;DocumentsMore*&quot;" style="display:block;margin:0 auto" width="908" height="140" loading="lazy">

<p>As you can see, we didn't use "More items", only "More". You can shorten that filter how you want. It will still be applied to the whole path. See the example below:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*green*" -File |
Where-Object { $_.FullName -match "s\\Mo*" }
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/817fb9cb-ddd3-4d9f-8ff5-2dbbc3eb7d0d.png" alt="Picture: filter works, even if it could be more specific" style="display:block;margin:0 auto" width="908" height="140" loading="lazy">

<p>Earlier, we used the <code>not</code> statement in <code>-like</code> filter to exclude certain files and directories. The same can be done with <code>-notmatch</code>:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*green*" -File | 
Where-Object { $_.FullName -notmatch "ents\\Ite*" }
</code></pre>
<p>Be aware that we're now excluding the "Items" folder from the search, not "More items".</p>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/acf4d31f-9c8f-4092-b0bd-ed577ca982cf.png" alt="Picture: excluding &quot;Documentstems&quot; folders from search by using &quot;notmatch&quot; filter" style="display:block;margin:0 auto" width="908" height="140" loading="lazy">

<p>And, with <code>-cmatch</code> we can apply the same case-sensitive filter as with <code>-clike</code>:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*green*" -File | 
Where-Object { $_.FullName -cmatch "green*" }
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/7db7d3bb-9a9e-4881-b0e9-4a119c3f93d8.png" alt="7db7d3bb-9a9e-4881-b0e9-4a119c3f93d8" style="display:block;margin:0 auto" width="908" height="140" loading="lazy">

<p>I hope you get the gist of it now.</p>
<h3 id="heading-44-filter-how-deep-how-many-folders-in-you-want-to-search-for-the-file">4.4 Filter how deep (how many folders in) you want to search for the file</h3>
<p>Sometimes you might have a very long path to some of your files. If you don't want to waste time searching every folder on your computer recursively, you can use <code>-Depth</code> option. It specifies how many folders to search inside your folder tree. I already showed you the picture of a folder tree in the beginning of this article, but you should take a look at it here once again.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/7a433d89-cfe5-4bf6-8f6d-b7b812414a93.png" alt="Picture: a visual explanation of -depth and -recurse parameters. It shows &quot;Documents&quot; folder at the bottom, which is tagged both as Parent and Depth 0. It points upwords to its child folders and a child file. Those are tagged as Depth 0 children of our Documents folder. They are simultaneously tagged as Depth 1 parents, so files and folders. to which they are pointing upwards, are their Depth 1 children." style="display:block;margin:0 auto" width="821" height="656" loading="lazy">

<p>So, how does the <code>-Depth</code> parameter work?</p>
<p><code>-Depth 0</code> means that our command will search only the current folder. It will show results of all children of Depth level 0. Those results are:<br>1 "child file" and 2 "child folders".</p>
<p><code>-Depth 1</code> searches the current folder and its child-folders. It will show the results of all children of Depth level 1. Those results are:<br>1 "child file", 2 "child folders", 2 "grandchild files" and 1 "grandchild folder".</p>
<p><code>-Depth 2</code> searches the current folder and its child and grandchild folders. It will show results of all children of Depth level 2. Those results are:<br>1 "child file", 2 "child folders", 2 "grandchild files", 1 "grandchild folder" and 1 "great grandchild file".</p>
<p>Let's see the difference between these two commands:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*item*" -Depth 0
</code></pre>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*item*" -Depth 1
</code></pre>
<p>The first command will show you only the files and folders inside our current directory.<br>The second command will also search for them inside every folder found inside the current folder.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768408448152/2d875580-e463-4903-b99f-d3b457f5eb5a.png" alt="depth parameter explanation" style="display:block;margin:0 auto" width="612" height="583" loading="lazy">

<p>For the sake of practice, let's combine it with <code>Where-Object</code> to find the green bracelet:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*item*" -Depth 1 | Where-Object { $_.name -clike"*green*" }
</code></pre>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768408504470/4af154e6-9d24-43e4-b228-770da7de9151.png" alt="Picture: gci looking for file with set depth" style="display:block;margin:0 auto" width="812" height="162" loading="lazy">

<p>I hope that this example showed you how easy it is to use multiple options ( <code>-Depth</code>, <code>-Recurse</code>) and filters (<code>-Filter</code>, <code>Where-Object</code>).</p>
<h2 id="heading-5-how-to-search-through-hidden-files">5. How to Search Through Hidden Files</h2>
<p>Some files are not that easily accessible to the user. You can see some of the hidden files and folders in Windows Explorer (<a href="https://notblackmagic.hashnode.dev/how-to-see-hidden-files-and-folders-in-windows-file-explorer">here's how</a>). But sometimes it's easier to find what you need if you see <strong>only</strong> those hidden files. That's possible with PowerShell.</p>
<p>The options we're going to use for that are:</p>
<ul>
<li><p><code>-Force</code>: show files otherwise not accessible by the user, such as hidden files.</p>
</li>
<li><p><code>-Hidden</code>: show <strong>only</strong> those hidden files and directories.</p>
</li>
</ul>
<p>This example will search for hidden files in our user's folder:</p>
<pre><code class="language-powershell">gci -Path ~\ -Force -Hidden
</code></pre>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768408531210/22319e7b-f9f1-40cb-8037-7abcb9225c3b.png" alt="Picture: gci with -Forece and -Hidden parameters showing hidden files and folders" style="display:block;margin:0 auto" width="1315" height="553" loading="lazy">

<p>Everything here is usually invisible to the typical user. But not for you now :D</p>
<p>The interesting thing is that there are more files not available to the user than the available ones. If you're brave enough, you can see them yourself (Remember! <strong>Ctrl + C</strong> stops the command!):</p>
<pre><code class="language-powershell">gci -Path ~\ -Force -Hidden -Recurse
</code></pre>
<h2 id="heading-6-how-can-you-know-all-the-properties-that-you-can-use-as-a-filter">6. How can you know all the properties that you can use as a filter?</h2>
<p>Up until now, we'vce used some common properties, like <code>Name</code> and <code>Fullname</code>. But there are many others that you might want to access, like <code>CreationTime</code> (date of creating the file) or <code>LastWriteTime</code> (date of last edit of the file).</p>
<p>In this section, I'll first show you how to see all the possible properties. After that, you'll learn how to retrieve only the property you want for scripting purposes.</p>
<p>Go through <strong>step 2</strong> above if you haven't already, because we're going to use the same files that we created before.</p>
<p>Move to the <code>Documents</code> folder in PowerShell.</p>
<p>I hope that this script looks familiar to you now. It searches for files with "item" in their names and checks if these names contain the word "green" (all lowercase letters):</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*item*" | 
Where-Object { $_.Name -clike "*green*" }
</code></pre>
<p>We know that only one file should appear (if you don't trust me, just see for yourself). So, we're going to see every possible property we can use by appending (adding at the end) this fragment of code:<br><code>| Select-Object -Property *</code></p>
<p><code>Select-Object</code> (alias: <code>select</code>) is used for selecting different types of properties. By using an option <code>-Property</code> we tell it to show both values and names of all the properties.</p>
<p>For example:</p>
<p>Name of property: <code>FullName</code><br>Value of property: <code>~\Documents\More items\item 1- green bracelet.txt</code></p>
<p>The asterisk <code>*</code> at the end tells this command to show these names and values for every property possible.</p>
<p>The final version of this command looks like this:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*item*" | 
Where-Object { $_.Name -clike "*green*" } | 
Select-Object -Property *
</code></pre>
<p>Try finding the <code>FullName</code> property in there :D</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768408562185/710a4835-a415-4500-af4f-6371a83ae3b6.png" alt="getting all command options or properties" style="display:block;margin:0 auto" width="962" height="830" loading="lazy">

<p>This command showed us all possible properties that we can use for that 1 file that it found. If there were more files fitting the filter, then every single one of them would have a similar list of properties. But for different types of files you will get different results.</p>
<h3 id="heading-how-to-retrieve-only-1-desired-property">How to retrieve only 1 desired property</h3>
<p>You've already learned how to check for all possible properties. So, how do we use any of them? Just put one of them instead an asterisk <code>*</code> at the end of the command, like we put <code>CreationTime</code> in here:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*green*" -File |
Where-Object { $_.Name -clike "*green*" } | 
Select-Object -Property CreationTime
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/060de2e6-909f-4679-987c-e715fb7ee19b.png" alt="Picture: Select-Object shows only the CreationTime property" style="display:block;margin:0 auto" width="1120" height="182" loading="lazy">

<p>You can use any other property for the sake of this exercise, like <code>LastWriteTime</code>:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*green*" -File |
Where-Object { $_.Name -clike "*green*" } | 
Select-Object -Property LastWriteTime
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/8223f738-9467-4961-8518-82e010a6e425.png" alt="Picture: Select-Object shows only the LastWriteTime property" style="display:block;margin:0 auto" width="1112" height="184" loading="lazy">

<p>What if you want to retrieve only the value of the property without its name (because you already know its name and it also messes up your script)? You can retrieve just the value, by changing the <code>-Property</code> to <code>-ExpandProperty</code>:</p>
<pre><code class="language-powershell">Get-ChildItem -Recurse -Filter "*green*" -File |
Where-Object { $_.Name -clike "*green*" } | 
Select-Object -ExpandProperty LastWriteTime
</code></pre>
<p>See the result:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/7f08ecfd-4a19-4276-9d2a-eac53d5cdb93.png" alt="Picture: Changing -Property to -ExpandProperty makes the script to show only the value of the property without its name. the" style="display:block;margin:0 auto" width="1114" height="170" loading="lazy">

<h2 id="heading-7-i-dont-know-the-files-name-but-i-know-whats-inside-it-how-do-i-find-the-file-by-its-content">7. I don't know the file’s name, but I know what's inside it. How do I find the file by its content?</h2>
<p>Sometimes it's easier to find a file by searching it by its content. Or perhaps you have lots of similar files and you'd like to check them quickly without opening and closing them. I'll show you some techniques that will let you achieve that in no time.</p>
<p>This command will search every file on your system for the specified word or phrase (in our case, the phrase is "match"):</p>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Recurse -File | 
Select-String -Pattern 'match' -List
</code></pre>
<p>Here's what's happening:</p>
<ul>
<li><p><code>Get-ChildItem -Path C:\ -Recurse -File</code>: as you already know, this part searches for every file on your computer.</p>
</li>
<li><p><code>|</code> – passes the list of files to the next command. So, the next command will search for a certain phrase only in the files listed by <code>gci</code>.</p>
</li>
<li><p><code>Select-String</code> – "String" is a common word in programming used to describe a word/phrase/some text. So, we select the phrase that we want to search for. That phrase is specified by the <code>-Pattern</code> parameter (in our case it's "match").</p>
</li>
<li><p><code>-List</code> tells the command to show only the first found match in every file (great if you want to just see the list of all found files).</p>
</li>
</ul>
<p>Here's an example output of our command:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/cb91d732-c15a-4b10-a127-1a78af2efe63.png" alt="Picture: Select-String showing path to the file and the place in the file where the pattern was found." style="display:block;margin:0 auto" width="1119" height="127" loading="lazy">

<p>Of course, you have quite a lot of files, and some images may also appear in your search (like .svg files that are basically text files that tell the system how to draw an icon). So, it's always best to specify what type of file you're searching for. Let's look for the phrase "red" inside .svg files:</p>
<pre><code class="language-powershell">Get-ChildItem -Filter "*.svg" -Recurse | 
Select-String -Pattern 'red' -List
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/c1d33d66-014b-49af-9e8d-508a581d51fa.png" alt="Picture: gci looking for text inside svg graphic files." style="display:block;margin:0 auto" width="1300" height="250" loading="lazy">

<p>On the other hand, some text documents will never appear in your search (for example .doc and .docx documents are encoded in such a way that they're impossible to decode without Word).</p>
<p>But in regular text files, you can search for phrases with an emphasis on big and small letters with the <code>-CaseSensitive</code> option. Here, we're going to search for the phrase "github" with only lowercase letters:</p>
<pre><code class="language-powershell">Get-ChildItem -Filter "*.txt" -Recurse | 
Select-String -Pattern 'github' -List -CaseSensitive
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/affa057b-3fc4-402d-9f93-4a1b93bcb98f.png" alt="affa057b-3fc4-402d-9f93-4a1b93bcb98f" style="display:block;margin:0 auto" width="1116" height="325" loading="lazy">

<p>Other options that you'll often use with the <code>Select-String</code> command are:</p>
<ul>
<li><code>Select-String -AllMatch</code> will show you all matches found in every searched file (instead of only 1 match found per file, like with <code>-List</code>).<br><code>Select-String -Context 3</code> shows the three lines of text before and after the line in which the match is found.<br><code>Select-String -Raw</code> won't show you the paths, just the content of the files. This is great for automation and scripts. It's often combined with the <code>-Context</code> option.</li>
</ul>
<p>Let's see some of these options in action:</p>
<pre><code class="language-powershell">Get-ChildItem -Filter "*.txt" -Recurse | 
Select-String -Pattern 'github' -AllMatch -Context 3
</code></pre>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771434300827/4a61d396-755c-49e8-a1f5-f0fd24347b5c.png" alt="looking for file based on its content" style="display:block;margin:0 auto" width="1920" height="350" loading="lazy">

<p>Thanks to the <code>-Context</code> parameter, you can see a total of seven lines (three lines before and three lines after the match) in this file, one after another. This makes it easier to differentiate it from all the other matches found by <code>-AllMatch</code> that might be put in a very similar context.</p>
<p>If you ever feel like there's too much clutter on your screen, you can combine <code>Select-String</code> with <code>Select-Object</code> to get only the paths of the files with matched phrases.</p>
<p>The command below will search every .txt file on your computer for the phrase specified:</p>
<pre><code class="language-powershell">Get-ChildItem -Filter "*.txt" -Recurse | 
Select-String -Pattern 'github' -List
</code></pre>
<p>Let's add the <code>Select-Object -Property Path</code> filter at the end. Now, the command will only show the paths, so there's less clutter on your screen:</p>
<pre><code class="language-powershell">Get-ChildItem -Filter "*.txt" -Recurse | 
Select-String -Pattern 'github' -List | 
Select-Object -Property Path
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/71b9b173-3dd4-40a1-911f-0d494c6a44bb.png" alt="Picture: adding Select-Object makes the results more readable and easier to understand." style="display:block;margin:0 auto" width="1051" height="585" loading="lazy">

<p>Some of the paths are not fully visible. We'll fix that in the next step.</p>
<h2 id="heading-8-i-cant-see-the-full-path-how-do-i-fix-this">8. I can't see the full path - how do I fix this?</h2>
<p>Let's format the results with the <code>Format-Table -Wrap -AutoSize</code> command. <code>-Autosize</code> allows the result to take the whole available space. <code>-Wrap</code> allows wrapping (continuing the text in the next line when it doesn't fit in the space available), which creates more space if it's needed.</p>
<p>Here's an example:</p>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Filter "*.txt" -Recurse | 
Select-String -Pattern 'github' -List | 
Select -Property Path | 
Format-Table -Wrap -AutoSize
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69289f73296ac6cbc0e5620b/bcea9d57-e966-4c41-8d24-bfb3acf95281.png" alt="bcea9d57-e966-4c41-8d24-bfb3acf95281" style="display:block;margin:0 auto" width="1057" height="426" loading="lazy">

<p>Now, you can see the whole paths (or any other results you need) even in PowerShell!</p>
<h2 id="heading-9-hard-to-read-open-the-results-in-the-text-editor-of-your-choice">9. Hard to read? Open the results in the text editor of your choice</h2>
<p>You can send the results of any script/command in two ways:</p>
<p><code>&gt; ~\Documents\command_output.txt</code><br>AND<br><code>| Out-File ~\Documents\command_output.txt</code></p>
<p>Both of these will create a file inside your <code>Documents</code> folder, which you can later open in any program of your choice and edit.</p>
<p>Just add whichever solution you prefer to the end of your command, like here:</p>
<pre><code class="language-powershell">Get-ChildItem -Filter "*.txt" -Recurse | 
Select-String -Pattern 'match' -List | 
Select -Property Path | 
Out-File ~\Documents\command_output.txt
</code></pre>
<p>In the image below, first you'll see the same command, but without exporting the results to another file. The second command, at the bottom of the image, will export the results to the other file without showing them in PowerShell:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771431975160/41f0445e-1a0c-41f6-83b5-446f21e9bea9.png" alt="Picture: gci looking for file based on its content, but showing only paths to the files with found matches." style="display:block;margin:0 auto" width="1920" height="650" loading="lazy">

<p>You'll see the results from second command after opening the file in any text editor:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771432401191/851770d9-6596-4081-b537-45bf8373ac44.png" alt="Picture: command results are possible to open in any text editor." style="display:block;margin:0 auto" width="1920" height="650" loading="lazy">

<p>But, what if you can't see the full path even in your text editor?</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771432650086/f21b1bf6-6aad-4799-a34b-c5889b8f8ee7.png" alt="Picture: command results don't show all information you need. They sometimes stop showing, if it's more then default settings allow for." style="display:block;margin:0 auto" width="700" height="650" loading="lazy">

<p>To address this, you can add <code>| Format-Table -Wrap -AutoSize</code> right before sending the results to the file:</p>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Filter "*.txt" -Recurse | 
Select-String -Pattern 'match' -List | 
Select -Property Path | 
Format-Table -Wrap -AutoSize |
Out-File ~\Documents\command_output.txt
</code></pre>
<p>And open the file to see the whole path!</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771434628016/b8bebea1-f6db-4d12-a0f5-70ca18492b9b.png" alt="Picture: bug fixed. Now, you can see all the information." style="display:block;margin:0 auto" width="1920" height="350" loading="lazy">

<p>Just remember that you have to copy each line one by one. Where you see the arrows in the screenshot above is a "newline" character, which you have to delete. Only after doing that can you copy the whole path and paste it into Windows Explorer or into some script.</p>
<h2 id="heading-10-summary-the-ultimate-commands-for-searching-and-finding-whatever-you-need">10. Summary: the Ultimate Commands for Searching and Finding Whatever You Need</h2>
<p><a href="https://github.com/NotBlackMagician/NBM-cheat-sheets/blob/main/windows_powershell/NBM_cheat_sheet_Get-ChildItem_find_any_file_like_on_linux.txt">Here</a> you can download a free cheat sheet with explanations of the commands and examples in one place.</p>
<h3 id="heading-most-used-commands">Most used commands:</h3>
<ul>
<li>Case-sensitive search:</li>
</ul>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Recurse -Filter "*whatYouNeed*" |   
Where-Object { $_.Name -clike "*whatYouNeed*" } |   
Select-Object { $_.FullName } |
Format-Table -Wrap -AutoSize
</code></pre>
<ul>
<li>Alternatively, send the result to a file:</li>
</ul>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Recurse -Filter "*whatYouNeed*" |   
Where-Object { $_.Name -clike "*whatYouNeed*" } |   
Select-Object { $_.FullName } |
Format-Table -Wrap -AutoSize |
Out-File ~\Documents\command_output.txt
</code></pre>
<ul>
<li>Search by file's content:</li>
</ul>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Recurse | 
Select-String -Pattern 'what you remember' -AllMatch -Context 2 |
Format-Table -Wrap -AutoSize
</code></pre>
<ul>
<li>Alternatively, send the result to the file:</li>
</ul>
<pre><code class="language-powershell">Get-ChildItem -Path C:\ -Recurse | 
Select-String -Pattern 'what you remember' -CaseSensitive -AllMatch -Context 2 |
Format-Table -Wrap -AutoSize |
Out-File ~\Documents\command_output.txt
</code></pre>
<p>These commands should work for anything you want to find. I hope you understand now how they function after reading through this tutorial ;)</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>If you want to learn more about these commands, I show you how to work with them in depth in my tutorial <a href="https://notblackmagic.hashnode.dev/learn-windows-powershell-commands-like-a-linux-user">“Learn PowerShell commands like a Linux user”</a>.</p>
<p>If what you found here helped you in any way, consider following me on my social media in order to help me reach further audience: <a href="https://social.linux.pizza/@SecretDevil">Mastodon</a>, <a href="https://www.linkedin.com/in/piotr-opoka-4320143a5/">LinkedIn</a>.</p>
<p>You can also rate me on <a href="https://github.com/NotBlackMagician">Github</a> and support me on <a href="https://ko-fi.com/piotropoka">Ko-fi!</a></p>
<p>Thank you for any support you're able to give. Have a great day!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Production-Ready Flutter CI/CD Pipeline with GitHub Actions: Quality Gates, Environments, and Store Deployment ]]>
                </title>
                <description>
                    <![CDATA[ Mobile application development has evolved over the years. The processes, structure, and syntax we use has changed, as well as the quality and flexibility of the apps we build. One of the major improv ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-production-ready-flutter-ci-cd-pipeline-with-github-actions-quality-gates-environments-and-store-deployment/</link>
                <guid isPermaLink="false">69bb2e078c55d6eefb6c2e8d</guid>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ github-actions ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ github copilot ]]>
                    </category>
                
                    <category>
                        <![CDATA[ CI/CD pipelines ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Productivity ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Wed, 18 Mar 2026 22:58:15 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/8c9d9384-ff02-47d7-aa69-42db2ebae247.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Mobile application development has evolved over the years. The processes, structure, and syntax we use has changed, as well as the quality and flexibility of the apps we build.</p>
<p>One of the major improvements has been a properly automated CI/CD pipeline flow that gives us seamless automation, continuous integration, and continuous deployment.</p>
<p>In this article, I'll break down how you can automate and build a production ready CI/CD pipeline for your Flutter application using GitHub Actions.</p>
<p>Note that there are other ways to do this, like with Codemagic (built specifically for Flutter apps – which I'll cover in a subsequent tutorial), but in this article we'll focus on GitHub Actions instead.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-the-typical-workflow">The Typical Workflow</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-pipeline-architecture">Pipeline Architecture</a></p>
</li>
<li><p><a href="#heading-writing-the-workflows">Writing the Workflows</a></p>
<ul>
<li><p><a href="#heading-the-helper-scripts">The Helper Scripts</a></p>
<ul>
<li><p><a href="#heading-script-1-generateconfigsh">generate_config.sh</a></p>
</li>
<li><p><a href="#heading-script-2-qualitygatesh">quality_gate.sh</a></p>
</li>
<li><p><a href="#heading-script-3-uploadsymbolssh-sentry">upload_symbols.sh</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-workflow-1-prchecksyml">PR Quality Gate (pr_checks.yml)</a></p>
</li>
<li><p><a href="#heading-workflow-2-androidyml">Android CI/CD Pipeline (android.yml)</a></p>
</li>
<li><p><a href="#heading-workflow-3-iosyml">iOS CI/CD Pipeline (ios.yml)</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-secrets-and-configuration-reference">Secrets and Configuration Reference</a></p>
</li>
<li><p><a href="#heading-end-to-end-flow">End-to-End Flow</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-the-typical-workflow">The Typical Workflow</h2>
<p>First, let's define the common approach to deploying production-ready Flutter apps.</p>
<p>The development team does their work on local, pushes to the repository for merge or review, and eventually runs <code>flutter build apk</code> or <code>flutter build appbundle</code> to generate the apk file. This then gets shared with the QA team manually, or deployed to Firebase app distribution for testing. If it's a production move, the app bundle is submitted to the Google Play store for review and then deployed.</p>
<p>This process is often fully manual with no automated checks, validation, or control over quality, speed, and seamlessness. Manually shipping a Flutter app starts out relatively simply, but can quickly and quietly turn into a liability. You run <code>flutter build</code>, switch configs, sign the build, upload it somewhere, and hope you didn’t mix up staging keys with production ones.</p>
<p>As teams grow and release updates more and more quickly, these manual steps become real risks. A skipped quality check, a missing keystore, or an incorrect base URL deployed to production can cost hours of debugging or worse – it can affect your users.</p>
<p>Automating this process fully involves some high level configuration and predefined scripting. It completely takes control of the deployment process from the moment the developer raised a PR into the common or base branch (for example, the <code>develop</code> branch).</p>
<p>This automated process takes care of everything that needs to be done – provided it has been predefined, properly scripted, and aligns with the use case of the team.</p>
<h3 id="heading-what-well-do-here">What we'll do here:</h3>
<p>In this tutorial, we'll build a production-grade CI/CD pipeline for a Flutter app using GitHub Actions. The pipeline automates the entire lifecycle: pull-request quality checks, environment-specific configuration injection, Android and iOS builds, Firebase App Distribution for testers, Sentry symbol uploads, and final deployment to the Play Store and App Store.</p>
<p>By the end, every release – from a developer opening a PR to the final build landing in users' hands – will be fully automated, with no one touching a terminal.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before starting, you should have:</p>
<ol>
<li><p>A Flutter app with working Android and iOS builds</p>
</li>
<li><p>Basic familiarity with <a href="https://www.freecodecamp.org/news/automate-cicd-with-github-actions-streamline-workflow/">GitHub Actions</a> (workflows and jobs)</p>
</li>
<li><p>A Firebase project with App Distribution enabled</p>
</li>
<li><p>A Sentry project for error tracking</p>
</li>
<li><p>A Google Play Console app already created</p>
</li>
<li><p>An Apple Developer account with App Store Connect access</p>
</li>
<li><p>Fastlane configured for your iOS project</p>
</li>
<li><p>Basic Bash knowledge (I’ll explain the important parts)</p>
</li>
</ol>
<h2 id="heading-pipeline-architecture">Pipeline Architecture</h2>
<p>In this guide, we'll be building a CI/CD pipeline with very precise instructions and use cases. These use cases determine the way your pipeline is built.</p>
<p>For this tutorial, we'll use this use case:</p>
<p>I want to automate the workflow on my development team based on the following criteria:</p>
<ol>
<li><p>When a developer on the team raises a PR into the common working branch <code>develop</code> in most cases), a workflow is triggered to run quality checks on the code. It only allows the merge to happen if all checks (like tests coverage, quality checks, and static analysis) pass.</p>
</li>
<li><p>Code that's moving from the develop branch to the staging branch goes through another workflow that injects staging configurations/secret keys, does all the necessary checks, and distributes the application for testing on Firebase App Distribution for android as well as Testflight for iOS.</p>
</li>
<li><p>Code that's moving from the staging to the production branch goes through the production level workflow which involves apk secured signing, production configuration injection, running tests to ensure nothing breaks, Sentry analysis for monitoring, and submission to App Store Connect as well as Google Play Console.</p>
</li>
</ol>
<p>These are our predefined conditions which help with the construction of our workflows.</p>
<h2 id="heading-writing-the-workflows">Writing the Workflows</h2>
<p>We'll split this pipeline into three GitHub Actions workflows.</p>
<p>We'll also be taking it a notch higher by creating three helper .sh scripts for a cleaner and more maintainable workflow.</p>
<p>In your project root, create two folders:</p>
<ol>
<li><p>.github/</p>
</li>
<li><p>scripts.</p>
</li>
</ol>
<p>The <strong>.github/</strong> folder will hold the workflows we'll be creating for each use case, while the <strong>scripts/</strong> folder will hold the helper scripts that we can easily call in our CLI or in the workflows directly.</p>
<p>After this, we'll create three workflow .yaml files:</p>
<ol>
<li><p>pr_checks.yaml</p>
</li>
<li><p>android.yaml</p>
</li>
<li><p>ios.yaml</p>
</li>
</ol>
<p>Also in the scripts folder, let's create three .sh files:</p>
<ol>
<li><p>generate_config.sh</p>
</li>
<li><p>quality_checks.sh</p>
</li>
<li><p>upload_symbols.sh</p>
</li>
</ol>
<pre><code class="language-yaml">.github/
  workflows/
    pr_checks.yml
    android.yml
    ios.yml

scripts/
  generate_config.sh
  quality_checks.sh
  upload_symbols.sh
</code></pre>
<p>This workflow architecture ensures that a push to <code>develop</code> automatically produces a tester build. Also, merging to <code>production</code> ships directly to the stores without manual commands or config changes.</p>
<p>The scripts live outside the YAML on purpose. This lets you run the same logic locally.</p>
<h3 id="heading-the-helper-scripts">The Helper Scripts</h3>
<p>The scripts form the backbone of the pipeline. Each one has a single responsibility and is reused across workflows.</p>
<p>Instead of cramming logic into YAML, we'll move it into <strong>reusable scripts</strong>. This keeps workflows clean and lets you run the same logic locally. Let's go through each one now.</p>
<h3 id="heading-script-1-generateconfigsh">Script #1: <code>generate_config.sh</code></h3>
<p>Injecting secrets safely is one of the hardest CI/CD problems in mobile apps.</p>
<p>The strategy:</p>
<ul>
<li><p>Commit a Dart template file with placeholders</p>
</li>
<li><p>Replace placeholders at build time using secrets from GitHub Actions</p>
</li>
<li><p>Never commit real credentials</p>
</li>
</ul>
<pre><code class="language-yaml">#!/usr/bin/env bash
set -euo pipefail


ENV_NAME=${1:-}
BASE_URL=${2:-}
ENCRYPTION_KEY=${3:-}

TEMPLATE="lib/core/env/env_ci.dart"
OUT="lib/core/env/env_ci.g.dart"

if [ -z "\(ENV_NAME" ] || [ -z "\)BASE_URL" ] || [ -z "$ENCRYPTION_KEY" ]; then
  echo "Usage: $0 &lt;env-name&gt; &lt;base-url&gt; &lt;encryption-key&gt;"
  exit 2
fi

sed -e "s|&lt;&lt;BASE_URL&gt;&gt;|$BASE_URL|g" \
    -e "s|&lt;&lt;ENCRYPTION_KEY&gt;&gt;|$ENCRYPTION_KEY|g" \
    -e "s|&lt;&lt;ENV_NAME&gt;&gt;|$ENV_NAME|g" \
    "\(TEMPLATE" &gt; "\)OUT"

echo "Generated config for $ENV_NAME"
</code></pre>
<p>This script is responsible for injecting environment-specific configuration into the Flutter app at build time, without ever committing secrets to source control.</p>
<p>Let’s walk through it carefully.</p>
<h4 id="heading-1-shebang-choosing-the-shell">1. Shebang: Choosing the Shell</h4>
<pre><code class="language-yaml">#!/usr/bin/env bash
</code></pre>
<p>This line tells the system to execute the script using <strong>Bash</strong>, regardless of where Bash is installed on the machine.</p>
<p>Using <code>/usr/bin/env bash</code> instead of <code>/bin/bash</code> makes the script more portable across local machines, GitHub Actions runners, and Docker containers.</p>
<h4 id="heading-2-fail-fast-fail-loud">2. Fail Fast, Fail Loud</h4>
<pre><code class="language-yaml">set -euo pipefail
</code></pre>
<p>This is one of the most important lines in the script.</p>
<p>It enables three strict Bash modes:</p>
<ul>
<li><p><code>-e</code>: Exit immediately if any command fails</p>
</li>
<li><p><code>-u</code>: Exit if an undefined variable is used</p>
</li>
<li><p><code>-o pipefail</code>: Fail if any command in a pipeline fails, not just the last one</p>
</li>
</ul>
<p>This matters in CI because silent failures are dangerous, partial config generation can break production builds, and CI should stop immediately when something is wrong.</p>
<p>This line ensures that no broken config ever makes it into a build.</p>
<h4 id="heading-3-reading-input-arguments">3. Reading Input Arguments</h4>
<pre><code class="language-yaml">
ENV_NAME=${1:-}
BASE_URL=${2:-}
ENCRYPTION_KEY=${3:-}
</code></pre>
<p>These lines read <strong>positional arguments</strong> passed to the script:</p>
<ul>
<li><p><code>$1</code>: Environment name (<code>dev</code>, <code>staging</code>, <code>production</code>)</p>
</li>
<li><p><code>$2</code>: API base URL</p>
</li>
<li><p><code>$3</code>: Encryption or API key</p>
</li>
</ul>
<p>The <code>${1:-}</code> syntax means:</p>
<p><em>“If the argument is missing, default to an empty string instead of crashing.”</em></p>
<p>This works hand-in-hand with <code>set -u</code> , we control the failure explicitly instead of letting Bash explode unexpectedly.</p>
<h4 id="heading-4-defining-input-and-output-files">4. Defining Input and Output Files</h4>
<pre><code class="language-yaml">TEMPLATE="lib/core/env/env_ci.dart"
OUT="lib/core/env/env_ci.g.dart"
</code></pre>
<p>Here we define two files:</p>
<ul>
<li><p><strong>Template file (</strong><code>env_ci.dart</code><strong>)</strong></p>
<ul>
<li><p>Contains placeholder values like <code>&lt;&lt;BASE_URL&gt;&gt;</code></p>
</li>
<li><p>Safe to commit to Git</p>
</li>
</ul>
</li>
<li><p><strong>Generated file (</strong><code>env_ci.g.dart</code><strong>)</strong></p>
<ul>
<li><p>Contains real environment values</p>
</li>
<li><p>Must be ignored by Git (<code>.gitignore</code>)</p>
</li>
</ul>
</li>
</ul>
<p>At the heart of this approach are two Dart files with very different responsibilities. They may look similar, but they play completely different roles in the system.</p>
<h4 id="heading-envcidart"><code>env.ci.dart</code>:</h4>
<pre><code class="language-java">// lib/core/env/env_ci.dart

class EnvConfig {
  static const String baseUrl = '&lt;&lt;BASE_URL&gt;&gt;';
  static const String encryptionKey = '&lt;&lt;ENCRYPTION_KEY&gt;&gt;';
  static const String environment = '&lt;&lt;ENV_NAME&gt;&gt;';
}
</code></pre>
<p>This file is <strong>safe</strong>, <strong>static</strong>, and <strong>version-controlled</strong>. It contains placeholders, not real values.</p>
<p>Some of its key characteristics are:</p>
<ul>
<li><p>Contains no real secrets</p>
</li>
<li><p>Uses obvious placeholders (<code>&lt;&lt;BASE_URL&gt;&gt;</code>, etc.)</p>
</li>
<li><p>Safe to commit to Git</p>
</li>
<li><p>Reviewed like normal source code</p>
</li>
<li><p>Serves as the single source of truth for required config fields</p>
</li>
</ul>
<p>Think of this file as a contract:</p>
<p><em>“These are the configuration values the app expects at runtime.”</em></p>
<h4 id="heading-envcigdart"><code>env.ci.g.dart</code>:</h4>
<p>This file is created at <strong>build time</strong> by <code>generate_config.sh</code>. After substitution, it looks like this:</p>
<pre><code class="language-java">// lib/core/env/env_ci.g.dart
// GENERATED FILE — DO NOT COMMIT

class EnvConfig {
  static const String baseUrl = 'https://staging.api.example.com';
  static const String encryptionKey = 'sk_live_xxxxx';
  static const String environment = 'staging';
}
</code></pre>
<p>Key characteristics:</p>
<ul>
<li><p>Contains real environment values</p>
</li>
<li><p>Generated dynamically in CI</p>
</li>
<li><p>Differs per environment (dev / staging / production)</p>
</li>
<li><p>Must <strong>never</strong> be committed to source control</p>
</li>
</ul>
<p>This file exists only on a developer’s machine (if generated locally), inside the CI runner during a build. Once the job finishes, it disappears.</p>
<h4 id="heading-gitignore"><code>.gitignore</code>:</h4>
<p>To guarantee the generated file never leaks, it must be ignored:</p>
<h4 id="heading-why-this-separation-is-critical">Why This Separation Is Critical</h4>
<p>This design solves several hard problems at once.</p>
<p><strong>Security:</strong></p>
<ul>
<li><p>Secrets live <strong>only</strong> in GitHub Actions secrets</p>
</li>
<li><p>They never appear in the repository</p>
</li>
<li><p>They never appear in PRs</p>
</li>
<li><p>They never appear in Git history</p>
</li>
</ul>
<p><strong>Environment Isolation:</strong></p>
<p>Each environment gets its own generated config:</p>
<ul>
<li><p><code>develop</code>: dev API</p>
</li>
<li><p><code>staging</code>: staging API</p>
</li>
<li><p><code>production</code>: production API</p>
</li>
</ul>
<p>The same codebase behaves differently <strong>without branching logic in Dart</strong>.</p>
<p><strong>Deterministic Builds:</strong></p>
<p>Every build is fully reproducible, fully automated, and explicit about which environment it targets.</p>
<p>There are no “it worked locally” scenarios.</p>
<h4 id="heading-5-validating-required-arguments">5. Validating Required Arguments</h4>
<pre><code class="language-java">if [ -z "\(ENV_NAME" ] || [ -z "\)BASE_URL" ] || [ -z "$ENCRYPTION_KEY" ]; then
  echo "Usage: $0 &lt;env-name&gt; &lt;base-url&gt; &lt;encryption-key&gt;"
  exit 2
fi
</code></pre>
<p>This block enforces correct usage.</p>
<ul>
<li><p><code>-z</code> checks whether a variable is empty</p>
</li>
<li><p>If any required argument is missing:</p>
<ul>
<li><p>A helpful usage message is printed</p>
</li>
<li><p>The script exits with a non-zero status code</p>
</li>
</ul>
</li>
<li><p><code>0</code>: success</p>
</li>
<li><p><code>1+</code>: failure</p>
</li>
<li><p><code>2</code> conventionally means incorrect usage</p>
</li>
</ul>
<p>In CI, this immediately fails the job and prevents an invalid build.</p>
<h4 id="heading-6-injecting-environment-values">6. Injecting Environment Values</h4>
<pre><code class="language-java">sed -e "s|&lt;&lt;BASE_URL&gt;&gt;|$BASE_URL|g" \
    -e "s|&lt;&lt;ENCRYPTION_KEY&gt;&gt;|$ENCRYPTION_KEY|g" \
    -e "s|&lt;&lt;ENV_NAME&gt;&gt;|$ENV_NAME|g" \
    "\(TEMPLATE" &gt; "\)OUT"
</code></pre>
<p>This is the heart of the script.</p>
<p>What’s happening here:</p>
<ol>
<li><p><code>sed</code> performs <strong>stream editing</strong>: it reads text, transforms it, and outputs the result</p>
</li>
<li><p>Each <code>-e</code> flag defines a replacement rule:</p>
<ul>
<li><p>Replace <code>&lt;&lt;BASE_URL&gt;&gt;</code> with the actual API URL</p>
</li>
<li><p>Replace <code>&lt;&lt;ENCRYPTION_KEY&gt;&gt;</code> with the real key</p>
</li>
<li><p>Replace <code>&lt;&lt;ENV_NAME&gt;&gt;</code> with the environment label</p>
</li>
</ul>
</li>
<li><p>The transformed output is written to <code>env_ci.g.dart</code></p>
</li>
</ol>
<p>This entire operation happens <strong>at build time</strong>:</p>
<ul>
<li><p>No secrets are committed</p>
</li>
<li><p>No secrets are logged</p>
</li>
<li><p>No secrets persist beyond the CI run</p>
</li>
</ul>
<h4 id="heading-7-success-feedback">7. Success Feedback</h4>
<pre><code class="language-java">echo "Generated config for $ENV_NAME"
</code></pre>
<p>This line provides a clear success signal in CI logs.</p>
<p>It answers three important questions instantly:</p>
<ul>
<li><p>Did the script run?</p>
</li>
<li><p>Did it finish successfully?</p>
</li>
<li><p>Which environment was generated?</p>
</li>
</ul>
<p>In long CI logs, these small confirmations matter.</p>
<p>Alright, now let's move on to the second script.</p>
<h3 id="heading-script-2-qualitygatesh">Script #2: <code>quality_gate.sh</code></h3>
<p>This script defines what <em>“good code”</em> means for your team.</p>
<pre><code class="language-yaml">#!/usr/bin/env bash
set -euo pipefail

echo "Running quality checks"

dart format --output=none --set-exit-if-changed .
flutter analyze
flutter test --no-pub --coverage

if command -v dart_code_metrics &gt;/dev/null 2&gt;&amp;1; then
  dart_code_metrics analyze lib --reporter=console || true
fi

echo "Quality checks passed"
</code></pre>
<p>Lets break down this script bit by bit.</p>
<h4 id="heading-1-start-amp-end-log-markers">1. Start &amp; End Log Markers</h4>
<pre><code class="language-yaml">echo "Running quality checks"
...
echo "Quality checks passed"
</code></pre>
<p>These two lines act as <strong>visual boundaries</strong> in CI logs.</p>
<p>In large pipelines (especially when Android and iOS jobs run in parallel), logs can be very noisy. Clear markers:</p>
<ul>
<li><p>Help developers quickly find the quality phase</p>
</li>
<li><p>Make debugging faster</p>
</li>
<li><p>Confirm that the script completed successfully</p>
</li>
</ul>
<p>The final success message only prints if <strong>everything above it passed</strong>, because <code>set -e</code> would have terminated the script earlier on failure.</p>
<p>So this line effectively means: All quality gates passed. Safe to proceed.</p>
<h4 id="heading-2-running-the-test-suite">2. Running the Test Suite</h4>
<pre><code class="language-yaml">flutter test --no-pub --coverage
</code></pre>
<p>This line executes your entire Flutter test suite.</p>
<p>Let’s break it down carefully.</p>
<p>1. <code>flutter test</code></p>
<p>This runs unit tests, widget tests, and any test under the <code>test/</code> directory. If <strong>any test fails</strong>, the command exits with a non-zero status code.</p>
<p>Because we enabled <code>set -e</code> earlier, that immediately stops the script and fails the CI job.</p>
<p>2. <code>--coverage</code></p>
<p>This flag generates a coverage report at:</p>
<pre><code class="language-yaml">coverage/lcov.info
</code></pre>
<p>This file can later be uploaded to Codecov, used to enforce minimum coverage thresholds, and tracked over time for quality improvement.</p>
<p>Even if you’re not enforcing coverage yet, generating it now future-proofs your pipeline.</p>
<h4 id="heading-3-optional-code-metrics">3. Optional Code Metrics</h4>
<pre><code class="language-yaml">if command -v dart_code_metrics &gt;/dev/null 2&gt;&amp;1; then
  dart_code_metrics analyze lib --reporter=console || true
fi
</code></pre>
<p>This block is intentionally designed to be optional and non-blocking.</p>
<p><strong>Step 1 – Check If the Tool Exists:</strong></p>
<pre><code class="language-yaml">command -v dart_code_metrics &gt;/dev/null 2&gt;&amp;1
</code></pre>
<p>This checks whether <code>dart_code_metrics</code> is installed.</p>
<ul>
<li><p>If installed, proceed</p>
</li>
<li><p>If not installed, skip silently</p>
</li>
</ul>
<p>The redirection:</p>
<ul>
<li><p><code>&gt;/dev/null</code> hides normal output</p>
</li>
<li><p><code>2&gt;&amp;1</code> hides errors</p>
</li>
</ul>
<p>This makes the script portable:</p>
<ul>
<li><p>Developers without the tool can still run the script</p>
</li>
<li><p>CI can enforce it if configured</p>
</li>
</ul>
<p><strong>Step 2 – Run Metrics (Soft Enforcement):</strong></p>
<pre><code class="language-yaml">dart_code_metrics analyze lib --reporter=console || true
</code></pre>
<p>This analyzes the <code>lib/</code> directory and prints results in the console.</p>
<p>The important part is:</p>
<pre><code class="language-yaml">|| true
</code></pre>
<p>Because we enabled <code>set -e</code>, any failing command would normally stop the script.</p>
<p>Adding <code>|| true</code> overrides that behavior:</p>
<ul>
<li><p>If metrics report issues,</p>
</li>
<li><p>The script continues,</p>
</li>
<li><p>CI does not fail.</p>
</li>
</ul>
<p>Why design it this way? Because metrics are often gradual improvements, technical debt indicators, or advisory rather than blocking.</p>
<p>You can later remove <code>|| true</code> to make metrics mandatory.</p>
<h4 id="heading-4-final-success-message"><strong>4. Final Success Message</strong></h4>
<pre><code class="language-yaml">echo "✅ Quality checks passed"
</code></pre>
<p>This line only executes if formatting passed, static analysis passed, and tests passed.</p>
<p>If you see this in CI logs, it means the branch has successfully cleared the quality gate. It’s your automated approval before deployment steps begin.</p>
<h4 id="heading-what-this-script-guarantees">What This Script Guarantees</h4>
<p>With this in place, every branch must satisfy:</p>
<ul>
<li><p>Clean formatting</p>
</li>
<li><p>No analyzer errors</p>
</li>
<li><p>Passing tests</p>
</li>
<li><p>(Optional) Healthy metrics</p>
</li>
</ul>
<p>That’s how you move from <strong>“We try to maintain quality”</strong> to <strong>“Quality is enforced automatically.”</strong></p>
<p>Alright, on to the third script.</p>
<h3 id="heading-script-3-uploadsymbolssh-sentry"><strong>Script #3:</strong> <code>upload_symbols.sh</code> <strong>(Sentry)</strong></h3>
<p>This script is responsible for uploading <strong>obfuscation debug symbols</strong> to Sentry so production crashes remain readable.</p>
<pre><code class="language-yaml">#!/usr/bin/env bash
set -euo pipefail

RELEASE=${1:-}

[ -z "$RELEASE" ] &amp;&amp; exit 2

if ! command -v sentry-cli &gt;/dev/null 2&gt;&amp;1; then
  exit 0
fi

sentry-cli releases new "$RELEASE" || true

sentry-cli upload-dif build/symbols || true

sentry-cli releases finalize "$RELEASE" || true

echo "✅ Symbols uploaded for release $RELEASE"
</code></pre>
<p>Let's go through it step by step.</p>
<h4 id="heading-1-reading-the-release-identifier">1. Reading the Release Identifier</h4>
<pre><code class="language-yaml">RELEASE=${1:-}
</code></pre>
<p>This reads the first positional argument passed to the script.</p>
<p>When you call the script in CI, it typically looks like:</p>
<pre><code class="language-yaml">./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
</code></pre>
<p>So <code>$1</code> becomes the short Git commit SHA.</p>
<p>Using <code>${1:-}</code> ensures:</p>
<ul>
<li><p>If no argument is passed, the variable becomes an empty string</p>
</li>
<li><p>The script does not crash due to <code>set -u</code></p>
</li>
</ul>
<p>This release value ties the uploaded symbols, deployed build, and crash reports all to the exact same commit. This linkage is critical for production debugging.</p>
<h4 id="heading-2-validating-the-release-argument">2. Validating the Release Argument</h4>
<pre><code class="language-yaml">[ -z "$RELEASE" ] &amp;&amp; exit 2
</code></pre>
<p>This is a compact validation check.</p>
<ul>
<li><p><code>-z</code> checks whether the string is empty</p>
</li>
<li><p>If it is empty → exit with status code 2</p>
</li>
</ul>
<p>Conventionally:</p>
<ul>
<li><p><code>0</code> = success</p>
</li>
<li><p><code>1+</code> = failure</p>
</li>
<li><p><code>2</code> = incorrect usage</p>
</li>
</ul>
<p>This prevents symbol uploads from running without a release identifier, which would break traceability in Sentry.</p>
<h4 id="heading-3-checking-if-sentry-cli-exists">3. Checking If <code>sentry-cli</code> Exists</h4>
<pre><code class="language-yaml">if ! command -v sentry-cli &gt;/dev/null 2&gt;&amp;1; then
  exit 0
fi
</code></pre>
<p>This block checks whether the <code>sentry-cli</code> tool is available in the environment.</p>
<p>What’s happening:</p>
<ul>
<li><p><code>command -v sentry-cli</code> checks if it exists</p>
</li>
<li><p><code>&gt;/dev/null 2&gt;&amp;1</code> suppresses all output</p>
</li>
<li><p><code>!</code> negates the condition</p>
</li>
</ul>
<p>So this reads as: <em>"If</em> <code>sentry-cli</code> <em>is NOT installed, exit successfully."</em></p>
<p>Why exit with <code>0</code> instead of failing?</p>
<p>Because not every environment needs symbol uploads. Also, dev builds may not install Sentry, and you don’t want CI to fail just because Sentry isn’t configured.</p>
<p>This makes symbol uploading <strong>environment-aware</strong> and <strong>optional</strong>.</p>
<p>Production environments can install <code>sentry-cli</code>, while dev environments skip it cleanly.</p>
<h4 id="heading-4-creating-a-new-release-in-sentry">4. Creating a New Release in Sentry</h4>
<pre><code class="language-yaml">sentry-cli releases new "$RELEASE" || true
</code></pre>
<p>This tells Sentry: “A new release exists with this version identifier.”</p>
<p>Even if the release already exists, the script continues because of:</p>
<pre><code class="language-yaml">|| true
</code></pre>
<p>This prevents the build from failing if:</p>
<ul>
<li><p>The release was already created</p>
</li>
<li><p>The command returns a non-critical error</p>
</li>
</ul>
<p>The goal is resilience, not strict enforcement.</p>
<h4 id="heading-5-uploading-debug-information-files-difs">5. Uploading Debug Information Files (DIFs)</h4>
<pre><code class="language-yaml">sentry-cli upload-dif build/symbols || true
</code></pre>
<p>This is the core step.</p>
<p><code>build/symbols</code> is generated when you build Flutter with:</p>
<pre><code class="language-yaml">--obfuscate --split-debug-info=build/symbols
</code></pre>
<p>When you obfuscate Flutter builds:</p>
<ul>
<li><p>Method names are renamed</p>
</li>
<li><p>Stack traces become unreadable</p>
</li>
</ul>
<p>The symbol files allow Sentry to reverse-map obfuscated stack traces and show readable crash reports.</p>
<p>Without this step, production crashes look like:</p>
<pre><code class="language-yaml">a.b.c.d (Unknown Source)
</code></pre>
<p>With this step, you get:</p>
<pre><code class="language-yaml">AuthRepository.login()
</code></pre>
<p>Again, <code>|| true</code> ensures the build doesn’t fail if:</p>
<ul>
<li><p>The directory doesn’t exist</p>
</li>
<li><p>No symbols were generated</p>
</li>
<li><p>Upload encounters a transient issue</p>
</li>
</ul>
<p>Symbol uploads should not block deployment.</p>
<h4 id="heading-6-finalizing-the-release">6. Finalizing the Release</h4>
<pre><code class="language-yaml">sentry-cli releases finalize "$RELEASE" || true
</code></pre>
<p>This marks the release as complete in Sentry.</p>
<p>Finalizing signals:</p>
<ul>
<li><p>The release is deployed</p>
</li>
<li><p>It can begin aggregating crash reports</p>
</li>
<li><p>It’s ready for production monitoring</p>
</li>
</ul>
<p>Like the previous steps, this is soft-failed with <code>|| true</code> to keep CI robust.</p>
<h4 id="heading-what-this-script-guarantees">What This Script Guarantees</h4>
<p>When everything is configured correctly:</p>
<ol>
<li><p>Production build is obfuscated</p>
</li>
<li><p>Debug symbols are generated</p>
</li>
<li><p>Symbols are uploaded to Sentry</p>
</li>
<li><p>Crashes map back to real source code</p>
</li>
<li><p>Release version matches commit SHA</p>
</li>
</ol>
<p>That’s production-grade crash observability.</p>
<p>Now that we've gone through the three helper scripts we've created to optimize and enhance this process, lets now dive into the three workflow .yaml files we're going to create.</p>
<h2 id="heading-workflow-1-prchecksyml">Workflow #1: <code>PR_CHECKS.YML</code></h2>
<p>This workflow will be designed to help ensure a certain standard is met once a PR is raised into a certain common or base branch. This will ensure that all quality checks in the incoming code pass before allowing any merge into the base branch.</p>
<p>This is basically a gate that verifies the quality of the code that's about to be merged into the base branch. If your pipeline allows unverified code into your base branch, then your CI becomes decorative, not protective.</p>
<p>Lets break down what's actually needed during every PR Check.</p>
<h3 id="heading-1-dependency-integrity">1. Dependency Integrity</h3>
<p>For Flutter apps, where we manage dependencies with the <strong>pub get</strong> command, it's important to make sure that the integrity of all dependencies are confirmed – up to date as well as compatible.</p>
<p>Every PR should begin with:</p>
<pre><code class="language-yaml">flutter pub get
</code></pre>
<p>This ensures:</p>
<ul>
<li><p><code>pubspec.yaml</code> is valid</p>
</li>
<li><p>Dependency constraints are consistent</p>
</li>
<li><p>Lockfiles are not broken</p>
</li>
<li><p>The project is buildable in a clean environment</p>
</li>
</ul>
<p>If this fails, the branch is not deployable.</p>
<h3 id="heading-2-static-analysis">2. Static Analysis</h3>
<p>This ensures code quality and architecture integrity. Static analysis helps prevent common issues like forgotten await, dead code, null safety violations, async misuse, and so on.</p>
<p>Most production bugs aren't business logic errors – they're structural carelessness. Static analysis helps enforce consistency automatically, so code reviews focus on intent, not linting.</p>
<pre><code class="language-yaml">flutter analyze --fatal-infos --fatal-warnings
</code></pre>
<h3 id="heading-3-formatting">3. Formatting</h3>
<p>This command ensures that your code is properly formatted based on your organization's coding standard and policies.</p>
<pre><code class="language-yaml">dart format --output=none --set-exit-if-changed .
</code></pre>
<h3 id="heading-4-tests">4. Tests</h3>
<p>This runs the unit, widget and business logic tests to ensure quality and avoid regression leaks, silent behavior changes and feature drift.</p>
<pre><code class="language-yaml">flutter test --coverage
</code></pre>
<h3 id="heading-5-test-coverage-enforcement">5. Test Coverage Enforcement</h3>
<p>Ideally, running tests is not enough. Your workflow should also enforce a minimum threshold:</p>
<pre><code class="language-yaml">if [ \((lcov --summary coverage/lcov.info | grep lines | awk '{print \)2}' | sed 's/%//') -lt 70 ]; then
  echo "Coverage too low"
  exit 1
fi
</code></pre>
<p>The command above ensures that a minimum test coverage of 70% is met, with this quality becomes measurable.</p>
<p>The five commands above must be checked (at least) for a <strong>quality gate</strong> to guarantee code quality, security, and integrity.</p>
<p>Now here is the full <strong>pr_checks.yml</strong> file:</p>
<pre><code class="language-yaml">name: PR Quality Gate

on:
  pull_request:
    branches: develop
    types: [opened, synchronize, reopened, ready_for_review]

jobs:
  pr-checks:
    name: Run quality checks on this pull request
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Setup Java
        uses: actions/setup-java@v1
        with:
          java-version: "12.x"

      - name: Setup Flutter
        uses: subosito/flutter-action@v1
        with:
          channel: "stable"

      - name: Install dependencies
        run: flutter pub get

      - name: Run quality checks
        run: ./scripts/quality_checks.sh

      - name: Notify Team (Success)
        if: success()
        run: |
          echo "PR Quality Checks PASSED"
          echo "PR: ${{ github.event.pull_request.html_url }}"
          echo "Branch: \({{ github.head_ref }} → \){{ github.base_ref }}"
          echo "By: @${{ github.actor }}"
          echo "Team notification: @foluwaseyi-dev @olabodegbolu"

      - name: Notify Team (Failure)
        if: failure()
        run: |
          echo "PR Quality Checks FAILED"
          echo "PR: ${{ github.event.pull_request.html_url }}"
          echo "Branch: \({{ github.head_ref }} → \){{ github.base_ref }}"
          echo "By: @${{ github.actor }}"
          echo "Please fix the issues before requesting review 🔧"
          echo "Team notification: @foluwaseyi-dev @olabodegbolu"
</code></pre>
<p>Every time a developer opens (or updates) a pull request targeting the <code>develop</code> branch, this workflow kicks in automatically. Think of it as a bouncer at the door: no code gets through without passing inspection first.</p>
<h3 id="heading-what-triggers-it">What Triggers it?</h3>
<p>The workflow fires on four events: when a PR is <code>opened</code>, <code>synchronized</code> (new commits pushed), <code>reopened</code>, or marked <code>ready_for_review</code>. So drafts won't trigger it – only PRs that are actually ready to be looked at.</p>
<h3 id="heading-what-does-it-actually-do">What Does it Actually Do?</h3>
<p>It spins up a fresh Ubuntu machine and runs five steps in sequence:</p>
<ol>
<li><p><strong>Checkout</strong>: pulls down the branch's code</p>
</li>
<li><p><strong>Setup Java 12</strong>: installs the JDK (likely a dependency for some tooling or build process)</p>
</li>
<li><p><strong>Setup Flutter (stable channel)</strong>: this is a Flutter project, so it grabs the stable Flutter SDK</p>
</li>
<li><p><strong>Install dependencies</strong>: runs <code>flutter pub get</code> to pull all Dart/Flutter packages</p>
</li>
<li><p><strong>Run quality checks</strong>: executes the helper shell script (<code>./scripts/quality_checks.sh</code>) that we created which runs linting, tests, formatting checks, or all of the above</p>
</li>
</ol>
<h3 id="heading-the-notification-layer">The Notification Layer</h3>
<p>After the checks run, the workflow reports the verdict and it's context-aware:</p>
<ul>
<li><p><strong>If everything passes</strong>, it logs a success message with the PR URL, branch info, and the person who opened it</p>
</li>
<li><p><strong>If something fails</strong>, it logs a failure message and nudges the author to fix issues before requesting a review</p>
</li>
</ul>
<p>Both outcomes tag <code>@foluwaseyi-dev</code> and <code>@olabodegbolu</code> – the two team members responsible for staying in the loop.</p>
<p>This workflow enforces a "fix it before you merge it" culture. No one can merge broken code into <code>develop</code> without the team knowing about it.</p>
<h2 id="heading-workflow-2-androidyml">Workflow #2: Android.yml</h2>
<p>It's a better practice to split your workflows based on platform. This helps you properly manage the instructions regarding each platform. This is the reason behind keeping the Android workflow separate.</p>
<p>Unlike <code>PR _Checks</code>, this workflow presumes that all checks for quality and standards have been done and the code that runs this workflow already meets the required standards.</p>
<p>Based on our predefined use case, let's create a workflow to handle test deployments when merged to develop or staging, and production level activities when merged to production.</p>
<pre><code class="language-yaml">name: Android Build &amp; Release

on:
  push:
    branches:
      - develop
      - staging
      - production

jobs:
  android:
    runs-on: ubuntu-latest
    env:
      FLUTTER_VERSION: 'stable'

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup Java
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: '11'

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: ${{ env.FLUTTER_VERSION }}

      - name: Install dependencies
        run: flutter pub get

      - name: Determine environment
        id: env
        run: |
          echo "branch=\({GITHUB_REF##*/}" &gt;&gt; \)GITHUB_OUTPUT
          if [ "${GITHUB_REF##*/}" = "develop" ]; then
            echo "ENV=dev" &gt;&gt; $GITHUB_OUTPUT
          elif [ "${GITHUB_REF##*/}" = "staging" ]; then
            echo "ENV=staging" &gt;&gt; $GITHUB_OUTPUT
          else
            echo "ENV=production" &gt;&gt; $GITHUB_OUTPUT
          fi

      # Dev uses hardcoded values no secrets needed
      - name: Generate config (dev)
        if: steps.env.outputs.ENV == 'dev'
        run: ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"

      # Staging and production inject real secrets
      - name: Generate config (staging/production)
        if: steps.env.outputs.ENV != 'dev'
        run: |
          if [ "${{ steps.env.outputs.ENV }}" = "staging" ]; then
            ./scripts/generate_config.sh staging \
              "${{ secrets.STAGING_BASE_URL }}" \
              "${{ secrets.STAGING_API_KEY }}"
          else
            ./scripts/generate_config.sh production \
              "${{ secrets.PROD_BASE_URL }}" \
              "${{ secrets.PROD_API_KEY }}"
          fi

      # Keystore is only needed for signed builds (staging &amp; production)
      - name: Restore Keystore
        if: steps.env.outputs.ENV != 'dev'
        run: |
          echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode &gt; android/app/upload-keystore.jks

      # Production builds are obfuscated + split debug info for Play Store
      - name: Build artifact
        run: |
          if [ "${{ steps.env.outputs.ENV }}" = "production" ]; then
            flutter build appbundle --release \
              --obfuscate \
              --split-debug-info=build/symbols
          else
            flutter build appbundle --release
          fi

      # Dev and staging go to Firebase App Distribution for internal testing
      - name: Upload to Firebase App Distribution
        if: steps.env.outputs.ENV == 'dev' || steps.env.outputs.ENV == 'staging'
        env:
          FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
          FIREBASE_ANDROID_APP_ID: ${{ secrets.FIREBASE_ANDROID_APP_ID }}
          FIREBASE_GROUPS: ${{ secrets.FIREBASE_GROUPS }}
        run: |
          firebase appdistribution:distribute \
            build/app/outputs/bundle/release/app-release.aab \
            --app "$FIREBASE_ANDROID_APP_ID" \
            --groups "$FIREBASE_GROUPS" \
            --token "$FIREBASE_TOKEN"

      # Only production goes to the Play Store
      - name: Upload to Play Store
        if: steps.env.outputs.ENV == 'production'
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
          packageName: com.your.package
          releaseFiles: build/app/outputs/bundle/release/app-release.aab
          track: production

      - name: Notify Team (Success)
        if: success()
        run: |
          echo "Android Build &amp; Release PASSED"
          echo "Environment: ${{ steps.env.outputs.ENV }}"
          echo "Branch: ${{ steps.env.outputs.branch }}"
          echo "By: @${{ github.actor }}"
          echo "Commit: ${{ github.sha }}"

      - name: Notify Team (Failure)
        if: failure()
        run: |
          echo "Android Build &amp; Release FAILED"
          echo "Environment: ${{ steps.env.outputs.ENV }}"
          echo "Branch: ${{ steps.env.outputs.branch }}"
          echo "By: @${{ github.actor }}"
          echo "Commit: ${{ github.sha }}"
          echo "Check the logs and fix the issue before retrying"
</code></pre>
<p>This workflow ensures that whenever code lands on the <strong>develop, staging or production</strong> branch, this action is triggered on a fresh Ubuntu machine.</p>
<p>This is triggered by a simple push to any of the tracked branches, no manual intervention needed.</p>
<p>Let's walk through it piece by piece.</p>
<h3 id="heading-1-the-setup-phase">1. The Setup Phase</h3>
<p>Before any Flutter-specific work happens, the workflow lays the foundation:</p>
<ol>
<li><p><strong>Checkout</strong>: grabs the latest code from the branch that triggered the run (using the more modern <code>actions/checkout@v3</code>).</p>
</li>
<li><p><strong>Java 11 via Temurin</strong>: this is an upgrade from the first workflow we created. Instead of a generic <code>setup-java@v1</code>, this uses the <code>temurin</code> distribution which is the Eclipse's open-source JDK build. It's the current industry standard for Android toolchains.</p>
</li>
<li><p><strong>Flutter (stable)</strong>: this pulls the stable Flutter SDK, version pinned via an environment variable (<code>FLUTTER_VERSION: 'stable'</code>) defined at the job level.</p>
</li>
<li><p><strong>Install dependencies</strong>: this ensures we run <code>flutter pub get</code> to pull all packages</p>
</li>
</ol>
<h3 id="heading-2-environment-detection">2. Environment Detection</h3>
<p>This is where it gets interesting. This workflow also checks and determines the environment which will help us define the next set of instructions to run.</p>
<p>This command reads the branch name from <strong>GITHUB REF</strong> and maps it to its environment label which we already created in one of our helper scripts.</p>
<ul>
<li><p>develop → ENV=dev</p>
</li>
<li><p>staging → ENV=staging</p>
</li>
<li><p>production → ENV=production</p>
</li>
</ul>
<p>It strips the branch name from the full ref path using <code>\({GITHUB_REF##*/}</code>, then writes both the branch name and the resolved <code>ENV</code> value to <code>\)GITHUB_OUTPUT</code>, making them available as named outputs (<code>steps.env.outputs.ENV</code>) for every subsequent step.</p>
<p>This means the rest of the pipeline can branch its behaviour based on which environment it's building for, different API keys, different signing configs, different targets – whatever the app needs.</p>
<h3 id="heading-3-config-injection">3. Config Injection</h3>
<p>With the environment resolved, the next step is injecting the right configuration into the app. This is where the <code>generate_config.sh</code> script we built earlier gets called directly from the workflow.</p>
<p>For the <code>dev</code> environment, hardcoded placeholder values are used. No real secrets are needed, since this build is only meant for internal developer testing:</p>
<pre><code class="language-yaml">- name: Generate config (dev)
  if: steps.env.outputs.ENV == 'dev'
  run: ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"
</code></pre>
<p>For staging and production, however, real secrets are pulled from GitHub Actions secrets and passed directly into the script:</p>
<pre><code class="language-yaml">- name: Generate config (staging/production)
  if: steps.env.outputs.ENV != 'dev'
  run: |
    if [ "${{ steps.env.outputs.ENV }}" = "staging" ]; then
      ./scripts/generate_config.sh staging \
        "${{ secrets.STAGING_BASE_URL }}" \
        "${{ secrets.STAGING_API_KEY }}"
    else
      ./scripts/generate_config.sh production \
        "${{ secrets.PROD_BASE_URL }}" \
        "${{ secrets.PROD_API_KEY }}"
    fi
</code></pre>
<p>Notice that these two steps use an <code>if</code> condition to make them mutually exclusive. Only one will ever run per job. This keeps the pipeline clean: no complicated branching logic inside the script itself, just a clear decision at the workflow level.</p>
<h3 id="heading-4-keystore-restoration">4. Keystore Restoration</h3>
<p>Android requires signed builds for distribution. The signing keystore file cannot be committed to the repository for obvious security reasons, so it's stored as a Base64-encoded GitHub secret and decoded at build time.</p>
<pre><code class="language-yaml">- name: Restore Keystore
  if: steps.env.outputs.ENV != 'dev'
  run: |
    echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode &gt; android/app/upload-keystore.jks
</code></pre>
<p>This step is skipped entirely for the <code>dev</code> environment because dev builds are unsigned debug artifacts meant purely for internal testing on Firebase App Distribution. Only staging and production builds need to be properly signed.</p>
<p>To encode your keystore file as a Base64 string for storing in GitHub secrets, you have to run this locally:</p>
<pre><code class="language-yaml">base64 -i upload-keystore.jks | pbcopy
</code></pre>
<p>This copies the encoded string to your clipboard, which you can then paste directly into your GitHub repository secrets.</p>
<h3 id="heading-5-building-the-artifact">5. Building the Artifact</h3>
<p>With the environment configured and the keystore in place, the workflow builds the app bundle:</p>
<pre><code class="language-yaml">- name: Build artifact
  run: |
    if [ "${{ steps.env.outputs.ENV }}" = "production" ]; then
      flutter build appbundle --release \
        --obfuscate \
        --split-debug-info=build/symbols
    else
      flutter build appbundle --release
    fi
</code></pre>
<p>There's a deliberate difference between how production and non-production builds are compiled.</p>
<p>For production:</p>
<ul>
<li><p><code>--obfuscate</code> renames method and class names in the compiled output, making it significantly harder to reverse engineer the app</p>
</li>
<li><p><code>--split-debug-info=build/symbols</code> extracts the debug symbols into a separate directory at <code>build/symbols</code></p>
</li>
</ul>
<p>These symbols are what <code>upload_symbols.sh</code> later ships to Sentry, so obfuscated crash reports remain readable in your monitoring dashboard.</p>
<p>For dev and staging, neither flag is used. This keeps build times faster and makes local debugging easier since stack traces remain human-readable.</p>
<h3 id="heading-6-distributing-to-firebase-app-distribution">6. Distributing to Firebase App Distribution</h3>
<p>Once the app bundle is built, dev and staging builds are uploaded to Firebase App Distribution so testers can install them immediately:</p>
<pre><code class="language-yaml">- name: Upload to Firebase App Distribution
  if: steps.env.outputs.ENV == 'dev' || steps.env.outputs.ENV == 'staging'
  env:
    FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
    FIREBASE_ANDROID_APP_ID: ${{ secrets.FIREBASE_ANDROID_APP_ID }}
    FIREBASE_GROUPS: ${{ secrets.FIREBASE_GROUPS }}
  run: |
    firebase appdistribution:distribute \
      build/app/outputs/bundle/release/app-release.aab \
      --app "$FIREBASE_ANDROID_APP_ID" \
      --groups "$FIREBASE_GROUPS" \
      --token "$FIREBASE_TOKEN"
</code></pre>
<p>Three secrets power this step:</p>
<ul>
<li><p><code>FIREBASE_TOKEN</code>: the authentication token generated from <code>firebase login:ci</code></p>
</li>
<li><p><code>FIREBASE_ANDROID_APP_ID</code>: the app identifier from the Firebase console</p>
</li>
<li><p><code>FIREBASE_GROUPS</code>: the tester group(s) that should receive the build notification</p>
</li>
</ul>
<p>Once this step completes, every tester in the specified groups receives an email with a direct download link. No one needs to manually share an APK file over Slack or email.</p>
<h3 id="heading-7-deploying-to-the-play-store">7. Deploying to the Play Store</h3>
<p>Production builds skip Firebase entirely and goes straight to the Google Play Store:</p>
<pre><code class="language-yaml">- name: Upload to Play Store
  if: steps.env.outputs.ENV == 'production'
  uses: r0adkll/upload-google-play@v1
  with:
    serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
    packageName: com.your.package
    releaseFiles: build/app/outputs/bundle/release/app-release.aab
    track: production
</code></pre>
<p>This uses the <code>r0adkll/upload-google-play</code> GitHub Action, which handles the Google Play API interaction under the hood. The only requirements are:</p>
<ul>
<li><p>A Google Play service account with the correct permissions, stored as a JSON secret</p>
</li>
<li><p>The correct package name matching what is registered in your Play Console</p>
</li>
<li><p>The <code>track</code> set to <code>production</code> (you can also use <code>internal</code>, <code>alpha</code>, or <code>beta</code> depending on your release strategy)</p>
</li>
</ul>
<p>Replace <code>com.your.package</code> with your actual application ID (the same one defined in your <code>build.gradle</code> file).</p>
<h3 id="heading-8-the-notification-layer">8. The Notification Layer</h3>
<p>Just like the PR checks workflow, this workflow reports its outcome clearly:</p>
<pre><code class="language-yaml">- name: Notify Team (Success)
  if: success()
  run: |
    echo "Android Build &amp; Release PASSED"
    echo "Environment: ${{ steps.env.outputs.ENV }}"
    echo "Branch: ${{ steps.env.outputs.branch }}"
    echo "By: @${{ github.actor }}"
    echo "Commit: ${{ github.sha }}"

- name: Notify Team (Failure)
  if: failure()
  run: |
    echo "Android Build &amp; Release FAILED"
    echo "Environment: ${{ steps.env.outputs.ENV }}"
    echo "Branch: ${{ steps.env.outputs.branch }}"
    echo "By: @${{ github.actor }}"
    echo "Commit: ${{ github.sha }}"
    echo "Check the logs and fix the issue before retrying 🔧"
</code></pre>
<p>The success notification includes the environment, branch, actor, and shares everything needed to trace exactly what was deployed and who triggered it.</p>
<p>The failure notification includes the same context, with a clear call to action.</p>
<h2 id="heading-workflow-3-iosyml">Workflow #3: iOS.yml</h2>
<p>iOS CI/CD is more complex than Android by nature. This is because Apple's signing requirements involve certificates, provisioning profiles, and entitlements that all need to be in the right place before Xcode will produce a valid archive.</p>
<p>Fastlane helps us handles all of that complexity, and the workflow simply calls into it.</p>
<p>Here is the full <code>ios.yml</code>:</p>
<pre><code class="language-yaml">name: iOS Build &amp; Release

on:
  push:
    branches:
      - develop
      - staging
      - production

jobs:
  ios:
    runs-on: macos-latest
    env:
      FLUTTER_VERSION: 'stable'

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: ${{ env.FLUTTER_VERSION }}

      - name: Install dependencies
        run: flutter pub get

      - name: Determine environment
        id: env
        run: |
          echo "branch=\({GITHUB_REF##*/}" &gt;&gt; \)GITHUB_OUTPUT
          if [ "${GITHUB_REF##*/}" = "develop" ]; then
            echo "ENV=dev" &gt;&gt; $GITHUB_OUTPUT
          elif [ "${GITHUB_REF##*/}" = "staging" ]; then
            echo "ENV=staging" &gt;&gt; $GITHUB_OUTPUT
          else
            echo "ENV=production" &gt;&gt; $GITHUB_OUTPUT
          fi

      - name: Generate config (dev)
        if: steps.env.outputs.ENV == 'dev'
        run: ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"

      - name: Generate config (staging/production)
        if: steps.env.outputs.ENV != 'dev'
        run: |
          if [ "${{ steps.env.outputs.ENV }}" = "staging" ]; then
            ./scripts/generate_config.sh staging \
              "${{ secrets.STAGING_BASE_URL }}" \
              "${{ secrets.STAGING_API_KEY }}"
          else
            ./scripts/generate_config.sh production \
              "${{ secrets.PROD_BASE_URL }}" \
              "${{ secrets.PROD_API_KEY }}"
          fi

      - name: Install Fastlane
        run: |
          cd ios
          gem install bundler
          bundle install

      - name: Import signing certificate
        if: steps.env.outputs.ENV != 'dev'
        run: |
          echo "${{ secrets.IOS_CERTIFICATE_BASE64 }}" | base64 --decode &gt; ios/cert.p12
          security create-keychain -p "" build.keychain
          security import ios/cert.p12 -k build.keychain -P "${{ secrets.IOS_CERTIFICATE_PASSWORD }}" -T /usr/bin/codesign
          security list-keychains -s build.keychain
          security default-keychain -s build.keychain
          security unlock-keychain -p "" build.keychain
          security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain

      - name: Install provisioning profile
        if: steps.env.outputs.ENV != 'dev'
        run: |
          echo "${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}" | base64 --decode &gt; profile.mobileprovision
          mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
          cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/

      - name: Build iOS (dev)
        if: steps.env.outputs.ENV == 'dev'
        run: flutter build ios --release --no-codesign

      - name: Build &amp; distribute to TestFlight (staging)
        if: steps.env.outputs.ENV == 'staging'
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
        run: |
          cd ios
          bundle exec fastlane beta

      - name: Build &amp; release to App Store (production)
        if: steps.env.outputs.ENV == 'production'
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
        run: |
          cd ios
          bundle exec fastlane release

      - name: Upload Sentry symbols (production only)
        if: steps.env.outputs.ENV == 'production'
        env:
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
          SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
          SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
        run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)

      - name: Notify Team (Success)
        if: success()
        run: |
          echo "iOS Build &amp; Release PASSED"
          echo "Environment: ${{ steps.env.outputs.ENV }}"
          echo "Branch: ${{ steps.env.outputs.branch }}"
          echo "By: @${{ github.actor }}"
          echo "Commit: ${{ github.sha }}"

      - name: Notify Team (Failure)
        if: failure()
        run: |
          echo "iOS Build &amp; Release FAILED"
          echo "Environment: ${{ steps.env.outputs.ENV }}"
          echo "Branch: ${{ steps.env.outputs.branch }}"
          echo "By: @${{ github.actor }}"
          echo "Commit: ${{ github.sha }}"
          echo "Check the logs and fix the issue before retrying 🔧"
</code></pre>
<p>Let's walk through what is different about this workflow compared to that of android.</p>
<h3 id="heading-1-macos-runner">1. MacOS Runner</h3>
<pre><code class="language-yaml">runs-on: macos-latest
</code></pre>
<p>This is the major difference.</p>
<p>iOS builds require Xcode, which only runs on macOS. GitHub Actions provides hosted macOS runners, but they are significantly more expensive in terms of compute minutes than Ubuntu runners. Just keep that in mind when thinking about build frequency.</p>
<p>No Java setup is needed here. Flutter on iOS compiles through Xcode directly, so the toolchain requirements are different.</p>
<h3 id="heading-2-installing-fastlane">2. Installing Fastlane</h3>
<pre><code class="language-yaml">- name: Install Fastlane
  run: |
    cd ios
    gem install bundler
    bundle install
</code></pre>
<p>Fastlane is a Ruby-based automation tool that handles certificate management, building, and uploading to TestFlight and the App Store.</p>
<p>This step navigates into the <code>ios/</code> directory and installs Fastlane along with all its dependencies as defined in the project's <code>Gemfile</code>.</p>
<p>Your <code>ios/Gemfile</code> should look something like this:</p>
<pre><code class="language-ruby">source "https://rubygems.org"

gem "fastlane"
</code></pre>
<p>And your <code>ios/fastlane/Fastfile</code> should define at minimum two lanes: one for staging (TestFlight) and one for production (App Store):</p>
<pre><code class="language-ruby">default_platform(:ios)

platform :ios do
  lane :beta do
    build_app(scheme: "Runner", export_method: "app-store")
    upload_to_testflight(skip_waiting_for_build_processing: true)
  end

  lane :release do
    build_app(scheme: "Runner", export_method: "app-store")
    upload_to_app_store(force: true, skip_screenshots: true, skip_metadata: true)
  end
end
</code></pre>
<h3 id="heading-3-certificate-and-provisioning-profile-setup">3. Certificate and Provisioning Profile Setup</h3>
<p>This is the step that trips most teams up the first time. Apple's code signing requires two things to be present on the machine:</p>
<ol>
<li><p>The signing certificate (a <code>.p12</code> file)</p>
</li>
<li><p>The provisioning profile</p>
</li>
</ol>
<p>Both are stored as Base64-encoded GitHub secrets and restored at build time.</p>
<pre><code class="language-yaml">- name: Import signing certificate
  if: steps.env.outputs.ENV != 'dev'
  run: |
    echo "${{ secrets.IOS_CERTIFICATE_BASE64 }}" | base64 --decode &gt; ios/cert.p12
    security create-keychain -p "" build.keychain
    security import ios/cert.p12 -k build.keychain -P "${{ secrets.IOS_CERTIFICATE_PASSWORD }}" -T /usr/bin/codesign
    security list-keychains -s build.keychain
    security default-keychain -s build.keychain
    security unlock-keychain -p "" build.keychain
    security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain
</code></pre>
<p>Breaking this down step by step:</p>
<ul>
<li><p>Decodes the Base64 certificate and write it to <code>cert.p12</code></p>
</li>
<li><p>Creates a temporary keychain called <code>build.keychain</code> with an empty password</p>
</li>
<li><p>Imports the certificate into that keychain, granting codesign access</p>
</li>
<li><p>Sets it as the default keychain so Xcode finds it automatically</p>
</li>
<li><p>Unlocks the keychain so it can be used non-interactively</p>
</li>
<li><p>Sets partition list to allow access without repeated prompts</p>
</li>
</ul>
<p>The provisioning profile step is simpler:</p>
<pre><code class="language-yaml">- name: Install provisioning profile
  if: steps.env.outputs.ENV != 'dev'
  run: |
    echo "${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}" | base64 --decode &gt; profile.mobileprovision
    mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
    cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/
</code></pre>
<p>It decodes the profile and copies it into the exact directory where Xcode expects to find provisioning profiles on any macOS system.</p>
<p>To encode your certificate and profile locally, you can run these:</p>
<pre><code class="language-bash">base64 -i Certificates.p12 | pbcopy   # for the certificate
base64 -i YourApp.mobileprovision | pbcopy   # for the provisioning profile
</code></pre>
<h3 id="heading-4-building-for-each-environment">4. Building for Each Environment</h3>
<p>Dev builds skip signing entirely. They're built without code signing just to verify the project compiles correctly on a clean machine:</p>
<pre><code class="language-yaml">- name: Build iOS (dev)
  if: steps.env.outputs.ENV == 'dev'
  run: flutter build ios --release --no-codesign
</code></pre>
<p>Staging builds go through Fastlane's <code>beta</code> lane, which builds and uploads to TestFlight. Production builds go through Fastlane's <code>release</code> lane, which submits directly to App Store Connect.</p>
<p>Both staging and production steps consume the same three App Store Connect API key secrets: the key ID, the issuer ID, and the key content itself.</p>
<p>Fastlane uses these to authenticate with Apple's API without requiring a manual Apple ID login.</p>
<h3 id="heading-5-sentry-symbol-upload">5. Sentry Symbol Upload</h3>
<p>On production iOS builds, the <code>upload_symbols.sh</code> script runs after the build completes, passing the current short commit SHA as the release identifier:</p>
<pre><code class="language-yaml">- name: Upload Sentry symbols (production only)
  if: steps.env.outputs.ENV == 'production'
  env:
    SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
    SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
    SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
  run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
</code></pre>
<p>This is the same script explained earlier in the helper scripts section. It creates a Sentry release, uploads the debug information files, and finalizes the release. Any production crash from this point forward will map back to real, readable source code in your Sentry dashboard.</p>
<h2 id="heading-secrets-and-configuration-reference">Secrets and Configuration Reference</h2>
<p>For this entire pipeline to work, you need to configure the following secrets in your GitHub repository. Go to <strong>Settings → Secrets and variables → Actions → New repository secret</strong> to add each one.</p>
<p><strong>Shared (used across environments):</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>FIREBASE_TOKEN</code></td>
<td>Generated via <code>firebase login:ci</code> on your local machine</td>
</tr>
<tr>
<td><code>FIREBASE_ANDROID_APP_ID</code></td>
<td>Android app ID from your Firebase console</td>
</tr>
<tr>
<td><code>FIREBASE_GROUPS</code></td>
<td>Comma-separated tester group names in Firebase</td>
</tr>
<tr>
<td><code>SENTRY_AUTH_TOKEN</code></td>
<td>Auth token from your Sentry account settings</td>
</tr>
<tr>
<td><code>SENTRY_ORG</code></td>
<td>Your Sentry organization slug</td>
</tr>
<tr>
<td><code>SENTRY_PROJECT</code></td>
<td>Your Sentry project slug</td>
</tr>
</tbody></table>
<p><strong>Staging:</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>STAGING_BASE_URL</code></td>
<td>Your staging API base URL</td>
</tr>
<tr>
<td><code>STAGING_API_KEY</code></td>
<td>Your staging API or encryption key</td>
</tr>
</tbody></table>
<p><strong>Production:</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>PROD_BASE_URL</code></td>
<td>Your production API base URL</td>
</tr>
<tr>
<td><code>PROD_API_KEY</code></td>
<td>Your production API or encryption key</td>
</tr>
</tbody></table>
<p><strong>Android:</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>ANDROID_KEYSTORE_BASE64</code></td>
<td>Base64-encoded <code>.jks</code> keystore file</td>
</tr>
<tr>
<td><code>GOOGLE_PLAY_SERVICE_ACCOUNT_JSON</code></td>
<td>Full JSON content of your Play Console service account</td>
</tr>
</tbody></table>
<p><strong>iOS:</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>IOS_CERTIFICATE_BASE64</code></td>
<td>Base64-encoded <code>.p12</code> signing certificate</td>
</tr>
<tr>
<td><code>IOS_CERTIFICATE_PASSWORD</code></td>
<td>Password protecting the <code>.p12</code> file</td>
</tr>
<tr>
<td><code>IOS_PROVISIONING_PROFILE_BASE64</code></td>
<td>Base64-encoded <code>.mobileprovision</code> file</td>
</tr>
<tr>
<td><code>APP_STORE_CONNECT_API_KEY_ID</code></td>
<td>Key ID from App Store Connect → Users &amp; Access → Keys</td>
</tr>
<tr>
<td><code>APP_STORE_CONNECT_API_ISSUER_ID</code></td>
<td>Issuer ID from the same App Store Connect page</td>
</tr>
<tr>
<td><code>APP_STORE_CONNECT_API_KEY_CONTENT</code></td>
<td>The full content of the downloaded <code>.p8</code> key file</td>
</tr>
</tbody></table>
<p>None of these values should ever appear in your codebase. If any secret is accidentally committed, rotate it immediately.</p>
<h2 id="heading-end-to-end-flow">End-to-End Flow</h2>
<p>With all three workflows in place, here is exactly what happens from the moment a developer opens a pull request to the moment a user receives an update:</p>
<h3 id="heading-1-developer-opens-a-pr-into-develop">1. Developer Opens a PR into <code>develop</code></h3>
<p>The <code>pr_checks.yml</code> workflow fires. It runs formatting checks, static analysis, and the full test suite. If anything fails, the PR cannot be merged and the team is notified immediately. The developer fixes the issues and pushes again, which triggers a fresh run.</p>
<h3 id="heading-2-pr-is-approved-and-merged-into-develop">2. PR is Approved and Merged into <code>develop</code></h3>
<p>The <code>android.yml</code> and <code>ios.yml</code> workflows both fire on the push event. They detect the environment as <code>dev</code>, inject placeholder config, build unsigned artifacts, and upload them to Firebase App Distribution. Testers receive an email and can install the build on their devices within minutes – no one shared a file manually.</p>
<h3 id="heading-3-develop-is-merged-into-staging">3. <code>develop</code> is Merged into <code>staging</code></h3>
<p>Both platform workflows fire again. This time the environment resolves to <code>staging</code>. Real secrets are injected, builds are properly signed, and the artifacts go to Firebase App Distribution (Android) and TestFlight (iOS). QA begins testing the staging build against the staging API.</p>
<h3 id="heading-4-staging-is-merged-into-production">4. <code>staging</code> is merged into <code>production</code></h3>
<p>Both workflows fire one final time. Production secrets are injected, builds are obfuscated and signed, debug symbols are uploaded to Sentry, and the final artifacts are submitted to the Google Play Store and App Store Connect. The release goes live on Apple and Google's review timelines with no further human intervention required.</p>
<p>From that first PR to a production submission, not a single command was run manually.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Building this pipeline is an upfront investment that pays off from the very first release cycle. What used to be a sequence of error-prone manual steps building locally, signing, uploading, switching configs, and hoping nothing was mixed up is now a fully automated, auditable, and repeatable process that runs the moment code moves between branches.</p>
<p>The architecture we built here does more than just automate builds. The PR quality gate enforces team standards consistently, so code review becomes a conversation about intent rather than a hunt for formatting issues. The environment-aware config injection eliminates an entire class of production incidents where staging keys made it into a live release. The Sentry symbol upload means your team can debug production crashes with full source visibility even from an obfuscated binary.</p>
<p>Every piece of this pipeline also runs locally. The helper scripts in the <code>scripts/</code> folder are plain Bash so you can call them from your terminal the same way CI calls them. This eliminates the frustrating cycle of pushing a commit just to test a pipeline change.</p>
<p>As your team grows, this foundation scales with you. You can extend the <code>pr_checks.yml</code> to enforce code coverage thresholds, add a performance benchmarking job, or introduce a dedicated security scanning step. You can extend the platform workflows to support multiple flavors, multiple Firebase projects, or staged rollouts on the Play Store. The architecture stays the same – you're just adding new steps to an already working system.</p>
<p>This ensures that standards are met, code quality remains high, you have a proper team structure, clear process and automated post development activities are in place – and at the end of the day, you'll have an optimized engineering approach that will help your team in so many ways.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an AI-Powered Research Automation System with n8n, Groq, and Academic APIs ]]>
                </title>
                <description>
                    <![CDATA[ As a researcher and developer, I found myself spending hours manually searching academic databases, reading abstracts, and trying to synthesize findings across multiple sources. For my work on circula ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-an-ai-powered-research-automation-system-with-n8n-groq-and-academic-apis/</link>
                <guid isPermaLink="false">69b849372ad6ae5184dbb6b8</guid>
                
                    <category>
                        <![CDATA[ n8n ]]>
                    </category>
                
                    <category>
                        <![CDATA[ freeCodeCamp.org ]]>
                    </category>
                
                    <category>
                        <![CDATA[ General Programming ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Chidozie Managwu ]]>
                </dc:creator>
                <pubDate>Mon, 16 Mar 2026 18:17:27 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/d4660bc7-3f3c-4325-bee7-57770e821204.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>As a researcher and developer, I found myself spending hours manually searching academic databases, reading abstracts, and trying to synthesize findings across multiple sources.</p>
<p>For my work on circular economy and battery recycling, I needed a way to query multiple databases at once without the manual fatigue.</p>
<p>In this tutorial, you'll build an automated research pipeline using n8n that reduces roughly six hours of manual literature review into a five-minute automated process.</p>
<p>This isn’t a “cool demo workflow.” It’s a production-minded pipeline with parallel collection, normalisation, deduplication, structured AI extraction, scoring, and practical error handling.</p>
<h3 id="heading-table-of-contents">Table of Contents</h3>
<ol>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-problem-research-takes-too-long">The Problem: Research Takes Too Long</a></p>
</li>
<li><p><a href="#heading-the-tech-stack">The Tech Stack</a></p>
</li>
<li><p><a href="#heading-the-project-structure-how-to-think-about-an-n8n-workflow-like-software">The Project Structure: How to Think About an n8n Workflow Like Software</a></p>
</li>
<li><p><a href="#heading-stage-1-centralized-configuration">Stage 1: Centralised Configuration</a></p>
</li>
<li><p><a href="#heading-stage-2-parallel-api-collection=with-failure-isolation">Stage 2: Parallel API Collection (With Failure Isolation)</a></p>
</li>
<li><p><a href="#heading-stage-3-normalisation-and-deduplication-doifirst-title-fallback">Stage 3: Normalisation and Deduplication (DOI-first, Title fallback)</a></p>
</li>
<li><p><a href="#heading-stage-4-aipowered-content-extraction-strict-json">Stage 4: AI-Powered Content Extraction (Strict JSON)</a></p>
</li>
<li><p><a href="#heading-stage-5-scoring-and-synthesis">Stage 5: Scoring and Synthesis</a></p>
</li>
<li><p>[Beginner-Friendly Evals (Retrieval and Extraction QA)(#heading-beginnerfriendly-evals-retrieval-and-extraction-qa)</p>
</li>
<li><p><a href="#heading-key-learnings-and-error-handling">Key Learnings and Error Handling</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You don’t need to be a DevOps engineer to follow this, but you should have:</p>
<ul>
<li><p>Basic comfort with APIs and JSON (request/response payloads)</p>
</li>
<li><p>Familiarity with spreadsheets (Google Sheets basics)</p>
</li>
<li><p>Willingness to use a small amount of JavaScript inside n8n Function/Code nodes</p>
</li>
</ul>
<p>Access to:</p>
<ul>
<li><p>An n8n instance (self-hosted or cloud)</p>
</li>
<li><p>A Groq API key (or a compatible LLM provider)</p>
</li>
<li><p>Optional API keys, depending on the databases you use</p>
</li>
</ul>
<p>What you’ll build assumes:</p>
<ul>
<li><p>You’re extracting from metadata + abstracts (not downloading full PDFs).</p>
</li>
<li><p>You can accept that some sources will occasionally rate-limit or return partial results (and your workflow will be designed to survive this).</p>
</li>
</ul>
<h2 id="heading-the-problem-research-takes-too-long">The Problem: Research Takes Too Long</h2>
<p>Manual research is often a bottleneck for innovation. Before building this automation, my workflow involved searching multiple academic databases, scanning abstracts, and manually extracting key findings. This process was not only slow but also prone to human error and inconsistent note-taking.</p>
<p>The goal of this automation is to provide a “full-stack research assistant” that handles the heavy lifting of collecting candidate papers, removing duplicates, extracting consistent fields, scoring relevance and quality, and delivering a curated daily or weekly report, so you can spend your time on high-level synthesis rather than repetitive collection.</p>
<h2 id="heading-the-tech-stack">The Tech Stack</h2>
<p>This workflow leverages a combination of automation tooling, high-speed LLM inference, and academic metadata providers.</p>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td>n8n</td>
<td>The workflow engine that orchestrates all steps</td>
</tr>
<tr>
<td>Groq</td>
<td>Runs a fast LLM (for example, Llama 3.3 70B) for structured extraction/synthesis</td>
</tr>
<tr>
<td>Semantic Scholar / OpenAlex</td>
<td>Broad academic coverage for metadata, abstracts, citations</td>
</tr>
<tr>
<td>arXiv / PubMed</td>
<td>Strong specialised coverage (preprints, life sciences)</td>
</tr>
<tr>
<td>Google Sheets</td>
<td>A lightweight “research database” for storage + history</td>
</tr>
</tbody></table>
<p>Notes: coverage varies by provider. Some APIs return abstracts reliably, while others may omit them. Your pipeline should treat missing abstracts as a normal case, not a failure.</p>
<h2 id="heading-the-project-structure-how-to-think-about-an-n8n-workflow-like-software">The Project Structure: How to Think About an n8n Workflow Like Software</h2>
<p>While n8n is a visual tool, it helps to design your workflow as modular stages to avoid the “spaghetti workflow” problem.</p>
<pre><code class="language-text">.
├── configuration/         # Keywords, thresholds, limits, date filters
├── collectors/            # Parallel HTTP request nodes (multiple sources)
├── processing/            # Normalization + deduplication code nodes
├── extraction/            # LLM extraction nodes (strict JSON)
├── scoring/               # Relevance + quality scoring + filtering
└── delivery/              # Google Sheets + email/HTML report
</code></pre>
<p>Design principle: each stage should produce a clean, predictable output shape that the next stage can rely on.</p>
<h2 id="heading-stage-1-centralised-configuration">Stage 1: Centralised Configuration</h2>
<p>Instead of hardcoding search parameters (keywords, min year, citation thresholds) across multiple nodes, use one configuration node to define workflow variables.</p>
<p>This matters for maintainability (change a value once, not in ten nodes), reusability (repurpose the entire pipeline by swapping one config object), and debuggability (log the config at the start of each run so you can reproduce results).</p>
<p>Use a Set node, or a Code node returning JSON like this:</p>
<pre><code class="language-json">{
  "keywords": "circular economy battery recycling remanufacturing",
  "min_year": 2020,
  "max_results_per_source": 10,
  "min_citations": 2,
  "relevance_threshold": 15,
  "batch_size": 10
}
</code></pre>
<p>Tip: keep numeric fields as numbers (not strings) to avoid scoring bugs later.</p>
<h2 id="heading-stage-2-parallel-api-collection-with-failure-isolation">Stage 2: Parallel API Collection (With Failure Isolation)</h2>
<p>Your workflow should query multiple sources simultaneously. In n8n, you can branch from your configuration node into multiple HTTP Request nodes, and then merge results later.</p>
<p>The production mindset here is simple: APIs fail. Rate limits happen. Providers return partial data. The key is to prevent one failing collector from crashing the whole run.</p>
<p>To implement this, on each HTTP Request node, enable <strong>Continue On Fail</strong> (or the equivalent “don’t stop workflow” behaviour). Then, in the normalisation stage, treat missing or failed outputs as empty arrays so downstream stages still run.</p>
<p>In practice, it also helps to set explicit timeouts and add a small retry policy (one to two retries) for transient failures. “Good” looks like this: if two out of five sources fail, you still produce a useful report from the remaining three, and you log which sources failed so you can investigate later.</p>
<h2 id="heading-stage-3-normalisation-and-deduplication-doi-first-title-fallback">Stage 3: Normalisation and Deduplication (DOI-first, Title fallback)</h2>
<p>Each academic API returns different field names and shapes. One might use <code>title</code>, another <code>display_name</code>, another <code>paper_title</code>. Your next stage should normalise all inputs into one schema.</p>
<h3 id="heading-target-normalised-schema">Target normalised schema</h3>
<p>Here’s a simple baseline schema (expand later as needed):</p>
<pre><code class="language-json">{
  "title": "string",
  "abstract": "string|null",
  "doi": "string|null",
  "year": 2024,
  "citations": 12,
  "url": "string|null",
  "source": "Semantic Scholar|OpenAlex|arXiv|PubMed"
}
</code></pre>
<h3 id="heading-what-deduping-by-doi-means-and-what-a-doi-is">What deduping by DOI means (and what a DOI is)</h3>
<p>A <strong>DOI</strong> (Digital Object Identifier) is a unique, persistent identifier assigned to many scholarly publications. If a paper has a DOI, that DOI functions like a stable ID: the same paper may appear in multiple databases with slightly different metadata, but the DOI should remain consistent.</p>
<p>So, <strong>deduping by DOI</strong> means: if two records share the same DOI, treat them as the same paper and keep only one.</p>
<p>When a DOI is missing (which is common for some preprints and some API responses), the fallback is to dedupe using a normalised title key, lowercased, trimmed, punctuation stripped, and whitespace collapsed. It’s not as perfect as DOI-based matching, but it’s a strong pragmatic backup.</p>
<h3 id="heading-what-normalise-into-a-unified-object-means-whats-happening-in-the-code">What “normalise into a unified object” means (what’s happening in the code)</h3>
<p>“Normalise into a unified object” simply means converting every provider’s raw response into the same predictable shape (the schema above). Once everything looks the same, downstream steps, such as deduplication, scoring, AI extraction, and storage, become straightforward because they don’t need provider-specific logic.</p>
<p>In the code below, that’s what the <code>normalized</code> object is: it maps Semantic Scholar’s fields (<code>paper.title</code>, <code>paper.externalIds.DOI</code>, <code>paper.citationCount</code>) into your standard fields (<code>title</code>, <code>doi</code>, <code>citations</code>, etc.). After that, the workflow generates a dedupe key (<code>doi:...</code> if DOI exists, otherwise <code>title:...</code>) and uses a <code>Set</code> to keep only the first occurrence.</p>
<h4 id="heading-example-n8n-code-node-normalisation-dedupe-pattern">Example n8n Code Node (Normalisation + Dedupe Pattern)</h4>
<pre><code class="language-javascript">const itemsIn = $input.all();

const seen = new Set();
const results = [];

function titleKey(t) {
  return (t || "")
    .toLowerCase()
    .replace(/[\W_]+/g, " ")
    .replace(/\s+/g, " ")
    .trim();
}

for (const item of itemsIn) {
  // Example: Semantic Scholar response shape
  const papers = item.json?.data || [];

  for (const paper of papers) {
    // "Normalize into a unified object":
    // take the provider-specific fields and map them into our standard schema.
    const normalized = {
      title: paper.title || null,
      abstract: paper.abstract || null,
      doi: paper.externalIds?.DOI || null,
      year: paper.year || null,
      citations: paper.citationCount || 0,
      url: paper.url || null,
      source: "Semantic Scholar",
    };

    if (!normalized.title) continue;

    // Dedupe key: DOI is strongest; title is fallback
    const key = normalized.doi
      ? `doi:${normalized.doi.toLowerCase()}`
      : `title:${titleKey(normalized.title)}`;

    if (seen.has(key)) continue;
    seen.add(key);

    results.push(normalized);
  }
}

return results.map(r =&gt; ({ json: r }));
</code></pre>
<p>Production-minded note: keep a field like <code>source</code> so you can debug where bad metadata is coming from later.</p>
<h2 id="heading-stage-4-ai-powered-content-extraction-strict-json">Stage 4: AI-Powered Content Extraction (Strict JSON)</h2>
<p>Once you have a deduplicated list of papers, you can send each paper (or a small batch) to Groq for structured extraction.</p>
<h3 id="heading-why-structured-output-matters">Why structured output matters</h3>
<p>If your LLM returns narrative text instead of JSON, misses fields, or emits malformed JSON, your workflow breaks downstream. In a production workflow, that’s not a rare edge case; it’s something you should expect and design around.</p>
<p>That’s why you’ll use strict schema prompting <em>and</em> validate responses downstream.</p>
<h3 id="heading-system-prompt-vs-user-prompt-and-how-to-compose-them">System prompt vs user prompt (and how to compose them)</h3>
<p>A helpful way to think about prompts in production is:</p>
<ul>
<li><p>The <strong>system prompt</strong> defines the <em>non-negotiable contract</em>: output format, allowed keys, no commentary, and what to do in uncertain cases. This is where you say “return ONLY valid JSON” and “no extra keys.”</p>
</li>
<li><p>The <strong>user prompt</strong> provides the <em>variable data</em> for this specific request: title, year, citations, abstract, and the exact schema you want filled.</p>
</li>
</ul>
<p>Composing them this way keeps your workflow stable. The system prompt stays mostly constant (your formatting contract), while the user prompt changes per paper (your payload). It also makes debugging easier: if outputs start failing, you can adjust the system constraints without rewriting every payload template.</p>
<h3 id="heading-suggested-extraction-schema">Suggested extraction schema</h3>
<p>Extract only what you can support from abstract-level data:</p>
<ul>
<li><p><code>research_question</code></p>
</li>
<li><p><code>methodology</code></p>
</li>
<li><p><code>key_findings</code></p>
</li>
<li><p><code>limitations</code></p>
</li>
<li><p><code>notes</code> (for missing abstract / ambiguity)</p>
</li>
</ul>
<h3 id="heading-example-prompt-system-user">Example prompt (system + user)</h3>
<p><strong>System:</strong></p>
<p>You are a research extraction engine. You must return ONLY valid JSON.<br>No markdown. No extra keys. No commentary.<br>If the abstract is missing or too vague, set fields to null and include a reason in "notes".</p>
<p><strong>User:</strong></p>
<p>Extract structured fields from this paper.</p>
<p>TITLE: {{title}}<br>YEAR: {{year}}<br>CITATIONS: {{citations}}<br>ABSTRACT: {{abstract}}</p>
<p>Return JSON with keys:<br>research_question (string|null)<br>methodology (string|null)<br>key_findings (array of strings)<br>limitations (array of strings)<br>notes (string)</p>
<p>Model settings: keep temperature low (around 0.2–0.3) and keep responses short and structured.</p>
<h3 id="heading-batch-processing-to-avoid-timeouts">Batch processing to avoid timeouts</h3>
<p>Instead of sending 50 papers at once, process them in batches (for example, 10). This reduces latency spikes, failure blast radius, and cost surprises. Smaller batches also make it easier to retry only the failing chunk rather than re-running everything.</p>
<h2 id="heading-stage-5-scoring-and-synthesis">Stage 5: Scoring and Synthesis</h2>
<p>Not every retrieved paper is worth your time. Without scoring, your pipeline becomes a firehose: you’ve automated collection, but you still have to manually decide what to read. Scoring is what turns “a big list of results” into a shortlist you can trust.</p>
<p>I recommend computing two signals:</p>
<ul>
<li><p><strong>Relevance</strong>: Is this actually about your research question?</p>
</li>
<li><p><strong>Quality/priority</strong>: If it’s relevant, is it worth reading first?</p>
</li>
</ul>
<p>For <strong>relevance</strong>, keep it simple and explainable. Count keyword hits in the title and abstract (and optionally in extracted <code>key_findings</code>). Title matches should be weighted higher because titles are deliberately compact summaries. Abstract hits are useful too, but cap them so long abstracts don’t dominate the score.</p>
<p>For <strong>quality/priority</strong>, use lightweight metadata you already have. Recency is a strong signal in fast-moving areas, and citations can help, but they should be treated as a weak signal (and capped) so newer high-value papers aren’t unfairly penalised.</p>
<p>A solid first scoring model is: add a title bonus, add a capped abstract bonus, add a capped citations bonus, and add a small recency bonus for papers from the last two years. Then filter using the <code>relevance_threshold</code> results from Stage 1. The advantage of this approach is that it’s easy to debug and tune: you can always explain why a paper passed or failed.</p>
<p>Once you’ve filtered down to your “gold” set, synthesis becomes safer and more useful. Write one row per accepted paper to Google Sheets, then generate a daily/weekly HTML summary (for example, top 5 papers with 1–2 key findings each) and include links so you can verify quickly.</p>
<h2 id="heading-beginner-friendly-evals-retrieval-and-extraction-qa">Beginner-Friendly Evals: Retrieval and Extraction QA</h2>
<p>AI workflows regress silently. A prompt tweak, a model update, or an API schema change can break extraction without throwing an obvious error. Adding lightweight evals is the difference between “it worked last week” and “it’s reliable.”</p>
<p>The goal here isn’t to build a full evaluation framework. It’s to add small, cheap checks that catch the most common failure modes:</p>
<ul>
<li><p>Are collectors still returning results?</p>
</li>
<li><p>Are we actually removing duplicates?</p>
</li>
<li><p>Is the LLM returning valid JSON with the keys we require?</p>
</li>
</ul>
<h3 id="heading-what-it-looks-like-in-n8n-a-concrete-example">What it looks like in n8n (a concrete example)</h3>
<p>A simple implementation is to add an <strong>“Assertions” Code node</strong> immediately after your extraction step, plus (optionally) another one after normalisation/deduplication.</p>
<p>At a high level, the workflow section looks like:</p>
<ol>
<li><p>Collectors (parallel HTTP Request nodes)</p>
</li>
<li><p>Merge results</p>
</li>
<li><p>Normalise + dedupe (Code node)</p>
</li>
<li><p>Split in Batches (optional)</p>
</li>
<li><p>LLM extraction (Groq/OpenAI-compatible node)</p>
</li>
<li><p><strong>Assertions (Code node)</strong></p>
</li>
<li><p>If node (pass/fail)</p>
</li>
<li><p>Delivery (Sheets + email)</p>
</li>
</ol>
<h3 id="heading-example-assertions-code-node-after-extraction">Example: Assertions code node after extraction</h3>
<p>This code node assumes each item is a paper with:</p>
<ul>
<li><p><code>title</code>, <code>abstract</code> in the normalised fields, and</p>
</li>
<li><p>an <code>extraction</code> field (or whatever you name it) containing the LLM response as an object or JSON string.</p>
</li>
</ul>
<p>Adapt the field name to match your actual node output, but the pattern is the same: parse, validate required keys, compute percentages, then decide whether to fail or warn.</p>
<pre><code class="language-javascript">const items = $input.all();

let total = items.length;
let withTitle = 0;
let withAbstract = 0;

let parseOk = 0;
let schemaOk = 0;

const requiredKeys = [
  "research_question",
  "methodology",
  "key_findings",
  "limitations",
  "notes",
];

const failures = [];

for (let i = 0; i &lt; items.length; i++) {
  const p = items[i].json;

  if (p.title &amp;&amp; String(p.title).trim().length &gt; 0) withTitle++;
  if (p.abstract &amp;&amp; String(p.abstract).trim().length &gt; 0) withAbstract++;

  // Adjust this depending on where you store the model output:
  const raw = p.extraction ?? p.llm ?? p.model_output;

  let obj = null;
  try {
    obj = typeof raw === "string" ? JSON.parse(raw) : raw;
    parseOk++;
  } catch (e) {
    failures.push({ index: i, title: p.title || null, reason: "JSON parse failed" });
    continue;
  }

  const hasAllKeys = requiredKeys.every(k =&gt; Object.prototype.hasOwnProperty.call(obj, k));
  if (!hasAllKeys) {
    failures.push({ index: i, title: p.title || null, reason: "Missing required keys" });
    continue;
  }

  // Optional: ensure arrays are arrays
  const arraysOk = Array.isArray(obj.key_findings) &amp;&amp; Array.isArray(obj.limitations);
  if (!arraysOk) {
    failures.push({ index: i, title: p.title || null, reason: "key_findings/limitations not arrays" });
    continue;
  }

  schemaOk++;
}

const pct = (n) =&gt; (total === 0 ? 0 : Math.round((n / total) * 100));

const report = {
  total_papers: total,
  pct_with_title: pct(withTitle),
  pct_with_abstract: pct(withAbstract),
  pct_extraction_json_parse_ok: pct(parseOk),
  pct_extraction_schema_ok: pct(schemaOk),
  failures_sample: failures.slice(0, 5),
};

// Decide pass/fail thresholds
const HARD_FAIL_PARSE_BELOW = 90;
const HARD_FAIL_SCHEMA_BELOW = 85;

const shouldFail =
  report.pct_extraction_json_parse_ok &lt; HARD_FAIL_PARSE_BELOW ||
  report.pct_extraction_schema_ok &lt; HARD_FAIL_SCHEMA_BELOW;

return [
  {
    json: {
      eval_report: report,
      shouldFail,
    },
  },
];
</code></pre>
<p>Then add an <strong>If node</strong>:</p>
<ul>
<li><p>If <code>shouldFail</code> is true, then route to an “Alert/Stop” branch (Slack/email/log) and optionally stop the workflow.</p>
</li>
<li><p>If false, then continue to the delivery stage.</p>
</li>
</ul>
<p>This is the automation equivalent of unit tests: small, cheap, and extremely effective. It also gives you a concrete paper trail when something changes upstream.</p>
<h2 id="heading-key-learnings-and-error-handling">Key Learnings and Error Handling</h2>
<p>Building this automation taught me that the best workflows are designed for failure.</p>
<p>First, error resilience is not optional. Never let one failing API crash the workflow. Use “Continue On Fail” on your HTTP nodes, merge partial results, and log which sources failed in your final report so you can debug without losing an entire run.</p>
<p>Second, batching is your friend. Process papers in batches (often 5–15) to reduce timeouts and cost spikes. Keep LLM payloads small and focused on what you actually need (metadata + abstract), and retry transient failures once rather than repeatedly hammering the model or API.</p>
<p>Third, structured prompting is what makes AI reliable in automation. A strict JSON schema is the difference between a workflow that runs unattended and one that breaks randomly. Keep temperature low, enforce the schema in the system prompt, and validate everything downstream with simple parse-and-assert checks.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>A good research pipeline doesn’t just retrieve papers – it turns scattered results into a consistent, deduplicated, scored, and review-ready shortlist you can trust.</p>
<p>By treating your n8n workflow like software modular stages, strict contracts between steps, and lightweight eval checks, you can reduce hours of manual literature review into a fast, repeatable process that survives real-world API failures and model quirks.</p>
<p>If you build this with good defaults (failure isolation, batching, normalisation, strict JSON extraction, and simple scoring), you end up with something you can run daily or weekly and actually rely on without the manual fatigue.</p>
<h3 id="heading-about-me">About Me</h3>
<p>I am Chidozie Managwu, an award-winning AI Product Architect and founder focused on helping global tech talent build real, production-ready skills. I contribute to global AI initiatives as a GAFAI Delegate and lead the AI Titans Network, a community for developers learning how to ship AI products.</p>
<p>My work has been recognised with the Global Tech Hero award and featured on platforms like HackerNoon.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Penetration Testing — Services vs Automated Platforms: What’s Better in 2026? ]]>
                </title>
                <description>
                    <![CDATA[ In 2026, cybersecurity teams face more threats than ever before. Attack surfaces are broad, technology stacks are complex, and adversaries are quick to exploit weak points. Against this backdrop, comp ]]>
                </description>
                <link>https://www.freecodecamp.org/news/penetration-testing-services-vs-automated-platforms-what-is-better/</link>
                <guid isPermaLink="false">69b843d22ad6ae5184d73e34</guid>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cybersecurity ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pentesting ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Mon, 16 Mar 2026 17:54:26 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/820ccff8-9ef7-4b12-a7a9-113c5a71abdc.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In 2026, cybersecurity teams face more threats than ever before.</p>
<p>Attack surfaces are broad, technology stacks are complex, and adversaries are quick to exploit weak points.</p>
<p>Against this backdrop, companies must decide how best to test their defences.</p>
<p>Two main approaches have emerged as leaders: human-led penetration testing services and automated testing platforms. Each has strengths and limitations. Choosing the right one depends on your security goals, risk tolerance, and budget.</p>
<p>At its core, <a href="https://www.cloudflare.com/learning/security/glossary/what-is-penetration-testing/">penetration testing</a> is about finding security holes before attackers do. But how you get there matters.</p>
<p>Human experts bring creativity and real-world insight, while automated platforms offer scale and speed.</p>
<p>This article explores both approaches and compares top providers to help you decide what’s better for your organization in 2026.</p>
<h3 id="heading-what-well-cover">What we'll cover:</h3>
<ol>
<li><p><a href="#heading-what-are-penetration-testing-services">What Are Penetration Testing Services?</a></p>
</li>
<li><p><a href="#heading-what-are-automated-penetration-testing-platforms">What Are Automated Penetration Testing Platforms?</a></p>
</li>
<li><p><a href="#heading-why-the-debate-matters-in-2026">Why the Debate Matters in 2026</a></p>
<ul>
<li><p><a href="#heading-depth-of-testing-humans-vs-machines">Depth of Testing: Humans vs Machines</a></p>
</li>
<li><p><a href="#heading-speed-and-frequency-of-testing">Speed and Frequency of Testing</a></p>
</li>
<li><p><a href="#heading-cost-considerations">Cost Considerations</a></p>
</li>
<li><p><a href="#heading-integration-with-security-workflows">Integration with Security Workflows</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-real-world-context-top-providers-in-2026">Real World Context: Top Providers in 2026</a></p>
</li>
<li><p><a href="#heading-compliance-and-reporting">Compliance and Reporting</a></p>
</li>
<li><p><a href="#heading-which-one-should-you-choose-in-2026">Which One Should You Choose in 2026?</a></p>
</li>
<li><p><a href="#heading-final-thoughts">Final Thoughts</a></p>
</li>
</ol>
<h2 id="heading-what-are-penetration-testing-services">What Are Penetration Testing Services?</h2>
<p>Penetration testing services are engagements where cybersecurity professionals actively probe your systems to find vulnerabilities. These experts use a mix of tools, manual techniques, and real-world attack simulations to surface weaknesses that machines might miss.</p>
<p>These services may include scheduled tests, one-time assessments, and ongoing engagements. Many providers tailor their approach to the environment being tested, whether that’s a corporate network, web application, cloud infrastructure, or mobile ecosystem.</p>
<p>Human testers think like attackers, combining automated scans with logic and adaptability that machines cannot replicate on their own.</p>
<p>These engagements are typically measured in reports, debrief sessions, and clear remediation guidance. The human element is the defining factor. A skilled tester doesn’t just find flaws. They understand context, creative exploit paths, and business impact.</p>
<h2 id="heading-what-are-automated-penetration-testing-platforms">What Are Automated Penetration Testing Platforms?</h2>
<p>Automated penetration testing platforms use software to scan, crawl, and test systems for vulnerabilities. These platforms run scheduled scans or continuous assessments with minimal human intervention. They aim to find flaws early and often, integrating with development pipelines or security operations centers.</p>
<p>Automation brings consistency, speed, and the ability to repeat tests frequently. Many modern platforms use machine learning to prioritize findings and reduce noise. Some offer automation rules that trigger scans based on changes in the environment or codebase.</p>
<p>In contrast to full manual services, platforms are best suited for ongoing baseline assessments and rapid feedback. They are often priced in subscription models and integrate with other tooling like bug tracking systems or <a href="https://www.ibm.com/think/topics/siem">SIEMs</a>. While they can pinpoint known vulnerability patterns efficiently, automated tools are limited in creative attack paths and logic-based exploits.</p>
<h2 id="heading-why-the-debate-matters-in-2026">Why the Debate Matters in&nbsp;2026</h2>
<p>In 2026, the cybersecurity landscape is both more advanced and more hazardous. Organizations operate hybrid clouds, microservices architectures, and complex supply chains.</p>
<p>Threat actors are using AI to scale attacks. In this environment, the question is not only about finding old vulnerabilities but anticipating novel attack methods.</p>
<p>With limited resources, security leaders must choose wisely. Do you invest heavily in services with human experts? Do you adopt automated platforms that test continuously?</p>
<p>Maybe a mix is best. To answer these questions, let’s explore how the two approaches compare across key criteria.</p>
<h3 id="heading-depth-of-testing-humans-vs-machines">Depth of Testing: Humans vs&nbsp;Machines</h3>
<p>Human-led penetration tests shine when deep context and logic are required. Expert testers can chain together multiple issues to compromise a system in ways automated tools don't anticipate. They explore paths, think creatively, and adapt in real time to the environment they encounter.</p>
<p>Automated platforms excel at breadth and repetition. They perform wide sweeps of systems quickly and can generate alerts on common vulnerability classes. They're particularly strong in repetitive tasks like scanning hundreds of endpoints or validating compliance controls.</p>
<p>But platforms often rely on predefined signatures and patterns. They perform poorly when an exploit requires intuition or lateral thinking.</p>
<p>In simple terms, human services dig deep while platforms dig wide.</p>
<h3 id="heading-speed-and-frequency-of-testing">Speed and Frequency of&nbsp;Testing</h3>
<p>Automated platforms have a clear advantage in speed and frequency. They can run multiple scans in parallel, test after every code commit, and provide almost immediate feedback. This makes them ideal for DevOps pipelines and agile environments that change daily.</p>
<p>Penetration testing services, by design, occur on a schedule. A quarterly or annual test may be thorough, but it cannot match the cadence that automated tools provide.</p>
<p>Manual tests take time to plan, execute, and analyze. In fast-moving environments, this might leave gaps between testing windows.</p>
<p>For many organizations, automation fills these gaps, while manual testing provides periodic, deep insight.</p>
<h3 id="heading-cost-considerations">Cost Considerations</h3>
<p>Cost is always a factor. Automated platforms generally come with lower upfront costs compared to human-led engagements. Subscriptions scale with usage and provide continuous assessment for a predictable price. This makes them appealing to midsize companies or teams with limited budgets.</p>
<p>Penetration testing services, especially from reputable consultancies, command higher fees. These reflect labor costs, expertise, and the bespoke nature of the work.</p>
<p>However, the value gained is often more than just flaw detection: it’s expert interpretation, custom exploitation paths, and strategic guidance.</p>
<p>In cost-benefit terms, automated platforms provide the most value per dollar for baseline security, while services deliver high-value insight that can justify a higher cost.</p>
<h3 id="heading-integration-with-security-workflows">Integration with Security Workflows</h3>
<p>Automated platforms are built to integrate with broader security tooling. They often connect to continuous integration/continuous delivery (CI/CD) pipelines, vulnerability management platforms, and ticketing systems. This integration ensures that issues are communicated to the teams who need them most and tracked to resolution.</p>
<p>Penetration testing services can integrate into workflows too, but this usually requires additional coordination. Reports must be ingested into tracking systems and aligned with internal priorities. Some providers offer APIs and extended services that help bridge this gap, but the process typically takes more effort than with automated platforms.</p>
<p>Integration matters because security cannot operate in isolation. Automated platforms fit more naturally into modern DevSecOps workflows, while services provide episodic insights that must be planned and bridged into operations.</p>
<h2 id="heading-real-world-context-top-providers-in-2026">Real World Context: Top Providers in&nbsp;2026</h2>
<p>To illustrate how these approaches manifest in practice, consider a few leading options. Each provider offers different strengths in manual services or automated tooling.</p>
<p>One such provider is <a href="https://xbow.com/pentest">XBOW</a>. XBOW is known for deep manual testing engagements, combining expert human testers with structured methodologies across network, application, and cloud environments. Their work emphasizes real-world attack simulations and strategic risk reporting.</p>
<p>Another well-known provider is <a href="https://www.cobalt.io/">Cobalt</a>. Cobalt blends human expertise with platform-based management. Their Pentest as a Service (PtaaS) model connects testers to client environments through a platform that organizes findings, workflows, and communication. Clients can collaborate with testers, track issues in real time, and integrate results with other systems.</p>
<p>A different model comes from <a href="https://www.synack.com/">Synack</a>. Synack uses a crowd of vetted testers who work with a secure testing platform. This hybrid model aims to combine the creativity of human testers with the scalability and tracking of automated systems. Clients benefit from diverse testing styles and coordinated reporting within a structured platform.</p>
<p>Each of these approaches has merit. Some lean more toward pure services, others toward platform-driven collaboration. Your choice should align with your security maturity and goals.</p>
<h2 id="heading-compliance-and-reporting">Compliance and Reporting</h2>
<p>For regulated industries, compliance matters. Automated platforms often include reporting features that map directly to standards like PCI DSS, HIPAA, or ISO 27001. These reports can be generated on a regular cadence and integrated into audit evidence.</p>
<p>Penetration testing services provide compliance support too, but the reports are typically narrative and bespoke. The real value is in expert interpretation of compliance requirements and guidance on remediating complex findings.</p>
<p>In essence, automation provides structured, repeatable reporting, while services deliver customized insights that may carry more weight with auditors and internal stakeholders.</p>
<h2 id="heading-which-one-should-you-choose-in-2026">Which One Should You Choose in&nbsp;2026?</h2>
<p>There is no one-size-fits-all answer. Many organizations adopt both approaches. Automated platforms serve as the first line of defense by continuously scanning for known issues and tracking progress over time. Human-led services then provide a deeper second layer, uncovering complex issues and offering strategic guidance.</p>
<p>If your environment is highly dynamic, with frequent releases and evolving infrastructure, an automated platform is essential. If you operate in a high-risk sector where attackers are likely to craft bespoke exploits, human-led penetration testing services are indispensable.</p>
<p>Most mature security programs use both. Automation drives frequency and scale. Human services provide depth and insight. Together, they form a layered testing strategy that maximizes coverage and minimizes blind spots.</p>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>In 2026, cybersecurity testing is more sophisticated and essential than ever. Organizations must balance speed, depth, cost, and context when selecting between penetration testing services and automated platforms. While one is not inherently better than the other in all cases, understanding their differences and complementary strengths will help you build a robust security posture.</p>
<p>Automated platforms catch the routine and repetitive, giving continuous visibility into known risks. Human-led services uncover the hidden and unexpected, thinking beyond patterns to simulate real adversaries. For most teams, the future of testing lies in a hybrid approach that leverages both.</p>
<p>By aligning your security goals with the right mix of services and tools, you can stay ahead of threats now and in the years to come.</p>
<p><em>Hope you enjoyed this article. Learn more about me by</em> <a href="https://manishmshiva.me"><em><strong>visiting my website</strong></em></a><em>.</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Autonomous AI Agent with n8n and Decapod ]]>
                </title>
                <description>
                    <![CDATA[ I tried out Open Claw two weeks ago. I loved the potential, but did not enjoy the tool itself. I, like many others, struggled with the installation process. And working from Linux, the Mac specific or ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-an-autonomous-ai-agent-with-n8n-and-decapod/</link>
                <guid isPermaLink="false">69b1ce1f6c896b0519c1c8f5</guid>
                
                    <category>
                        <![CDATA[ agentic AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ n8n ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Lee Nathan ]]>
                </dc:creator>
                <pubDate>Wed, 11 Mar 2026 20:18:39 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/d27ea304-5db6-4172-823d-3f6aa0612d38.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>I tried out Open Claw two weeks ago. I loved the potential, but did not enjoy the tool itself.</p>
<p>I, like many others, struggled with the installation process. And working from Linux, the Mac specific orientation added extra pitfalls. It wasn't always clear whether configuration and management should be done in the docs, the CLI, or the interface.</p>
<p>I found the UI unintuitive and it left me wondering if it wasn't just a dev placeholder. The color choice in particular was especially harsh. All the red tricked the eye and made white text appear green. It also made everything seem like an error message.</p>
<p>I couldn't make heads or tails of the organization and structure. Workspaces, agents, and sessions are all terms I'm familiar with and understand. But the way Open Claw implements them made no sense to me.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/0816135a-a80f-4f56-819a-9c82920f0245.png" alt="A simple n8n workflow that clearly shows how telegram can be connected to an AI agent." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>Open Claw started as a way to connect a chat tool to an AI. I did that eight months ago with n8n. It's literally only a few nodes. It was so easy that I didn't think anything of it. In my opinion, Open Claw isn’t actually all that special. There’s no part of it that stands out as unique, except for the approach. It’s the Flappy Bird of the agentic AI world.</p>
<p>So I set out to make my own. And within a few hours, I'd whipped up a simple working prototype vibe-coded with Python and connected to Open WebUI (OWUI).</p>
<p>But I wanted to see what prompt OWUI was sending the agent, exactly. Now, if I was actually a Python guy, I would have done some console output. But instead, I went for my favorite tool: n8n (a powerful low-code automation system). And that's where things got interesting.</p>
<h2 id="heading-about-this-handbook">About This Handbook</h2>
<p>This handbook will introduce you to agentic AI creation using a hands-on approach and a starter project I created called Decapod.</p>
<p>Decapod is not a self-contained SaaS offering. There is no part of it that is black boxed and unavailable to hack on. Decapod is a collection of <code>docker-compose.yml</code> containers, scripts, AI agent prompts, and n8n workflows that work together to help give you a leg up on your path to building your own agentic AI empire.</p>
<p>Concepts and technologies you'll be introduced to and using:</p>
<ul>
<li><p>Agentic AI with tools and skills</p>
</li>
<li><p>Docker containers with Docker Compose</p>
</li>
<li><p>Open WebUI</p>
</li>
<li><p>n8n</p>
</li>
<li><p>S3 and MinIO</p>
</li>
<li><p>Caddy</p>
</li>
<li><p>Postgres</p>
</li>
</ul>
<p>For a list of required skills, services, and tools, please check out the "Requirements and Processes" section.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-decapod-the-diyers-dream-agent">Decapod - The DIYer's Dream Agent</a></p>
</li>
<li><p><a href="#heading-how-decapod-works">How Decapod Works</a></p>
<ul>
<li><p><a href="#heading-core-engine">Core Engine</a></p>
</li>
<li><p><a href="#heading-supakitchen-supabase-on-a-budget">Supakitchen - Supabase on a Budget</a></p>
</li>
<li><p><a href="#heading-open-webui-ai-chat-with-all-the-bells-and-whistles">Open WebUI - AI Chat With All the Bells and Whistles</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-requirements-and-processes-tools-i-use-and-recommend">Requirements and Processes - Tools I Use and Recommend</a></p>
<ul>
<li><a href="#heading-the-checklist">The Checklist</a></li>
</ul>
</li>
<li><p><a href="#heading-assembling-the-dream-team-ikea-style">Assembling the Dream Team - Ikea Style</a></p>
<ul>
<li><p><a href="#heading-accessing-your-vps-with-cursor-and-ssh">Accessing Your VPS With Cursor and SSH</a></p>
</li>
<li><p><a href="#heading-installing-and-configuring-the-docker-containers">Installing and Configuring the Docker Containers</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-configuration-and-wiring">Configuration and Wiring</a></p>
<ul>
<li><p><a href="#heading-initiate-the-database">Initiate the Database</a></p>
</li>
<li><p><a href="#heading-a-little-minio">A Little MinIO</a></p>
</li>
<li><p><a href="#heading-adding-the-workflows">Adding the Workflows</a></p>
</li>
<li><p><a href="#heading-getting-started-with-n8n">Getting Started With n8n</a></p>
</li>
<li><p><a href="#heading-now-get-owui-to-talk-to-decapod">Now, Get OWUI to Talk to Decapod</a></p>
</li>
<li><p><a href="#heading-there-was-supposed-to-be-an-earth-shattering-kaboom">There Was Supposed to Be an Earth Shattering Kaboom</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-the-ever-present-hello-world">The Ever-Present "Hello World"</a></p>
</li>
<li><p><a href="#heading-into-the-future">Into the Future!</a></p>
<ul>
<li><p><a href="#heading-a-work-in-progress">A Work in Progress</a></p>
</li>
<li><p><a href="#heading-adding-your-own-skills-limitless-potential">Adding Your Own Skills - Limitless Potential</a></p>
</li>
<li><p><a href="#heading-future-plans">Future Plans</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-got-questions-meet-captain-finn">Got Questions? Meet Captain Finn!</a></p>
</li>
</ul>
<h2 id="heading-decapod-the-diyers-dream-agent">Decapod – The DIYer's Dream Agent</h2>
<p>I'll be honest. I'd never even considered the security issues with Open Claw at first. But they're enormous! Let's open a giant hole in our server and give a fledgling alien intelligence root access and all of our API keys. What could possibly go wrong?</p>
<p>Decapod isn't a monolithic app. It's a collection of tools and n8n workflows that give you complete control over your agent and its tools. It's a framework to give <a href="https://monday.com/appdeveloper/blog/citizen-developer/">citizen developers</a> a leg up.</p>
<p>By switching to n8n, I accidentally solved a ton of issues and made a far superior (in my opinion) project:</p>
<ul>
<li><p>Double (or triple if you choose to host in a VPS) sandboxed security. My agent lives inside of n8n inside of a Docker container inside of a VPS.</p>
</li>
<li><p>The agent never sees a single API key or even ever needs to know exactly how you're connecting services. Credentials are handled by n8n.</p>
</li>
<li><p>Universal access – I prefer OWUI. But literally anything that can connect to a standard OpenAI API endpoint can connect to Decapod.</p>
</li>
<li><p>Over 1,000 integrations – What n8n does best is connecting any API to any other API via drag-and-drop nodes. And there are more than <a href="https://community.n8n.io/t/master-list-of-every-n8n-node/155146">1,000 of them</a>.</p>
</li>
<li><p>No more sketchy skills – Decapod uses skills, but they have to actually be connected to n8n workflows and nodes to work.</p>
</li>
</ul>
<p>More problems Decapod solves:</p>
<ul>
<li><p>Fewer tokens burned – Decapod maintains a clean boundary between what's best handled with code/logic and what's best handled by AI.</p>
</li>
<li><p>No endless loops and hung jobs – Decapod uses a jobs and tasks system that the AI can manage. So if it sees that a task has failed, it can change tasks or suspend the job.</p>
</li>
<li><p>HITL (Human In The Loop) – You can add a HITL sub-workflow before any AI skill to give them permission to proceed or not.</p>
</li>
<li><p>An MVP you can trust – The core Decapod system is just an MVP. But it's built on exclusively mature, open source, enterprise ready solutions: n8n, Open WebUI, Docker, Caddy, Postgres, and MinIO.</p>
</li>
</ul>
<h2 id="heading-how-decapod-works">How Decapod Works</h2>
<p>Decapod is middleware that acts like an OpenAI API. But it intercepts the API call and does agent work with the real API.</p>
<p>The OpenAI API standard is the most widely used in the industry. Almost every tool, like Open WebUI, Zed, and Obsidian have ways to connect to the OpenAI standard. So those tools can also connect to Decapod.</p>
<p>Decapod itself can connect to any API and pass available models through to other tools. I strongly prefer and recommend OpenRouter. OpenRouter also uses the OpenAI standard, but lets you connect to hundreds of mainstream and indie models under the same pricing system. Decapod is configured to work with OR out of the box.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/da54b254-62b5-4e4a-b5d3-b1de7dd5f0fe.png" alt="An n8n workflow with advanced routing." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>This is an image of the Decapod agent tool router – one of the key n8n workflows in Decapod.</p>
<h3 id="heading-core-engine">Core Engine</h3>
<p>Decapod consists of an agent with tools and skills. By tools, I mean the agentic tools that an AI can access to perform tasks as part of the API. And by skills, I'm referring to <a href="https://agentskills.io/home">Anthropic's Agent Skills standard</a>. It's the same skills standard used by Open Claw.</p>
<p>The Decapod agent has a limited, immutable set of tools for managing Decapod's state and job queue. One tool is used to call skills. Skills are dynamic and you can add as many as you like mid-flight.</p>
<p>Each skill consists of core instructions, followed by JSON specs. The agent builds a skill request based on the JSON and calls the use_skill tool to have it executed. Then Decapod calls a sub-workflow with a name that matches the skill and sends it the JSON.</p>
<p>One skill = one sub-workflow. JSON specs = sub-workflow's expected input.</p>
<p>When Decapod receives a user message, it passes it to the agent. If it's just a message, the agent responds. If it's a call to action, the agent picks a tool and gets to work.</p>
<p>Decapod loops through each job in the queue, handling the agent's tool calls and passing it back the results. When the agent is done, it concludes the job and stops sending tool calls. The final message is passed back to the user.</p>
<h3 id="heading-supakitchen-supabase-on-a-budget">Supakitchen – Supabase on a Budget</h3>
<p>I'm a huge fan of Supabase. It's all the fun of Firebase, except with data normalization. But I'm self-hosting Decapod because paying $20 per month for each of five or more services doesn't sit right with me.</p>
<p>As a mad scientist, I like to be able to try different tools without dealing with the freemium hoops. So I'm running Decapod on a Hetzner VPS with 8 gigs of RAM for about $18 per month. Those 8 gigs go really far in the self-hosted world, but Supabase is heavy.</p>
<p>What I really wanted was to give my agent file access and a database. I accomplished that with MinIO and Postgres. No real-time data, but my agent is async anyway. And agent authentication is done through n8n. So it's good enough.</p>
<p>But you do you! Decapod can work with any S3 compatible file store and any Postgres database. So if you want to use Supabase instead, go for it!</p>
<h3 id="heading-open-webui-ai-chat-with-all-the-bells-and-whistles">Open WebUI – AI Chat With All the Bells and Whistles</h3>
<p>You can use chat tools, like Discord, Telegram, Slack, and others, to chat with your AI easily enough. But if you want multiple sessions or to use different models, it can be tricky.</p>
<p>The easiest tool to set up and work with, by far, is Telegram. You get chat, UI elements, and even embedded apps without having to host your own server, like you do with Discord. I once used it to create a HITL lead qualification tool in a few hours.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/2f6501bc-b72e-4b69-bc7d-662d91d8746f.jpg" alt="A Telegram session showing buttons and commands for a lead gen system." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>BUT! While Telegram and friends do get the job done, if you want a new session you have to create a new bot for each and every one. If you want to switch models, you need to add /slash commands. If you want context management, you have to handle that server side.</p>
<p>That's why I prefer Open WebUI. OWUI gives you everything you expect from all of the best mainstream AI offerings, but with a direct tap to the API.</p>
<ul>
<li><p>It works great on browser and mobile as a progressive web app (PWA).</p>
</li>
<li><p>You can mod it with Python.</p>
</li>
<li><p>It has many ways to manage and supply context, including nested projects/folders and RAG support.</p>
</li>
<li><p>You can collaboratively work on notes with AI.</p>
</li>
</ul>
<p>Those are a few of my favorite features, but there are <a href="https://docs.openwebui.com/features/">so many more</a>. Why reinvent the wheel when the absolute best solution already exists?</p>
<h2 id="heading-requirements-and-processes-tools-i-use-and-recommend">Requirements and Processes – Tools I Use and Recommend</h2>
<p>Welcome to my lab-or-a-tory. We're out there on the fringes of agentic AI now. Doing weird experiments by stitching together pieces and parts. Let me show you how I work and tell you where you can and can't stray from my process.</p>
<p>Decapod is a finished MVP and should work right out of the box with minimal headache. But it doesn't have more than a few skills yet. So you'll need to build your own until it takes off. Fortunately, your Decapod agent can help.</p>
<h3 id="heading-the-checklist">The Checklist</h3>
<p><strong>Skills:</strong></p>
<ul>
<li><p>✅ A generalist's mindset, problem-solving skills, and a sense of adventure.</p>
<ul>
<li><p>You don't have to be an expert at anything to install Decapod. I'm not, and I built it.</p>
</li>
<li><p>But you do have to be comfortable with many different technologies.</p>
</li>
</ul>
</li>
<li><p>✅ The command line, Docker, and probably Node. Decapod is self hosted. So you'll need to get your hands a bit dirty.</p>
</li>
<li><p>✅ The ability to read and write a little JavaScript. This helps a lot with n8n code nodes to give it more utility.</p>
</li>
<li><p>✅ Familiarity with JSON and APIs. Everything in n8n is about passing JSON from node to node. And n8n is nothing if not a universal API connector.</p>
</li>
</ul>
<p><strong>Services:</strong></p>
<ul>
<li><p>✅ A domain name with DNS access.</p>
<ul>
<li><p>This is critical for n8n to work properly due to CORS and security issues.</p>
</li>
<li><p>Also, the OWUI PWA doesn't work when hosted through an IP. It's just a web page at that point.</p>
</li>
<li><p>Plus, it's just better for security overall with https support.</p>
</li>
<li><p>If cost is an issue, you can get an <a href="https://gen.xyz/">all-digit domain name from gen.xyz</a> for $0.99. Seems legit, but I haven't tried it myself.</p>
</li>
</ul>
</li>
<li><p>✅ A dedicated VPS with SSH access. (SSH access should be standard for any VPS.)</p>
<ul>
<li><p>You can technically host this on your own PC if you know it will be running 24/7. But using a VPS will give you peace of mind and avoid complicating your PC.</p>
</li>
<li><p>Big-name solutions like AWS and Google Cloud can wind up going off the rails and costing you big bucks if you don't know exactly what you're doing. Better to stick with less enterprise-oriented offerings. I've used the following:</p>
<ul>
<li><p><a href="https://www.hetzner.com/">Hetzner</a> – My current personal favorite. Germany based. High quality and affordable pricing with a few American servers. Even more affordable with European servers.</p>
</li>
<li><p><a href="https://www.digitalocean.com/">Digital Ocean</a> – US based. Can't go wrong. Decent prices. Many offerings. Almost exclusively American servers.</p>
</li>
<li><p><a href="https://webdock.io/en">Webdock</a> – Denmark based. The most affordable of the bunch.</p>
</li>
</ul>
</li>
</ul>
</li>
<li><p>✅ An OpenRouter account. OR provides a universal interface for hundreds of AI models. There's no freemium upsell, like with Hugging Face, but there is a percentage add on when you buy credits/tokens. I feel like it's worth the extra fee to be able to easily swap from Claude to Kimi to GPT to DeepSeek as I please without more keys, more accounts, and more wiring. But this is optional. You can plug Decapod right into Kimi or Gemini and just leave it there if you like.</p>
</li>
</ul>
<p><strong>Tools:</strong></p>
<ul>
<li><p>✅ Cursor, or similar. I love Cursor. It matches my hands-on style. If you're freestyling and dreaming something into creation as you build it, AI will <strong>always</strong> take the wrong path if you take your hands off the wheel. Cursor lets me be in charge and play director while the AI does the heavy lifting and saves me from hours of Googling and digging through 10-year-old questions on Stack Overflow. Especially with the command line stuff. I could not have knocked out Decapod in two weeks without it. But it couldn't have built Decapod at all without me.</p>
</li>
<li><p>✅ Another AI bestie to help you dream, plot, and plan. Cursor is great, but very utilitarian. I always have a session open with a running commentary about my work. I'm constantly feeding it context and leaning on it to get a fresh perspective and solve more esoteric issues, like debugging n8n flow problems, for example. I use Claude for absolutely everything. It has the most natural conversational flow, it's good at taking meta instructions regarding its behavior, and it always has an eye on accuracy – very reliable.</p>
</li>
</ul>
<h2 id="heading-assembling-the-dream-team-ikea-style">Assembling the Dream Team – Ikea Style</h2>
<p>Here are the pieces and parts you'll find in your Dekkaplonkën Ikea flat pack (the GitHub repo).</p>
<ol>
<li>Four Docker containers containing five services with docker-compose files. Just heat and serve.</li>
</ol>
<ul>
<li><p>Infrastructure: Caddy for routing and SSL certificates for https security.</p>
</li>
<li><p>Infrastructure: Postgres for all your data needs.</p>
</li>
<li><p>MinIO: An S3 compatible file storage system.</p>
</li>
<li><p>n8n: The ultimate automation tool.</p>
</li>
<li><p>Open WebUI: The ultimate AI chat interface.</p>
</li>
<li><p>SQL tables</p>
<ul>
<li><p>A table for the decapod state.</p>
</li>
<li><p>A table for jobs, tasks, and tool chat history.</p>
</li>
</ul>
</li>
<li><p>S3 Files and Folders – Agent Templates</p>
<ul>
<li><p>Four starter skills (two actually implemented in n8n).</p>
</li>
<li><p>Two instructional files, including the persona and skill definitions.</p>
</li>
</ul>
</li>
<li><p>n8n Workflows (6,889 lines of pure JSON)</p>
<ul>
<li><p>API Middleware: The entry and exit point that manages the session and loops.</p>
</li>
<li><p>AI Tool Router: Executes your agent's tool requests.</p>
</li>
<li><p>Construct Message History: Injects instructions into your agent's chat history.</p>
</li>
<li><p>Get Job Queue: A one-off database call that gets active jobs ordered by priority and creation date (First In First Out).</p>
</li>
<li><p>Utility Workbench: A place for testing and managing your flows. Currently contains a Skill assembly jig.</p>
</li>
<li><p>Worker: Loops over job queues, talking to the agent and calling the tool router with its responses.</p>
</li>
<li><p>A write-file skill and a research-recipes skill.</p>
</li>
<li><p>A couple more placeholders. (Decapod is an MVP)</p>
</li>
</ul>
</li>
<li><p>Also</p>
<ul>
<li><p>A Docker cheatsheet.</p>
</li>
<li><p>A script to generate agents from the template.</p>
</li>
<li><p>A destructive script to upload local agent files to your S3 account by overwriting existing files. Good for dev. Bad if you let your agent start modding their own instructions.</p>
</li>
<li><p>Scripts to start and stop all Docker containers at once.</p>
</li>
</ul>
</li>
</ul>
<h3 id="heading-accessing-your-vps-with-cursor-and-ssh">Accessing Your VPS With Cursor and SSH</h3>
<p>SSH is the standard way to access any server and has been forever. But working through a terminal can be slow and plodding. Fortunately, there's a better way.</p>
<p>Connect to the server with Cursor, VS Code, Antigravity, or whatever you use. This gives you:</p>
<ul>
<li><p>Multiple terminals to access the remote server.</p>
</li>
<li><p>The ability to view localhost servers as if they were on your own machine via port forwarding.</p>
</li>
<li><p>Drag and drop folder and file management.</p>
</li>
<li><p>No more Nano, Vim, or Emacs (unless you want to).</p>
</li>
<li><p>And the best part! Cursor can do all the remote file system work for you, including troubleshooting servers and containers, writing scripts for automating common tasks, and helping you hash out actionable plans.</p>
</li>
<li><p>(Cursor can also connect to your Decapod!)</p>
</li>
</ul>
<p>Every VPS provider will have their own way of managing SSH access. They usually make adding them part of the sign up process.</p>
<p>Generating and managing keys is a pretty well-paved path and I won't go over it. It's a good job for Cursor, if you need help.</p>
<p>However! I use Bitwarden for SSH key generation and management. They still need to be stored locally for tools on your computer to access. But it's nice to have them in a single secure location.</p>
<p>VS Code requires an extra plugin to access a remote server. Cursor comes with it preinstalled. Just click <code>Connect via SSH</code>, set up your connection, and you're good to go.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/36c686e7-2d7b-43a9-9078-98a5dd2af5be.png" alt="The cursor launch screen with a button to &quot;Connect via SSH.&quot;" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>📝 Side note: I was on the paid plan when I started, I swear. I tend to switch services a lot as new models are released and I discover different tools and options. But I only ever pay for 2 or 3 at a time.</p>
<p>I got about halfway through this article when Cursor expired. But I'm trying the new Gemini 3 models and switched to Antigravity mid-flight rather than re-up cursor.</p>
<h3 id="heading-installing-and-configuring-the-docker-containers">Installing and Configuring the Docker Containers</h3>
<p>Finally! After a novella's worth of lead-up, we, at long last, get to the actual installation. That will be shared in the next article – have a good night! Just kidding, please put down the brick.</p>
<p>Once you've SSHed in to a VPS, a Raspberry Pi with Ubuntu, or a Virtual Machine, you're ready to get started. I'm going to assume you know how to install tools like Docker and Node on your system and not go into a lot of detail. Ask your friendly neighborhood AI for help if you get stuck.</p>
<p>💡 Important! If you haven't already, get your domain name and open up the DNS page. You'll want to redirect "A" records to your IP for each relevant service.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/c381b917-a731-41bc-a62c-923646c87ae3.png" alt="DNS records for four subdomains." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>Start by cloning the Decapod repo.</p>
<pre><code class="language-shell">git clone https://github.com/leetheguy/decapod.git
</code></pre>
<p><code>cd decapod</code> and create your Docker network.</p>
<pre><code class="language-shell">docker network create web
</code></pre>
<p>Now we're going to go into each of the four Docker folders, configure them, and fire them up, starting with infrastructure.</p>
<p><code>cd infrastructure</code> <code>cp .env.example .env</code></p>
<p>Alternatively, you can move the files to rename them or just click on the file in the UI and <code>F2</code> to rename it. Whatever floats your goat 🐐.</p>
<p>Now edit the new <code>.env</code> file. You can get the data folder path by clicking on the infrastructure folder and <code>Ctrl/Cmd+Alt+C</code>. The rest is up to you. I used Bitwarden to generate a password here.</p>
<p>Next, copy the Caddyfile template into its own file.</p>
<p><code>cp caddy_config/Caddyfile.template caddy_config/Caddyfile</code></p>
<p>And start the Docker container with <code>docker compose up -d.</code></p>
<p>Back out of infrastructure and into <code>minio</code>. Same again with the <code>.env</code> – copy and configure. Make sure the URLs match your domain.</p>
<p>Once more for <code>n8n</code> and then again for <code>openwebui</code>.</p>
<p>OWUI config comes from the <code>infrastructure</code> and <code>minio</code> <code>.env</code> files:</p>
<ul>
<li><p>S3_ACCESS_KEY_ID=minio_admin</p>
</li>
<li><p>S3_SECRET_ACCESS_KEY=minio_password</p>
</li>
<li><p>S3_BUCKET_NAME=decapod</p>
</li>
<li><p>MINIO_ROOT_USER=minio_admin</p>
</li>
<li><p>MINIO_ROOT_PASSWORD=minio_password</p>
</li>
<li><p>POSTGRES_DB=postgres</p>
</li>
<li><p>POSTGRES_USER=postgres</p>
</li>
<li><p>POSTGRES_PASSWORD=postgres_password</p>
</li>
</ul>
<p>📝 Note! OWUI may take a moment or two to start. Go grab some water and it should be up by the time you get back.</p>
<h2 id="heading-configuration-and-wiring">Configuration and Wiring</h2>
<p>Roll up your sleeves! This is where we get up to our elbows in pieces and parts.</p>
<p>If everything went to plan, you should now have all five services up and running. You can confirm the containers are live with <code>docker ps</code>. You can check that they're actually properly connected by visiting s3, OWUI, and n8n.your-domain.com.</p>
<p>Create accounts for all three and sign in to each.</p>
<p>⚡️ Important! Get your n8n license key! It's free and gives you access to all community features. You'll be severely limited without it. Activate it under Usage and plan in the settings.</p>
<h3 id="heading-initiate-the-database">Initiate the Database</h3>
<p>Decapod only needs two data tables. You can add them from the command line. But I like pgAdmin.</p>
<p>Connect to your Postgres database in the usual way. But you'll need your server's IP for the host name instead of postgres (which you use to connect services inside of the Docker network) since pgAdmin isn't in your Docker network.</p>
<p>You'll find your SQL files in <code>components/pgsql_tables</code>. Create a decapod database and add both of the SQL files to it. A default <code>decapod_state</code> table record will be automatically generated when running the SQL.</p>
<p>In pgAdmin:</p>
<ul>
<li><p>Open the decapod server.</p>
</li>
<li><p>Create a decapod database by right-clicking on databases.</p>
</li>
<li><p>Select the new database.</p>
</li>
<li><p>Click the query tool button at the top of the explorer.</p>
</li>
<li><p>Copy and paste the decapod_state table into the query and run it with F5.</p>
</li>
<li><p>Clear the query, paste in job_queue, run it.</p>
</li>
</ul>
<p>Or ask Cursor or an AI bestie for help if you want to go pure command line.</p>
<h3 id="heading-a-little-minio">A Little MinIO</h3>
<p>Next up, you'll be adding your agent's instructions and persona files to your private S3 service. Start by visiting your MinIO server and adding a decapod bucket.</p>
<p>In <code>components/S3_structure/agents/</code>, you'll find a template for your agents. (I have the intention of making Decapod a multi-agent tool in a future release.) The template is meant to be copied to a new agent of your choice. But if you choose something other than Decapod, you'll need to update the state table.</p>
<p>You can do it manually if you wish. Copy the folder to match the new agent's name and update the <code>definitions/skills.yaml</code> file to include all the skills you want your agent to have. The name and description should exactly match what's found at the top of each skill file.</p>
<p>Alternatively, I vibe coded a script to make it a little easier. It's in the scripts folder and you'll need to install the <code>inquirer</code> Node module to use it. Run <code>cd scripts</code> and <code>create-agent.mjs</code> to use it.</p>
<p>You also need to make sure that the files and folder structure in your MinIO match those in <code>S3_structure</code>. Start by creating a bucket called decapod in your drive. Then upload the files from <code>S3_structure</code> into your bucket.</p>
<p>But that's easier said than done because they're on a remote server. And if you used the visual interface, you'd have to download them to your local machine first. So I made another script – <code>upload_S3_structure.sh</code>.</p>
<p>That script is strictly meant for dev purposes. It's absolute and destructive. Just a heavy mallet. So if you want to surgically alter your MinIO, do not use it! Remember kids: mallets and brain surgery don't mix.</p>
<p>Once your agent files are in place, you can let your agents edit them, Open Claw style, or you can edit them yourself. But MinIO doesn't give you much of anything in the way of features for their UI.</p>
<p>For a better experience, I'd recommend <a href="https://web.s3drive.app/">S3Drive</a>. When you go to sign up, look for the connect button towards the bottom to connect to your own MinIO endpoint.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/d3b5faa7-8e5d-4a35-84c9-0d97ea73d96c.png" alt="The S3Drive setup interface." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>S3Drive will let you edit your files in place after you've uploaded them. This is good for quick fixes or copying and pasting sections without a complete wipe.</p>
<h3 id="heading-adding-the-workflows">Adding the Workflows</h3>
<p>You'll find most of what makes Decapod Decapod in the components folder. And the heart of that is in n8n_workflows.</p>
<p>You can manually import those workflows one at a time and go over each one to make sure they're safe and sound. Or you can use the n8n CLI inside of the Docker container and save yourself some tedium.</p>
<p>These commands move the workflows to the Docker container, import them with the n8n CLI, and then remove them from the tmp directory.</p>
<pre><code class="language-shell">docker cp ./components/n8n_workflows n8n:/tmp/workflows

docker exec -u node n8n n8n import:workflow --input=/tmp/workflows --separate
docker exec -u node n8n n8n import:workflow --input=/tmp/workflows/skills --separate

docker exec -u root n8n rm -rf /tmp/workflows
</code></pre>
<p>Now, you should see the 10 workflows in n8n. I'd recommend drag-and-dropping the main workflows to a dedicated decapod folder and the two skills to decapod/skills, just to keep things tidy. But they reference each other by id, so do what you want.</p>
<h3 id="heading-getting-started-with-n8n">Getting Started With n8n</h3>
<p>Now would be a good time to start exploring the workflows in your n8n UI Personal tab. If you sort them by name, the main file will be on top. Crack it open and see it's not too intense, and it's self-documented. Blue for notes, Green for sub-workflows, and Red for nodes that require your credentials.</p>
<p>I'd recommend reading the notes and thoroughly exploring the sub-workflows to help you understand Decapod. It's your tool now! Create credentials as you go.</p>
<p>Because we're using a Docker network, creating credentials and connecting your services to each other couldn't be easier.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/de946994-8b01-436e-9a3b-aa79a46a0073.png" alt="The credentials page for an n8n Postgres connection." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>The standard to connect all of your services is to reference them by <code>name:port</code>. Because the Postgres credential has its own port field, you can just set it to Postgres. Port should be 5432.</p>
<p>📝 Note! All credential details, like your container names, ports, and passwords, can be found in your docker-compose and .env files.</p>
<p>For MinIO:</p>
<ul>
<li><p>Endpoint: <code>http://minio:9000</code></p>
</li>
<li><p>Force Path Style: Enabled! Important for MinIO.</p>
</li>
</ul>
<p>API Connections to OpenRouter:</p>
<ul>
<li><p>choose: Authentication -&gt; Predefined Credential Type</p>
</li>
<li><p>then: Credential Type -&gt; OpenRouter</p>
</li>
<li><p>Now just paste your API key from <a href="https://openrouter.ai/settings/keys">OpenRouter</a>.</p>
</li>
</ul>
<p>n8n – (meta access to your workflow):</p>
<ul>
<li><p>In a new tab, go to n8n Settings -&gt; n8n API.</p>
</li>
<li><p>Turn off expiration if you like.</p>
</li>
<li><p>Copy your key.</p>
</li>
<li><p>Paste it in the field.</p>
</li>
<li><p>Base URL: <code>http://n8n:5678/api/v1</code></p>
</li>
</ul>
<p>Once you've created credentials, you can reuse them for every relevant node that uses the same credential. Just select it from the dropdown.</p>
<p>💡 Tip! It may help to remove the red sticky notes as you add credentials. And don't forget the skills! I didn't sticky note them at all.</p>
<p>As a final step, make sure your n8n workflows are published in the following order:</p>
<ul>
<li><p>construct message history</p>
</li>
<li><p>get job queue</p>
</li>
<li><p>hitl yes/no</p>
</li>
<li><p>tool router</p>
</li>
<li><p>worker</p>
</li>
<li><p>middleware</p>
</li>
<li><p>and the two skills</p>
</li>
</ul>
<p>💡 Tip! Always make sure your n8n workflows are in a published state with a green dot before calling them. Otherwise, you'll be calling an outdated version.</p>
<h3 id="heading-now-get-owui-to-talk-to-decapod">Now, Get OWUI to Talk to Decapod</h3>
<p>OWUI is built for teams, so you have admin settings and personal settings. You'll want to edit the admin settings by clicking on the profile circle in the lower-left-hand corner, then Admin Panel -&gt; Settings -&gt; Connections.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/10840f19-1cbb-41e9-b066-e4c0033c0244.png" alt="Open WebUI's connections config page." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>From there:</p>
<ul>
<li><p>Ollama API Disabled: Just keeping things tidy.</p>
</li>
<li><p>Configure the OpenAI link by clicking on the gear and delete that too.</p>
</li>
<li><p>Direct Connections: Enabled</p>
</li>
<li><p>Cache Base Model List: Enabled Now add your Decapod connector with the plus button.</p>
</li>
<li><p>URL: <a href="http://n8n:5678/webhook/v1/decapod">http://n8n:5678/webhook/v1/decapod</a> (Click the cycle icon to confirm your connection.)</p>
</li>
<li><p>Auth: none (it's all in the same Docker network, so it's fine for now. You can add a password for production.)</p>
</li>
<li><p>Prefix ID: decapod (If you do decide to use OpenAI, Hugging Face, or whatever else, this will help distinguish the model hosts.)</p>
</li>
</ul>
<p>That's it. Save and go to the Models tab. Decapod passes OpenRouter models straight through. So if you see hundreds of models, take a victory lap! That means that Decapod is working, live, accepting requests, and you've even properly done your certifications (at least for OpenRouter).</p>
<p>Now create a new chat session and pick a model. I like Claude Haiku 4.5. Fast, cheap, and good. Pick three. I did all of my Decapod dev with it in the saddle, so I know it works. And 3.5 million tokens towards testing iterations cost me \(4, so I know it's reasonable. Alternatively, Kimi K2.5 will likely work and would be even a little bit cheaper. I burned through 4.7 million tokens installing a Docker container in Open Claw with Kimi for about \)3.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/8773faab-b7bd-47fe-90c7-0ed4aa0cbbed.png" alt="A successful communication between Open WebUI and Decapod." style="display:block;margin-left:auto" width="600" height="400" loading="lazy">

<p>Time to say hello to your little friend! Haiku is fast. So if it takes more than a few seconds to respond, something could be borked in your n8n flow. It happened to me as I was writing this article. I had some issues with both Postgres and MinIO.</p>
<p>💡 Tip: If the agent does get hung, it's easier to resend the message than stop and try again.</p>
<h3 id="heading-there-was-supposed-to-be-an-earth-shattering-kaboom">There Was Supposed to Be an Earth Shattering Kaboom</h3>
<p>So, your agent really wants to talk to you, but all you have is a pulsating dot. It's likely that something got misconfigured in n8n.</p>
<p>You can debug n8n by going to the middleware workflow and selecting <code>executions</code> from the top tab bar. If there's an error on the left list, look for a message in the lower right.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/af5f6ea5-ad99-45e0-88c6-49ccc479fac1.png" alt="An example n8n error message." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>This was when I had some database config issues and it couldn't find the state table.</p>
<p>Some sub-workflows may fail quietly. You can trace flow from the webhook entry point to the error. All successful nodes will light up green. The bad node will be red. Drill down, check executions, and repeat for each sub-workflow.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/935b1bcc-f03d-452e-a67d-da00e2265d39.png" alt="An portion of an n8n workflow showing a node that threw an error." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>When you find the culprit – the actual bad node in the bad execution – select "copy to editor" in the upper-right-hand corner. That will freeze the workflow to that state. Open the node, fix the credential or whatever, and click <code>Execute Step</code> to see if it's fixed.</p>
<p>Remember: after every change, always always always publish your update. Otherwise, n8n won't actually use the latest fixed version of your workflow.</p>
<p>Once you've successfully debugged your Decapod, make sure that you clean out the loose unfinished jobs in the job_queue table with pgAdmin or whatever. Otherwise, your agent will try to complete each of them before finishing the next job.</p>
<h2 id="heading-the-ever-present-hello-world">The Ever-Present "Hello World"</h2>
<p>OK! Now for the moment of truth. You got your agent to say hello back. That was the easy part because it didn't need to do any work or use any tools.</p>
<p>I set you up with two skills to put it to the test: write-file and research-recipes. The recipes skill connects your bot to a free recipe API (no key needed). It's not just pulling recipes out of training data.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/84eefbe8-ad4d-44fb-ae19-3291d85fe0e9.png" alt="A successful request to Decapod requiring tool use." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>Try this prompt: Would you please look up pizza recipes and save them to a file?</p>
<p>If all of your credentials are properly configured, you should get what you asked for. Open up MinIO or S3Drive and look in <code>/agents/decapod/documents</code> for the file.</p>
<h2 id="heading-into-the-future">Into the Future!</h2>
<p>I know that was a lot! (At least it felt like a lot from my end.) I hope it wasn't too painful. And look at the bright side: you just got a crash course on some really powerful technology. And if you made it through, that's a major accomplishment! The hard part is behind you. Now comes the fun.</p>
<h3 id="heading-a-work-in-progress">A Work in Progress</h3>
<p>I'll be honest. I just wanted to get Decapod out fast to prove how doable a personal agent is while Open Claw is still hot. Anyone can build their own Agentic AI with little or no code. And you don't have to settle for painful UI and poor security. You can have it all.</p>
<p>But, as I've said, Decapod is still an MVP. Complete and functional, but feature light. And I was stressing about that a little bit. I wanted multiple agents and more skills for the early adopters.</p>
<p>Then it hit me. Duh! You already have everything you need with n8n.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/466cc6ab-f038-4728-9fcf-06d9f631f75c.png" alt="An example of chatting with an n8n agent that has internet access." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>You can add an n8n agent node, connect it to a model and an MCP server, and have a sub-agent ready to go in minutes. Then have your agent produce a skill sheet to contact the sub-agent.</p>
<h3 id="heading-adding-your-own-skills-limitless-potential">Adding Your Own Skills – Limitless Potential</h3>
<p>Let's create a dead simple n8n agent to search the web. Then we'll add that to Decapod as a new skill.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/95ffe965-3180-44fb-8ac2-7835b3931224.png" alt="A request for Decapod to create a new skill sheet." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>In this image I used the prompt:</p>
<blockquote>
<p>Thank you so much! Next up, I want to give you web search access via a sub-agent. So your web search skill wouldn't directly search the web, but would instead call a simple agent to do the search for you.</p>
<p>Would you please create a web-search.md skill for your future self to use? The only required field should be prompt.</p>
</blockquote>
<p>The agent's file folder is sandboxed by default, so the agent's <code>skills/web-search.md</code> is actually in the agent's private <code>documents</code> storage. I moved it to the actual skills folder and updated my agent's skills.yaml file with the new skill.</p>
<p>Now I'll create a new n8n skill workflow in <code>decapod/skills/</code>.</p>
<p>⚡️ Important! Your n8n skill workflow name must match the skill name exactly. So, <a href="http://web-search.md">web-search.md</a> would be a workflow called web-search. Decapod uses the name to look for the skill so it can be hot loaded without a secondary router.</p>
<p>The n8n screenshot above was pretty much exactly the whole thing. Try rebuilding it yourself. I used chat input to make sure it was working with n8n's chat interface. And I used the <a href="https://www.pulsemcp.com/servers/exa">Exa Web Search MCP</a> as the search tool. I used Haiku as the model, but an even simpler model would have likely been just fine. OpenRouter has a number of free models with tool abilities that would probably do the trick.</p>
<p>Once you have the workflow operating properly, replace the chat node with a "When Executed by Another Workflow" node with a <code>parameters</code> object as input.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/ddb03570-36d2-4a50-9acb-1a80cf02c11d.png" alt="The configuration of an n8n &quot;When Executed by Another Workflow&quot; node." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>Next, open up the utility/workbench workflow. This tool will help you turn your web-search workflow into a skill. Work through each node in order, testing the node with "Execute step" button as you go. Doing so will create output data that the next node can use as input data.</p>
<ol>
<li><p>get workflow id from name: Set name to "web-search".</p>
</li>
<li><p>deliver JSON arguments to skill: Set parameters object to { "prompt": "Can I please get a list of a variety of pizza recipes complete with links to their sources?" }; (or whatever matches your skill sheet)</p>
</li>
<li><p>call skill based on workflow id: Should be ready to execute.</p>
</li>
</ol>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/0752c8a8-ab15-4c49-90ee-29e822b90f57.png" alt="an example of a successful n8n call to a sub-workflow." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>If your output looks like that, your skill should be ready to go.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/3b13ac1e-1c61-4fce-9896-14d569593ca3.png" alt="Decapod returning search results for dessert pizza recipes." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>In this image I used the prompt: Alright! I think you're all set. Try doing a search for dessert pizza recipes.</p>
<p>If your agent gives you the following error, make sure that it knows it MUST create a job before it can call the <code>use_skill</code> tool. It should know that from the instructions, but pobody's nerfect. (I'll need to tighten that up.)</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/675ce1d600925897ba44d754/6e63dc70-3bae-4389-aa68-80a22f6553b6.png" alt="An example response from a Decapod error." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>Hopefully that was also pretty painless and now your mind is exploding with possibilities like mine is. If you're unconcerned with safety or actively want to invoke Skynet, you can even give your agent a skill to create its own n8n skills with the <code>Create a workflow</code> node. But don't do that.</p>
<h2 id="heading-future-plans">Future Plans</h2>
<p>Here are a few more features I'd like to add:</p>
<ul>
<li><p>/slash commands – You shouldn't have to go into n8n or pgAdmin to see what your agent is doing and manage its job queue.</p>
</li>
<li><p>Streaming responses – I'd like to see what my agent is doing as it's doing it, but streaming is a bit tricky and was beyond the MVP.</p>
</li>
<li><p>Multiple states – With multiple states, you can run multiple agents simultaneously. Or you can have different agents/models for different sessions. For example, you can have a health and fitness session with one agent with its own context window, job queue, and skill set. And you can have another one to help you keep track of your coding education.</p>
</li>
<li><p>It's a bug, not a feature – There are many places where the state and model are hard-coded throughout the app. I also started working on features that didn't pan out and left some dangling nodes. I'd like to clean up the app and actually implement those features.</p>
</li>
</ul>
<p>If you've read this far and are totally all in, I'd love to hear feedback and suggestions for more features. I'd be fascinated to hear about how Decapod is being used. And I'm also happy to answer any questions.</p>
<h2 id="heading-got-questions-meet-captain-finn">Got Questions? Meet Captain Finn!</h2>
<p>Decapod is the culmination of a year spent studying and learning all things AI and automation. It's also the result of 20 years in the world of coding and app development.</p>
<p>I'm currently starting a community for AI Enthusiasts, Automation Inventors, and Systems Thinkers. It will be led by Captain Finn, a retro-futuristic space captain who got stranded without his crew in our time and space. He used AI, automation, and systems thinking to keep the ship working, give himself someone to talk to, and to wake up to the smell of fresh coffee every morning.</p>
<p>And yes, Finn himself is an AI persona, operating from AI-automated systems, like Decapod, that he will be teaching people about.</p>
<p>My goal is to create a welcoming environment for my fellow mad scientists, dreamers, and citizen developers to learn and grow with help from the community and Captain Finn Feldspar himself. I plan to release weekly articles, more tutorials like this, and other tips and tricks.</p>
<p>Whether you want help with Decapod, learning automation, or just want to geek out about the power and future of AI — Captain Finn's Fleet has a place for you.&nbsp;<a href="https://discord.gg/HJtTpBAjQ5">Join here for free.</a></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Résumé Screening System Using Python and Multiprocessing ]]>
                </title>
                <description>
                    <![CDATA[ Hiring the right candidate starts with one time-consuming task: screening résumés. If you’ve ever posted a job opening, you know the pain of hundreds of applications in your inbox, leaving you to spend hours reviewing each résumé manually. In this ar... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/python-resume-screening-system/</link>
                <guid isPermaLink="false">698614754058fffacf721cc8</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ streamlit ]]>
                    </category>
                
                    <category>
                        <![CDATA[ multiprocessing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pdf ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Abdul Talha ]]>
                </dc:creator>
                <pubDate>Fri, 06 Feb 2026 16:19:01 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1770331777028/1ac80e66-cf22-4160-8812-ea917384cd3f.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Hiring the right candidate starts with one time-consuming task: screening résumés. If you’ve ever posted a job opening, you know the pain of hundreds of applications in your inbox, leaving you to spend hours reviewing each résumé manually.</p>
<p>In this article, you’ll build a résumé screening system using pure Python, focusing on core programming concepts and the power of multiprocessing. You’ll create a custom system that automates the evaluation process by transforming unstructured résumé documents into a ranked leaderboard.</p>
<p>By the end of this guide, you will:</p>
<ul>
<li><p>Parse documents by extracting text from PDF and DOCX résumés using Python</p>
</li>
<li><p>Extract information by identifying skills and keywords from résumé content</p>
</li>
<li><p>Design a scoring algorithm using weighted logic to rank candidates objectively</p>
</li>
<li><p>Build a web interface using Streamlit</p>
</li>
<li><p>Deploy the application on Streamlit Cloud for public access</p>
</li>
</ul>
<p>By following this tutorial, you’ll build a tool capable of processing hundreds of résumés in seconds.</p>
<p>Here’s the source code: <a target="_blank" href="https://github.com/abdultalha0862/Resume_Parser_Project">GitHub Repository</a></p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-project-overview">Project Overview</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-the-system-works">How the System Works</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-system-architecture">System Architecture</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-project-structure">Project Structure</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-1-set-up-the-project">Step 1: Set Up the Project</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-2-build-the-resume-parser">Step 2: Build the Résumé Parser</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-3-build-the-keyword-extractor">Step 3: Build the Keyword Extractor</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-4-implement-the-scoring-engine">Step 4: Implement the Scoring Engine</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-5-build-the-web-interface">Step 5: Build the Web Interface</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-6-test-the-system">Step 6: Test the System</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-7-deploy-the-application">Step 7: Deploy the Application</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along with this tutorial, you should have:</p>
<ul>
<li><p>Basic knowledge of Python (functions, loops, dictionaries)</p>
</li>
<li><p>Python 3.8 or higher installed</p>
</li>
<li><p>Familiarity with installing packages using <code>pip</code></p>
</li>
<li><p>A code editor such as VS Code, PyCharm, or any editor you prefer</p>
</li>
</ul>
<h2 id="heading-project-overview">Project Overview</h2>
<p>In this guide, you’ll develop a system that takes a folder of résumés and a Job Description (JD) as input. The system processes each résumé, extracts relevant information, and calculates a score based on how well the candidate matches the job requirements.</p>
<h2 id="heading-how-the-system-works">How the System Works</h2>
<p>The project consists of four core components:</p>
<ul>
<li><p><strong>Résumé Parser</strong>: Reads PDF and DOCX files and extracts text</p>
</li>
<li><p><strong>JD Parser</strong>: Analyses the job description to identify required skills</p>
</li>
<li><p><strong>Keyword Extractor</strong>: Matches résumé content against a skills taxonomy</p>
</li>
<li><p><strong>Scoring Engine</strong>: Ranks candidates using a weighted algorithm</p>
</li>
</ul>
<h3 id="heading-scoring-formula">Scoring Formula</h3>
<p>Here’s the scoring formula we’ll use:</p>
<pre><code class="lang-plaintext">Total Score =
(Required Skills × 50%) +
(Preferred Skills × 25%) +
(Experience × 15%) +
(Keywords × 10%)
</code></pre>
<p>This approach ensures that essential skills carry more weight than secondary keywords.</p>
<h3 id="heading-how-this-approach-helps-reduce-bias">How This Approach Helps Reduce Bias</h3>
<p>This system evaluates résumés using predefined criteria instead of subjective judgment. Each résumé is scored based on the same set of required skills, preferred skills, experience indicators, and keywords.</p>
<p>Because all candidates are evaluated using the same weighted formula, personal factors such as writing style, formatting, or unconscious preferences don’t influence the ranking. The scoring logic focuses only on how closely a résumé matches the job requirements.</p>
<p>By normalising the evaluation process, the system promotes more consistent and objective screening, which helps reduce bias during the initial résumé review stage.</p>
<h2 id="heading-system-architecture">System Architecture</h2>
<pre><code class="lang-plaintext">Input                    Processing                     Output
─────                    ──────────                     ──────

Résumés ──► Résumé Parser ──► Keyword Extractor ──┐
(PDF/DOCX)                                        │
                                                  ├──► Scoring Engine ──► Ranked Results
Job Description ──► JD Parser ────────────────────┘
(TXT/PDF)
</code></pre>
<p>The system follows a simple input–process–output flow.</p>
<p>Résumés and the job description are provided as inputs. The Résumé Parser extracts text from each résumé, while the JD Parser identifies required and preferred skills from the job description.</p>
<p>The extracted résumé text is then passed to the Keyword Extractor, which matches skills and keywords using a predefined taxonomy.</p>
<p>Finally, the Scoring Engine applies a weighted formula to calculate a score for each candidate and outputs a ranked list of résumés.</p>
<h2 id="heading-project-structure">Project Structure</h2>
<pre><code class="lang-plaintext">resume_screening_system/
├── app.py                    # Streamlit web interface
├── main.py                   # Command-line interface
├── parsers/
│   ├── resume_parser.py      # PDF/DOCX text extraction
│   └── jd_parser.py          # Job description parsing
├── extractors/
│   └── keyword_extractor.py  # Skills and experience extraction
├── matcher/
│   └── scorer.py             # Scoring algorithm
├── data/
│   ├── config.json           # Scoring weights
│   └── skills_taxonomy.json  # Skills database
└── requirements.txt          # Dependencies
</code></pre>
<p>The project is organised into clear, modular directories. Parsing logic, keyword extraction, and scoring are separated into their own folders, while configuration files and data are kept isolated. This structure keeps the codebase easy to navigate, maintain, and extend.</p>
<h2 id="heading-step-1-set-up-the-project">Step 1: Set Up the Project</h2>
<p>Create the folder structure and set up a virtual environment:</p>
<pre><code class="lang-bash">mkdir resume_screening_system
<span class="hljs-built_in">cd</span> resume_screening_system
mkdir parsers extractors matcher data input output
python -m venv venv
</code></pre>
<p>Then go ahead and activate the virtual environment:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Windows</span>
<span class="hljs-built_in">source</span> venv/Scripts/activate

<span class="hljs-comment"># macOS / Linux</span>
<span class="hljs-built_in">source</span> venv/bin/activate
</code></pre>
<p>Install the required dependencies like this:</p>
<pre><code class="lang-bash">pip install PyPDF2 python-docx streamlit pandas
</code></pre>
<h2 id="heading-step-2-build-the-resume-parser">Step 2: Build the Résumé Parser</h2>
<p>The résumé parser handles different file formats by using a separate extraction method for each type.</p>
<p>For PDF files, the parser opens the document page by page and extracts text from each page using a PDF reader. The extracted text is combined into a single string for further processing.</p>
<p>For DOCX files, the parser reads each paragraph in the document and joins the paragraph text into one block. This ensures consistent text output regardless of the résumé format.</p>
<p>By combining all résumés into plain text, the parser allows components such as keyword extraction and scoring to work efficiently.</p>
<p><strong>File:</strong> <code>parsers/resume_parser.py</code></p>
<pre><code class="lang-python"><span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">_extract_pdf</span>(<span class="hljs-params">self, file_path: Path</span>) -&gt; str:</span>
    text = <span class="hljs-string">""</span>
    <span class="hljs-keyword">with</span> open(file_path, <span class="hljs-string">"rb"</span>) <span class="hljs-keyword">as</span> file:
        pdf_reader = PyPDF2.PdfReader(file)
        <span class="hljs-keyword">for</span> page <span class="hljs-keyword">in</span> pdf_reader.pages:
            page_text = page.extract_text()
            <span class="hljs-keyword">if</span> page_text:
                text += page_text + <span class="hljs-string">"\\n"</span>
    <span class="hljs-keyword">return</span> text.strip()

<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">_extract_docx</span>(<span class="hljs-params">self, file_path: Path</span>) -&gt; str:</span>
    <span class="hljs-keyword">from</span> docx <span class="hljs-keyword">import</span> Document
    doc = Document(file_path)
    <span class="hljs-keyword">return</span> <span class="hljs-string">"\\n"</span>.join(
        para.text <span class="hljs-keyword">for</span> para <span class="hljs-keyword">in</span> doc.paragraphs
    ).strip()
</code></pre>
<h2 id="heading-step-3-build-the-keyword-extractor">Step 3: Build the Keyword Extractor</h2>
<p>This project uses a résumé dataset from <a target="_blank" href="https://www.kaggle.com/datasets/snehaanbhawal/resume-dataset">Kaggle</a> to ensure the logic works with real-world professional data. The keyword extractor identifies skills by scanning the résumé text.</p>
<p>The résumé text is first converted to lowercase so that matching is case-insensitive. A predefined skills taxonomy stores each skill along with its possible variations. The extractor checks the résumé text against these variations to find matches.</p>
<p>Word boundaries are used during matching to avoid partial matches, such as matching “Java” inside “JavaScript”. Matched skills are stored in a set to prevent duplicates.</p>
<p>This approach ensures consistent and controlled skill detection across all résumés.</p>
<p><strong>File:</strong> <code>extractors/keyword_extractor.py</code></p>
<pre><code class="lang-python"><span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">extract_skills</span>(<span class="hljs-params">self, text: str</span>) -&gt; Set[str]:</span>
    text_lower = text.lower()
    found_skills = set()

    <span class="hljs-keyword">for</span> category, skills_dict <span class="hljs-keyword">in</span> self.skills_taxonomy.items():
        <span class="hljs-keyword">for</span> skill_name, variations <span class="hljs-keyword">in</span> skills_dict.items():
            <span class="hljs-keyword">for</span> variation <span class="hljs-keyword">in</span> variations:
                <span class="hljs-comment"># Prevent "Java" matching "JavaScript"</span>
                pattern = <span class="hljs-string">r"\\b"</span> + re.escape(variation) + <span class="hljs-string">r"\\b"</span>
                <span class="hljs-keyword">if</span> re.search(pattern, text_lower):
                    found_skills.add(skill_name)
                    <span class="hljs-keyword">break</span>

    <span class="hljs-keyword">return</span> found_skills
</code></pre>
<h2 id="heading-step-4-implement-the-scoring-engine">Step 4: Implement the Scoring Engine</h2>
<p>To produce objective rankings, the system uses a weighted scoring formula.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Component</td><td>Weight</td><td>Rationale</td></tr>
</thead>
<tbody>
<tr>
<td>Required Skills</td><td>50%</td><td>Essential technical needs</td></tr>
<tr>
<td>Preferred Skills</td><td>25%</td><td>Competitive differentiators</td></tr>
<tr>
<td>Experience</td><td>15%</td><td>Professional depth</td></tr>
<tr>
<td>Keywords</td><td>10%</td><td>Domain familiarity</td></tr>
</tbody>
</table>
</div><pre><code class="lang-plaintext">Total Score =
(S_req × 0.50) +
(S_pref × 0.25) +
(E_exp × 0.15) +
(K_key × 0.10)
</code></pre>
<p>The scoring engine calculates a final score for each résumé using weighted values.</p>
<p>It counts how many required skills, preferred skills, experience indicators, and keywords appear in a résumé. Each count is multiplied by its assigned weight, with required skills contributing the most.</p>
<p>The weighted values are summed to produce a single score. Résumés are then sorted by this score to generate a ranked list of candidates.</p>
<h2 id="heading-step-5-build-the-web-interface">Step 5: Build the Web Interface</h2>
<p>Streamlit provides a simple web interface for interacting with the résumé screening system.</p>
<p>The text area allows users to input a job description, while the file uploader lets them upload multiple résumé files. When the button is clicked, Streamlit triggers the backend logic to parse résumés, extract data, and calculate scores.</p>
<p>The results are then displayed in the browser, allowing users to run the screening process without using the command line.</p>
<p><strong>File:</strong> <code>app.py</code></p>
<pre><code class="lang-python"><span class="hljs-keyword">import</span> streamlit <span class="hljs-keyword">as</span> st

jd_text = st.text_area(
    <span class="hljs-string">"Paste the job description here:"</span>,
    height=<span class="hljs-number">300</span>
)

uploaded_files = st.file_uploader(
    <span class="hljs-string">"Upload resume files:"</span>,
    type=[<span class="hljs-string">"pdf"</span>, <span class="hljs-string">"docx"</span>, <span class="hljs-string">"txt"</span>],
    accept_multiple_files=<span class="hljs-literal">True</span>
)

<span class="hljs-keyword">if</span> st.button(<span class="hljs-string">"Screen Resumes"</span>, type=<span class="hljs-string">"primary"</span>):
    st.success(<span class="hljs-string">"Processing resumes..."</span>)
</code></pre>
<p>Run the application:</p>
<pre><code class="lang-bash">streamlit run app.py
</code></pre>
<p>The app will be available at <a target="_blank" href="http://localhost:8501/"><code>http://localhost:8500</code></a>.</p>
<h2 id="heading-step-6-test-the-system">Step 6: Test the System</h2>
<h3 id="heading-sample-job-description-input">Sample Job Description Input</h3>
<p>Below is an example of a job description you can use to test the system:</p>
<pre><code class="lang-plaintext">We are looking for a Senior Python Developer with strong experience in backend development.

Required Skills:
- Python
- Django
- REST APIs
- SQL

Preferred Skills:
- PostgreSQL
- Docker
- AWS

Experience:
- 3+ years of professional Python development
- Experience building web applications
</code></pre>
<p>This input helps the system identify required skills, preferred skills, and experience keywords, which are then used by the scoring engine to rank résumés.</p>
<pre><code class="lang-bash">python main.py
</code></pre>
<h3 id="heading-sample-output">Sample Output</h3>
<pre><code class="lang-plaintext">============================================================
SCREENING RESULTS
============================================================
Rank #1: Alice Johnson | Score: 85.42/100 | Matched: python, django, postgresql
Rank #2: Carol Davis   | Score: 72.50/100 | Matched: python, django
</code></pre>
<h2 id="heading-step-7-deploy-the-application">Step 7: Deploy the Application</h2>
<p>To make the system publicly accessible:</p>
<ol>
<li><p>Push the code to GitHub</p>
</li>
<li><p>Go to <a target="_blank" href="http://share.streamlit.io/"><code>share.streamlit.io</code></a></p>
</li>
<li><p>Select your <a target="_blank" href="http://app.py/"><code>app.py</code></a> file</p>
</li>
<li><p>Deploy the application</p>
</li>
</ol>
<p>Your app will be live at:</p>
<pre><code class="lang-plaintext">&lt;https://your-app-name.streamlit.app&gt;
</code></pre>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you’ve built a complete résumé screening system from scratch using Python. By combining text processing, structured scoring, and automation, this project demonstrates how manual résumé screening can be transformed into an efficient and objective workflow.</p>
<p>This system helps reduce bias, save time, and evaluate candidates more consistently. Happy coding!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Why You Should Stop Managing Kafka Manually – A Guide to Kafka UI and Cruise Control ]]>
                </title>
                <description>
                    <![CDATA[ Over 80% of Fortune 100 companies use Apache Kafka. That's not surprising, as Kafka has revolutionized how we build real-time data pipelines and streaming applications. If you're working in software engineering today, chances are you've encountered K... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/stop-managing-kafka-manually-a-guide-to-kafka-ui-and-cruise-control/</link>
                <guid isPermaLink="false">6967bd3e7b94dad713ce9fae</guid>
                
                    <category>
                        <![CDATA[ kafka ]]>
                    </category>
                
                    <category>
                        <![CDATA[ distributed system ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Operational Efficiency ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ramesh Sinha ]]>
                </dc:creator>
                <pubDate>Wed, 14 Jan 2026 15:58:54 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1768353949324/18ce4a43-fb21-4e9b-9285-7c4db7b7ae2e.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Over 80% of Fortune 100 companies use Apache Kafka. That's not surprising, as Kafka has revolutionized how we build real-time data pipelines and streaming applications. If you're working in software engineering today, chances are you've encountered Kafka in some capacity.</p>
<p>But here's the thing: while Kafka itself is incredibly powerful, managing Kafka clusters is notoriously challenging. This isn't a flaw in Kafka – it's just the reality of distributed systems. The bigger your cluster grows, the more complex operations become.</p>
<p>The most painful aspect? Manual cluster management. It's tedious, error-prone, and doesn't scale. What starts as simple topic creation with a few brokers turns into hours of carefully orchestrating partition reassignments across dozens of machines. One typo in a JSON file at 3 AM can take down production.</p>
<p>Sound familiar? You're not alone.</p>
<p>In this guide, you'll learn how two tools can transform Kafka operations from a manual slog into a manageable process:</p>
<ul>
<li><p><strong>Kafka UI</strong> – A modern web interface that replaces cryptic CLI commands with visual cluster management</p>
</li>
<li><p><strong>Cruise Control</strong> – LinkedIn's automation engine that handles cluster balancing and self-healing</p>
</li>
</ul>
<p>We'll start by experiencing the pain of manual management firsthand, then see how these tools solve real-world operational challenges. You'll set up everything locally with <code>Docker</code> and by the end you’ll know exactly how to manage Kafka clusters without the headache.</p>
<h2 id="heading-what-well-cover"><strong>What We’ll Cover:</strong></h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-setting-up-our-unmanaged-cluster">Setting Up Our Unmanaged Cluster</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-starting-the-cluster-amp-verification">Starting the Cluster &amp; Verification</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-creating-topics-the-manual-way">Creating Topics: The Manual Way</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-kafka-ui">Kafka UI</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-setting-up-kafka-ui">Setting up Kafka UI</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-drawbacks-of-kafka-ui">Drawbacks of Kafka UI</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-cruise-control">Cruise Control</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-how-cruise-control-works">How Cruise Control Works</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-setting-up-cruise-control">Setting Up Cruise Control</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-cruise-control-configuration-file">Cruise Control Configuration File</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-creating-the-imbalance">Creating the Imbalance</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-attempting-manual-rebalancing">Attempting Manual Rebalancing</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-rebalancing-using-cruise-control">Rebalancing Using Cruise Control</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-the-problem-manual-kafka-management">The Problem: Manual Kafka Management</h2>
<p>Let’s dive right in. First, I'm going to show you what managing a Kafka cluster looks like without any tools – just you, the command line, and dozens of manual operations.</p>
<p>You’ll spin up a small cluster locally, create some topics, and simulate the kind of growth you'd see in a real production environment. By the end of this section, you'll understand exactly why teams spend thousands of engineering hours just keeping Kafka clusters running smoothly.</p>
<p>Fair warning: this is going to feel tedious but it’s ok – <em>that’s the point</em>.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before we dive in, make sure you have:</p>
<ol>
<li><p><strong>Docker Desktop installed and running</strong></p>
<ul>
<li><p>Mac and Windows users: <a target="_blank" href="https://www.docker.com/products/docker-desktop/">https://www.docker.com/products/docker-desktop/</a></p>
</li>
<li><p>Linux users can install Docker Engine via their package manager </p>
</li>
</ul>
</li>
<li><p><strong>Basic Kafka knowledge.</strong> You should understand:</p>
<ul>
<li><p><strong>Topics</strong>: Categories for organizing messages</p>
</li>
<li><p><strong>Partitions</strong>: How topics are divided for parallelism</p>
</li>
<li><p><strong>Brokers</strong>: The Kafka servers that store data</p>
</li>
<li><p><strong>Producers and Consumers</strong>: Applications that write to and read from Kafka</p>
</li>
<li><p><strong>KRaft</strong>: Kafka consensus based discovery?</p>
</li>
</ul>
</li>
</ol>
<p>    If these terms are new to you, <a target="_blank" href="https://www.freecodecamp.org/news/apache-kafka-handbook/">here’s a great handbook about them</a>. I’d also recommend reading <a target="_blank" href="https://kafka.apache.org/intro">Kafka's Introduction</a> first.</p>
<ol start="3">
<li><p><strong>System Requirements</strong></p>
<ul>
<li><p>At least 8GB Ram</p>
</li>
<li><p>10GB Free Disk space</p>
</li>
</ul>
</li>
<li><p>Some basic understanding of <strong>containers</strong> is good to have:</p>
<ul>
<li><p>Docker</p>
</li>
<li><p>Images</p>
</li>
<li><p>Volumes</p>
</li>
<li><p>Networks</p>
</li>
</ul>
</li>
</ol>
<h2 id="heading-setting-up-our-unmanaged-cluster">Setting Up Our Unmanaged Cluster</h2>
<p>Let’s go ahead and build the cluster so that we can see the problems firsthand. We’ll use Docker to spin up three Kafka brokers running in <code>KRaft</code> mode (the modern, ZooKeeper-free approach).</p>
<p>Start by creating a file called <code>docker-compose-basic.yml</code>:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">version:</span> <span class="hljs-string">'3.8'</span>

<span class="hljs-attr">services:</span>
  <span class="hljs-attr">kafka-1:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">confluentinc/cp-kafka:7.6.0</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">kafka-1</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"9092:9092"</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-attr">KAFKA_NODE_ID:</span> <span class="hljs-number">1</span>
      <span class="hljs-attr">KAFKA_PROCESS_ROLES:</span> <span class="hljs-string">broker,controller</span>
      <span class="hljs-attr">KAFKA_CONTROLLER_QUORUM_VOTERS:</span> <span class="hljs-number">1</span><span class="hljs-string">@kafka-1:29093,2@kafka-2:29093,3@kafka-3:29093</span>
      <span class="hljs-attr">KAFKA_LISTENERS:</span> <span class="hljs-string">PLAINTEXT://0.0.0.0:29092,CONTROLLER://0.0.0.0:29093,PLAINTEXT_HOST://0.0.0.0:9092</span>
      <span class="hljs-attr">KAFKA_ADVERTISED_LISTENERS:</span> <span class="hljs-string">PLAINTEXT://kafka-1:29092,PLAINTEXT_HOST://localhost:9092</span>
      <span class="hljs-attr">KAFKA_LISTENER_SECURITY_PROTOCOL_MAP:</span> <span class="hljs-string">PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT</span>
      <span class="hljs-attr">KAFKA_CONTROLLER_LISTENER_NAMES:</span> <span class="hljs-string">CONTROLLER</span>
      <span class="hljs-attr">KAFKA_INTER_BROKER_LISTENER_NAME:</span> <span class="hljs-string">PLAINTEXT</span>
      <span class="hljs-attr">KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR:</span> <span class="hljs-number">2</span>
      <span class="hljs-attr">KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR:</span> <span class="hljs-number">2</span>
      <span class="hljs-attr">KAFKA_TRANSACTION_STATE_LOG_MIN_ISR:</span> <span class="hljs-number">1</span>
      <span class="hljs-attr">CLUSTER_ID:</span> <span class="hljs-string">'MkU3OEVBNTcwNTJENDM2Qk'</span>
      <span class="hljs-attr">KAFKA_LOG_DIRS:</span> <span class="hljs-string">/var/lib/kafka/data</span>
    <span class="hljs-attr">volumes:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">kafka-1-data:/var/lib/kafka/data</span>

  <span class="hljs-attr">kafka-2:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">confluentinc/cp-kafka:7.6.0</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">kafka-2</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"9093:9093"</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-attr">KAFKA_NODE_ID:</span> <span class="hljs-number">2</span>
      <span class="hljs-attr">KAFKA_PROCESS_ROLES:</span> <span class="hljs-string">broker,controller</span>
      <span class="hljs-attr">KAFKA_CONTROLLER_QUORUM_VOTERS:</span> <span class="hljs-number">1</span><span class="hljs-string">@kafka-1:29093,2@kafka-2:29093,3@kafka-3:29093</span>
      <span class="hljs-attr">KAFKA_LISTENERS:</span> <span class="hljs-string">PLAINTEXT://0.0.0.0:29092,CONTROLLER://0.0.0.0:29093,PLAINTEXT_HOST://0.0.0.0:9093</span>
      <span class="hljs-attr">KAFKA_ADVERTISED_LISTENERS:</span> <span class="hljs-string">PLAINTEXT://kafka-2:29092,PLAINTEXT_HOST://localhost:9093</span>
      <span class="hljs-attr">KAFKA_LISTENER_SECURITY_PROTOCOL_MAP:</span> <span class="hljs-string">PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT</span>
      <span class="hljs-attr">KAFKA_CONTROLLER_LISTENER_NAMES:</span> <span class="hljs-string">CONTROLLER</span>
      <span class="hljs-attr">KAFKA_INTER_BROKER_LISTENER_NAME:</span> <span class="hljs-string">PLAINTEXT</span>
      <span class="hljs-attr">KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR:</span> <span class="hljs-number">2</span>
      <span class="hljs-attr">KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR:</span> <span class="hljs-number">2</span>
      <span class="hljs-attr">KAFKA_TRANSACTION_STATE_LOG_MIN_ISR:</span> <span class="hljs-number">1</span>
      <span class="hljs-attr">CLUSTER_ID:</span> <span class="hljs-string">'MkU3OEVBNTcwNTJENDM2Qk'</span>
      <span class="hljs-attr">KAFKA_LOG_DIRS:</span> <span class="hljs-string">/var/lib/kafka/data</span>
    <span class="hljs-attr">volumes:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">kafka-2-data:/var/lib/kafka/data</span>

  <span class="hljs-attr">kafka-3:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">confluentinc/cp-kafka:7.6.0</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">kafka-3</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"9094:9094"</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-attr">KAFKA_NODE_ID:</span> <span class="hljs-number">3</span>
      <span class="hljs-attr">KAFKA_PROCESS_ROLES:</span> <span class="hljs-string">broker,controller</span>
      <span class="hljs-attr">KAFKA_CONTROLLER_QUORUM_VOTERS:</span> <span class="hljs-number">1</span><span class="hljs-string">@kafka-1:29093,2@kafka-2:29093,3@kafka-3:29093</span>
      <span class="hljs-attr">KAFKA_LISTENERS:</span> <span class="hljs-string">PLAINTEXT://0.0.0.0:29092,CONTROLLER://0.0.0.0:29093,PLAINTEXT_HOST://0.0.0.0:9094</span>
      <span class="hljs-attr">KAFKA_ADVERTISED_LISTENERS:</span> <span class="hljs-string">PLAINTEXT://kafka-3:29092,PLAINTEXT_HOST://localhost:9094</span>
      <span class="hljs-attr">KAFKA_LISTENER_SECURITY_PROTOCOL_MAP:</span> <span class="hljs-string">PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT</span>
      <span class="hljs-attr">KAFKA_CONTROLLER_LISTENER_NAMES:</span> <span class="hljs-string">CONTROLLER</span>
      <span class="hljs-attr">KAFKA_INTER_BROKER_LISTENER_NAME:</span> <span class="hljs-string">PLAINTEXT</span>
      <span class="hljs-attr">KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR:</span> <span class="hljs-number">2</span>
      <span class="hljs-attr">KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR:</span> <span class="hljs-number">2</span>
      <span class="hljs-attr">KAFKA_TRANSACTION_STATE_LOG_MIN_ISR:</span> <span class="hljs-number">1</span>
      <span class="hljs-attr">CLUSTER_ID:</span> <span class="hljs-string">'MkU3OEVBNTcwNTJENDM2Qk'</span>
      <span class="hljs-attr">KAFKA_LOG_DIRS:</span> <span class="hljs-string">/var/lib/kafka/data</span>
    <span class="hljs-attr">volumes:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">kafka-3-data:/var/lib/kafka/data</span>

<span class="hljs-attr">volumes:</span>
  <span class="hljs-attr">kafka-1-data:</span>
  <span class="hljs-attr">kafka-2-data:</span>
  <span class="hljs-attr">kafka-3-data:</span>
</code></pre>
<p>In the above configuration file, we’re creating three Kafka brokers (<code>kafka-1, kafka-2, kafka-3</code>). Each one uses the <code>confluentinc/cp-kafka:7.6.0</code> image and has its port opened (<code>9092, 9093, 9094</code>).</p>
<p>The environment variables are:</p>
<ul>
<li><p><strong>KAFKA_NODE_ID</strong> – A unique identifier for each broker (1,2,3). No two brokers can have the same ID.</p>
</li>
<li><p><strong>KAFKA_PROCESS_ROLES: broker, controller</strong> – This tells Kafka to run in <code>KRaft</code> mode (without ZooKeeper). Each broker acts as both a data broker and a controller for cluster coordination.</p>
</li>
<li><p><strong>KAFKA_CONTROLLER_QUORUM_VOTERS</strong> – The membership list that tells each broker how to find the others. All three brokers must have the identical list: <code>1@kafka-1:29093,2@kafka-2:29093,3@kafka-3:29093</code>. This is how they discover each other and elect a leader.</p>
</li>
<li><p><strong>CLUSTER_ID</strong> – A unique identifier for the entire cluster. All brokers must use the <strong>exact same value</strong> or they won't recognize each other as part of the same cluster. The actual value (<code>MkU3OEVBNTcwNTJENDM2Qk</code>) doesn't matter as long as long as it is consistent across brokers. One important thing to note is that CLUSTER_ID must be a valid <code>base64-encoded UUID</code>  per Kafka’s requirement.</p>
</li>
<li><p><strong>KAFKA_LISTENERS</strong> - Defines which network interfaces and ports Kafka listens on. We have three listeners:</p>
<ul>
<li><p><strong>PLAINTEXT://0.0.0.0:29092</strong>: For inter-broker communication (brokers talking to each other)</p>
</li>
<li><p><strong>CONTROLLER://0.0.0.0:29093</strong>: For controller communication in <code>KRaft</code> mode</p>
</li>
<li><p><strong>PLAINTEXT_HOST://0.0.0.0:9092</strong> (varies per broker): For external connections from your machine</p>
</li>
</ul>
</li>
<li><p><strong>KAFKA_ADVERTISED_LISTENERS</strong> – Tells clients (producers/consumers) how to connect to this broker. This is what gets returned when a client asks "<code>where should I connect?</code>" The PLAINTEXT_HOST://localhost:9092 part is what allows you to connect from your Mac.</p>
</li>
</ul>
<p>Note: <strong>Listener configuration is critical.</strong> Incorrect settings will prevent clients from connecting even when brokers are running. These settings work for local Docker environments where Docker's internal DNS resolves broker names (<code>kafka-1, kafka-2, kafka-3</code>). For production, replace hostnames with actual IP addresses or FQDNs - (Fully Qualified Domain Name):</p>
<ul>
<li><p><strong>KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 2</strong> – How many copies of consumer offset data to keep. We use 2 instead of 3 because with only three brokers, this prevents issues during rolling restarts. In production with more brokers, you'd use 3 or more.</p>
</li>
<li><p><strong>The Volumes</strong> –  <code>kafka-x-data:/var/lib/kafka/data</code>  creates persistent storage for each broker’s data. Without volumes you will lose your topics and messages if you stop or restart your containers. Volumes are assigned to each broker so they don’t accidentally share data.</p>
</li>
</ul>
<p>Note: For a restart from scratch you need to delete the volumes using the following command. The -v flag removes volumes. Without it, old data persists even after down. </p>
<pre><code class="lang-bash">docker compose -f docker-compose-basic.yml down -v
</code></pre>
<p>If you're using the legacy <code>docker-compose</code> tool (V1), replace <code>docker compose</code> with <code>docker-compose</code> in all commands throughout this tutorial.</p>
<h3 id="heading-ports"><strong>Ports</strong></h3>
<p>Three ports are used for any given broker. Their purposes are:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Port</td><td>Purpose</td></tr>
</thead>
<tbody>
<tr>
<td><strong>9092</strong></td><td>external connections (producers, consumers from you Mac)</td></tr>
<tr>
<td><strong>29092</strong></td><td>Internal broker-to-broker communication</td></tr>
<tr>
<td><strong>29093</strong></td><td>Cluster coordination via KRaft</td></tr>
</tbody>
</table>
</div><h2 id="heading-starting-the-cluster-amp-verification">Starting the Cluster &amp; Verification</h2>
<p>Now that we have the basic docker configuration for Kafka, let’s run it and verify the results.</p>
<p>Run the following command in the same directory where you saved <code>docker-compose-basic.yml</code>:</p>
<pre><code class="lang-bash">docker compose -f docker-compose-basic.yml up -d
</code></pre>
<p>The <code>-d</code> flag runs the containers in detached mode (in the background), so you get your terminal back.</p>
<p>You should see output like this:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1767554351786/4500c108-e9b6-403f-98cf-15198b3a9831.png" alt="Docker compose command result" class="image--center mx-auto" width="2484" height="830" loading="lazy"></p>
<p>Using the following command, check if the containers running Kafka brokers are up:</p>
<pre><code class="lang-bash">docker ps
</code></pre>
<p>You should see three Kafka containers (kafka-1, kafka-2, kafka-3) with status “<code>Up</code>” – something like this:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1767554452185/4e605547-8153-4a85-9903-e3b1132889a8.png" alt="Running Kafka Containers" class="image--center mx-auto" width="2486" height="278" loading="lazy"></p>
<p>Run the following command to verify that all three brokers are registered in the cluster:</p>
<pre><code class="lang-bash">docker <span class="hljs-built_in">exec</span> -it kafka-1 kafka-broker-api-versions --bootstrap-server kafka-1:29092,kafka-2:29092,kafka-3:29092
</code></pre>
<p>You should see API version information for all three brokers (IDs 1, 2, 3) without any connection errors.</p>
<p>Note that we’re using <code>kafka-1:29092,kafka-2:29092,kafka-3:29092</code> here (the internal Docker addresses) instead of localhost:9092 because this command runs inside the <code>kafka-1</code> container by virtue of <code>docker exec -it kafka-1</code>, where <code>localhost</code> only refers to that specific container.</p>
<p>If any of the above verification returns errors or doesn’t show expected result as shown in screenshots, you can run the following command to see logs and debug:</p>
<pre><code class="lang-bash">docker logs kafka-1
</code></pre>
<h2 id="heading-creating-topics-the-manual-way">Creating Topics: The Manual Way</h2>
<p>Now that we have a cluster running, let’s simulate a real production use case where different teams need Kafka topics for their applications – payments, logs, events, metrics notifications, you name it.</p>
<p>Let’s start by creating a topic for logs. The command to do this is:</p>
<pre><code class="lang-bash">docker <span class="hljs-built_in">exec</span> -it kafka-1 kafka-topics \
  --create \
  --topic freecodecamp-logs \
  --bootstrap-server kafka-1:29092,kafka-2:29092,kafka-3:29092 \
  --partitions 12 \
  --replication-factor 2 \
  --config retention.ms=604800000 \
  --config compression.type=snappy
</code></pre>
<p>You’ll need to specify some command parameters, which are:</p>
<ol>
<li><p>The exact broker address <code>kafka-1:29092,kafka-2:29092,kafka-3:29092</code> (or the IP address of your servers in production)</p>
</li>
<li><p>The number of partitions – I have used <code>12</code> in the above command. Creating too few partitions creates bottlenecks, while creating too many adds overhead.</p>
</li>
<li><p>Retention policy – I have used 7 days (that is, 604800000 milliseconds)</p>
</li>
<li><p>Compression type</p>
</li>
</ol>
<p>Manually managing these parameters and running the command a handful of times is okay – but what if you have to run this for every team in your enterprise? Each team will have different requirements. The grind of copy, paste, adjust becomes painful if you have 100+ topics and multiple clusters (dev, staging, prod).</p>
<p>Feel the pain yet? Well, let’s just go on for a minute and we’ll address this issue shortly. For now, if you run the above command you should see the “<strong>Created topic</strong>” message:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1767554860337/81937532-b88b-4d9a-bfca-f2f9b0433e4d.png" alt="Create Kafka Topic result" class="image--center mx-auto" width="2454" height="536" loading="lazy"></p>
<p>Note: We’re using <code>kafka-1:29092,kafka-2:29092,kafka-3:29092</code> to reach Kafka brokers because we’re running the command inside of broker kafka-1  by running using <code>docker exec</code>.</p>
<p>Let's keep going. We’ll create more topics using the same command by changing the topic name and partitions. Copy, paste, update, and run the above commands a couple times. On my machine, I ran it 3 more times like below (you can choose to run couple more times with changed values – it won’t matter because concrete values are not important for this tutorial):</p>
<pre><code class="lang-bash">docker <span class="hljs-built_in">exec</span> -it kafka-1 kafka-topics \
  --create \
  --topic freecodecamp-views \    
  --bootstrap-server kafka-1:29092,kafka-2:29092,kafka-3:29092 \
  --partitions 20 \
  --replication-factor 2 \
  --config retention.ms=604800000 \
  --config compression.type=snappy


docker <span class="hljs-built_in">exec</span> -it kafka-1 kafka-topics \
  --create \
  --topic freecodecamp-analytics \
  --bootstrap-server kafka-1:29092,kafka-2:29092,kafka-3:29092 \
  --partitions 3 \ 
  --replication-factor 2 \
  --config retention.ms=604800000 \
  --config compression.type=snappy


docker <span class="hljs-built_in">exec</span> -it kafka-1 kafka-topics \
  --create \
  --topic freecodecamp-articles \ 
  --bootstrap-server kafka-1:29092,kafka-2:29092,kafka-3:29092 \
  --partitions 5 \ 
  --replication-factor 2 \
  --config retention.ms=604800000 \
  --config compression.type=snappy
</code></pre>
<p>After creating the topics, let’s see all the ones you have now by running the following command:</p>
<pre><code class="lang-bash">docker <span class="hljs-built_in">exec</span> -it kafka-1 kafka-topics \ --list \ --bootstrap-server kafka-1:29092,kafka-2:29092,kafka-3:29092
</code></pre>
<p>You should see a list of topics like this:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1767555021681/451f69bc-5f91-432a-9c74-ca56b79aa179.png" alt="Kafka list of Topics" class="image--center mx-auto" width="2392" height="412" loading="lazy"></p>
<p>Notice that you just get the list of topics but no meaningful information, like:</p>
<ul>
<li><p>How many partitions does each have?</p>
</li>
<li><p>Which brokers are hosting them?</p>
</li>
<li><p>Are they evenly distributed?</p>
</li>
<li><p>What are their configurations?</p>
</li>
</ul>
<h3 id="heading-partition-information">Partition Information</h3>
<p>Let’s try to get information about our partitions. For this tutorial, I have created 4 topics and a total of 40 partitions spread across three brokers. I want to see which broker has the most partitions.</p>
<p>In a well-managed cluster, you’d want them roughly evenly distributed. But how can we check that? </p>
<p>Maybe the describe command shown below can help. Let’s run it:</p>
<pre><code class="lang-bash">docker <span class="hljs-built_in">exec</span> -it kafka-1 kafka-topics \
  --describe \
  --bootstrap-server kafka-1:29092,kafka-2:29092,kafka-3:29092
</code></pre>
<p>It will return a wall of text, something like this:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1767555186583/e50948e9-48f8-4431-8008-38f24da98373.png" alt="Kafka describe Topics" class="image--center mx-auto" width="2360" height="1344" loading="lazy"></p>
<p>So, we have partition information but:</p>
<ul>
<li><p>No summary or aggregation</p>
</li>
<li><p>No visual representation </p>
</li>
<li><p>It’s difficult to scan and compare</p>
</li>
<li><p>It gets exponentially worse with more topics</p>
</li>
</ul>
<h3 id="heading-counting-leaders">Counting Leaders</h3>
<p>The Leader field in the above screenshot tells you which broker is the leader for each partition. Leaders handle all read and write requests, so you want them evenly distributed or else some brokers will become overloaded. </p>
<p>Let’s try to count how many partitions each broker leads. To do that, run the following command:</p>
<pre><code class="lang-bash">docker <span class="hljs-built_in">exec</span> -it kafka-1 kafka-topics \
  --describe \
  --bootstrap-server kafka-1:29092,kafka-2:29092,kafka-3:29092 | grep <span class="hljs-string">"Leader: 1"</span> | wc -l
</code></pre>
<p>It will show something like this:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1767555304446/6a58be3a-e21f-4209-8165-1544c5bc6c20.png" alt="Kafka Leader Count result" class="image--center mx-auto" width="2466" height="292" loading="lazy"></p>
<p>Per my topic creation, <code>14</code> is the count of partitions where <code>broker 1 (Leader : 1)</code> is the leader. You might see a different number depending on how many topics and how many partitions you have created.</p>
<p>You can repeat this command to see the count of partitions led by other brokers. To do so, just change <code>Leader: 1</code> to <code>Leader: 2</code> or <code>Leader: 3.</code>. I get <code>14, 12, 14</code>:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1767555418151/8c66afe7-b5db-4106-800f-f6e7e8926ba2.png" alt="Kafka Leader Count for all Topics" class="image--center mx-auto" width="2318" height="704" loading="lazy"></p>
<p>That’s somewhat balanced, but you had to run the command multiple times, parse using <code>grep</code> and <code>wc</code>, and this is just 3 brokers. What if you had 100+? Also, what if you have to get the replicas’ information?</p>
<p>I could go on and on with the data we need and the commands to get that information. But the point I’m trying to make here is that sooner or later this becomes impossible to manage. Your team is going to need an army, and to be honest there isn’t much value in doing all of this manually. </p>
<p>So far, you’ve seen only simple operational commands, but the problems don’t stop there. In a real production environment there are more complex and challenging operations like:</p>
<ul>
<li><p><strong>Consumer Lag Monitoring</strong>: When consumers fall behind, you need to track which partitions are lagging, which consumer instances own them, and where the lag is growing or shrinking. With CLI tools, you get raw numbers but no trends or context.</p>
</li>
<li><p><strong>Broker Failures</strong>: When a broker fails, you need to identify under-replicated partitions, trigger leader elections, and create partition reassignment <code>JSON</code> files manually. One mistake in that JSON can cause data loss.</p>
</li>
<li><p><strong>Cluster rebalancing</strong>: You’ll see that when you add new brokers, they sit empty until you manually redistribute partitions. Similarly for removing brokers, you need to move all their partitions first. These operations require calculating optimal placement and creating complex reassignment plans.</p>
</li>
</ul>
<p>If you’re still with me, you’re probably thinking that there has to be a better way. Fortunately, there is – actually, there are a couple complimentary ways and we are going to talk about those next.</p>
<h2 id="heading-kafka-ui">Kafka UI</h2>
<p>Kafka UI is a modern, open-source web interface for managing Kafka clusters. It replaces the <code>command line chaos</code> we just experienced with a clean, visual dashboard.</p>
<p>Kafka UI provides the following features:</p>
<ul>
<li><p><code>Visual cluster Overview</code>: see all brokers, topics, and partitions at a glance.</p>
</li>
<li><p><code>Topic management</code>: create, configure, and delete topics with a GUI</p>
</li>
<li><p><code>Consumer group monitoring</code>: track lags, offsets, and consumer health in real-time</p>
</li>
<li><p><code>Message browsing</code>: view actual messages in topics without command line tools </p>
</li>
</ul>
<p>Without further ado, let’s set up Kafka UI.</p>
<h3 id="heading-setting-up-kafka-ui">Setting Up Kafka UI</h3>
<p>To setup up Kafka UI, let’s modify our existing <code>docker-compose-basic.yml</code> like this:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">version:</span> <span class="hljs-string">'3.8'</span>

<span class="hljs-attr">services:</span>
  <span class="hljs-attr">kafka-1:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">confluentinc/cp-kafka:7.6.0</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">kafka-1</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"9092:9092"</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-attr">KAFKA_NODE_ID:</span> <span class="hljs-number">1</span>
      <span class="hljs-attr">KAFKA_PROCESS_ROLES:</span> <span class="hljs-string">broker,controller</span>
      <span class="hljs-attr">KAFKA_CONTROLLER_QUORUM_VOTERS:</span> <span class="hljs-number">1</span><span class="hljs-string">@kafka-1:29093,2@kafka-2:29093,3@kafka-3:29093</span>
      <span class="hljs-attr">KAFKA_LISTENERS:</span> <span class="hljs-string">PLAINTEXT://0.0.0.0:29092,CONTROLLER://0.0.0.0:29093,PLAINTEXT_HOST://0.0.0.0:9092</span>
      <span class="hljs-attr">KAFKA_ADVERTISED_LISTENERS:</span> <span class="hljs-string">PLAINTEXT://kafka-1:29092,PLAINTEXT_HOST://localhost:9092</span>
      <span class="hljs-attr">KAFKA_LISTENER_SECURITY_PROTOCOL_MAP:</span> <span class="hljs-string">PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT</span>
      <span class="hljs-attr">KAFKA_CONTROLLER_LISTENER_NAMES:</span> <span class="hljs-string">CONTROLLER</span>
      <span class="hljs-attr">KAFKA_INTER_BROKER_LISTENER_NAME:</span> <span class="hljs-string">PLAINTEXT</span>
      <span class="hljs-attr">KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR:</span> <span class="hljs-number">2</span>
      <span class="hljs-attr">KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR:</span> <span class="hljs-number">2</span>
      <span class="hljs-attr">KAFKA_TRANSACTION_STATE_LOG_MIN_ISR:</span> <span class="hljs-number">1</span>
      <span class="hljs-attr">CLUSTER_ID:</span> <span class="hljs-string">'MkU3OEVBNTcwNTJENDM2Qk'</span>
      <span class="hljs-attr">KAFKA_LOG_DIRS:</span> <span class="hljs-string">/var/lib/kafka/data</span>
    <span class="hljs-attr">volumes:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">kafka-1-data:/var/lib/kafka/data</span>

  <span class="hljs-attr">kafka-2:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">confluentinc/cp-kafka:7.6.0</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">kafka-2</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"9093:9093"</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-attr">KAFKA_NODE_ID:</span> <span class="hljs-number">2</span>
      <span class="hljs-attr">KAFKA_PROCESS_ROLES:</span> <span class="hljs-string">broker,controller</span>
      <span class="hljs-attr">KAFKA_CONTROLLER_QUORUM_VOTERS:</span> <span class="hljs-number">1</span><span class="hljs-string">@kafka-1:29093,2@kafka-2:29093,3@kafka-3:29093</span>
      <span class="hljs-attr">KAFKA_LISTENERS:</span> <span class="hljs-string">PLAINTEXT://0.0.0.0:29092,CONTROLLER://0.0.0.0:29093,PLAINTEXT_HOST://0.0.0.0:9093</span>
      <span class="hljs-attr">KAFKA_ADVERTISED_LISTENERS:</span> <span class="hljs-string">PLAINTEXT://kafka-2:29092,PLAINTEXT_HOST://localhost:9093</span>
      <span class="hljs-attr">KAFKA_LISTENER_SECURITY_PROTOCOL_MAP:</span> <span class="hljs-string">PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT</span>
      <span class="hljs-attr">KAFKA_CONTROLLER_LISTENER_NAMES:</span> <span class="hljs-string">CONTROLLER</span>
      <span class="hljs-attr">KAFKA_INTER_BROKER_LISTENER_NAME:</span> <span class="hljs-string">PLAINTEXT</span>
      <span class="hljs-attr">KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR:</span> <span class="hljs-number">2</span>
      <span class="hljs-attr">KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR:</span> <span class="hljs-number">2</span>
      <span class="hljs-attr">KAFKA_TRANSACTION_STATE_LOG_MIN_ISR:</span> <span class="hljs-number">1</span>
      <span class="hljs-attr">CLUSTER_ID:</span> <span class="hljs-string">'MkU3OEVBNTcwNTJENDM2Qk'</span>
      <span class="hljs-attr">KAFKA_LOG_DIRS:</span> <span class="hljs-string">/var/lib/kafka/data</span>
    <span class="hljs-attr">volumes:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">kafka-2-data:/var/lib/kafka/data</span>

  <span class="hljs-attr">kafka-3:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">confluentinc/cp-kafka:7.6.0</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">kafka-3</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"9094:9094"</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-attr">KAFKA_NODE_ID:</span> <span class="hljs-number">3</span>
      <span class="hljs-attr">KAFKA_PROCESS_ROLES:</span> <span class="hljs-string">broker,controller</span>
      <span class="hljs-attr">KAFKA_CONTROLLER_QUORUM_VOTERS:</span> <span class="hljs-number">1</span><span class="hljs-string">@kafka-1:29093,2@kafka-2:29093,3@kafka-3:29093</span>
      <span class="hljs-attr">KAFKA_LISTENERS:</span> <span class="hljs-string">PLAINTEXT://0.0.0.0:29092,CONTROLLER://0.0.0.0:29093,PLAINTEXT_HOST://0.0.0.0:9094</span>
      <span class="hljs-attr">KAFKA_ADVERTISED_LISTENERS:</span> <span class="hljs-string">PLAINTEXT://kafka-3:29092,PLAINTEXT_HOST://localhost:9094</span>
      <span class="hljs-attr">KAFKA_LISTENER_SECURITY_PROTOCOL_MAP:</span> <span class="hljs-string">PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT</span>
      <span class="hljs-attr">KAFKA_CONTROLLER_LISTENER_NAMES:</span> <span class="hljs-string">CONTROLLER</span>
      <span class="hljs-attr">KAFKA_INTER_BROKER_LISTENER_NAME:</span> <span class="hljs-string">PLAINTEXT</span>
      <span class="hljs-attr">KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR:</span> <span class="hljs-number">2</span>
      <span class="hljs-attr">KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR:</span> <span class="hljs-number">2</span>
      <span class="hljs-attr">KAFKA_TRANSACTION_STATE_LOG_MIN_ISR:</span> <span class="hljs-number">1</span>
      <span class="hljs-attr">CLUSTER_ID:</span> <span class="hljs-string">'MkU3OEVBNTcwNTJENDM2Qk'</span>
      <span class="hljs-attr">KAFKA_LOG_DIRS:</span> <span class="hljs-string">/var/lib/kafka/data</span>
    <span class="hljs-attr">volumes:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">kafka-3-data:/var/lib/kafka/data</span>
<span class="hljs-comment"># Adding kafka-UI service start</span>
  <span class="hljs-attr">kafka-ui:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">provectuslabs/kafka-ui:latest</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">kafka-ui</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"8080:8080"</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-attr">DYNAMIC_CONFIG_ENABLED:</span> <span class="hljs-string">'true'</span>
      <span class="hljs-attr">KAFKA_CLUSTERS_0_NAME:</span> <span class="hljs-string">freecodecamp-cluster</span>
      <span class="hljs-attr">KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS:</span> <span class="hljs-string">kafka-1:29092,kafka-2:29092,kafka-3:29092</span>
    <span class="hljs-attr">depends_on:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">kafka-1</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">kafka-2</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">kafka-3</span>
<span class="hljs-comment"># Adding kafka-UI service end</span>
<span class="hljs-attr">volumes:</span>
  <span class="hljs-attr">kafka-1-data:</span>
  <span class="hljs-attr">kafka-2-data:</span>
  <span class="hljs-attr">kafka-3-data:</span>
</code></pre>
<p>The yaml file is pretty much the same as before except that we have added a new service called <code>kafka-ui</code> (for better clarity, I have added the changes in between start and end comments).</p>
<p>Key Configurations are:</p>
<ul>
<li><p><strong>Port 8080</strong> – You can access the UI at <a target="_blank" href="http://localhost:8080">http://localhost:8080</a> from your machine.</p>
</li>
<li><p><strong>KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS</strong> – This environment variable tells Kafka UI where to connect your cluster (using internal Docker addresses).</p>
</li>
<li><p><strong>KAFKA_CLUSTERS_0_NAME</strong> – A friendly name for your cluster in the UI.</p>
</li>
</ul>
<p>Let’s first clean up the old cluster while keeping the topic data intact. Go ahead and run the following command to do so:</p>
<pre><code class="lang-bash">docker compose -f docker-compose-basic.yml down
</code></pre>
<p>Note that we’re not using <code>-v</code> here, so volumes (topic data) will remain intact.</p>
<p>Wait for couple seconds and then run the following docker up command to bring up our cluster with Kafka UI:</p>
<pre><code class="lang-bash">docker compose -f docker-compose-basic.yml up -d
</code></pre>
<p>Now open a browser and visit <a target="_blank" href="http://localhost:8080/">http://localhost:8080/</a>. You’ll see the UI like this:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1767556151144/f0dd2a51-79c5-4906-a32d-d7732f9fd242.png" alt="Kafka UI" class="image--center mx-auto" width="2380" height="738" loading="lazy"></p>
<p>You can click around and see all information about the cluster we have created, like:</p>
<ul>
<li><p>Your 3 brokers </p>
</li>
<li><p>The topics you created earlier</p>
</li>
<li><p>Partition counts</p>
</li>
</ul>
<p>For comparison with manual commands, let's look at the Brokers tab. You can see the partition leader count for each broker at a glance – remember that we had to run multiple commands to get this information earlier. Beyond this, the UI provides many other useful metrics that would require separate command-line queries.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1767556236480/c99ba704-47b9-42da-b135-e1f9503ee1ab.png" alt="Kakfa UI Brokers" class="image--center mx-auto" width="2402" height="838" loading="lazy"></p>
<p>Remember the CLI commands we had to run to create topics? If you go to the <code>Topics</code> tab, you will notice that Topic management (<code>creation, deletion, data cleanup</code> and so on) are just a few button clicks.  </p>
<p>Similarly, managing Consumers only requires a few button clicks.</p>
<p>After exploring the Kafka UI, you'll see how much easier it is to monitor your cluster compared to running individual CLI commands.</p>
<h3 id="heading-drawbacks-of-kafka-ui">Drawbacks of Kafka UI</h3>
<p>That said, Kafka UI does have some limitations:</p>
<ul>
<li><p><strong>Automatic rebalancing</strong>: One or few brokers having more partitions that others, you must manually reassign them. </p>
</li>
<li><p><strong>Self-healing</strong>: If a broker fails, you have to manually create reassignment plans.</p>
</li>
<li><p><strong>Performance optimization</strong>: The UI can’t recommend intelligent partition placement.</p>
</li>
<li><p><strong>Alerts</strong>: The UI doesn’t warn you before problems happen.</p>
</li>
</ul>
<p>For small clusters (3 - 10 brokers ), Kafka UI and some command execution might be enough. You’ll be able to see problems clearly and fix them when needed.  </p>
<p>For large clusters, manual operations are still not scalable, so we need some kind of a complementary tool…and that tool is <strong>Cruise Control</strong>.</p>
<h2 id="heading-cruise-control">Cruise Control</h2>
<p>Cruise Control is an automation engine for Kafka clusters. While Kafka UI gives you visibility and manual control, Cruise Control provides intelligent automation and self-healing. You can think of Kafka UI as a dashboard with manual controls and Cruise Control as an autopilot. In other words, they complement each other.</p>
<p>Let’s try to create some imbalance in our cluster and fix it manually. This will help you learn how to reason through why you need Cruise Control. </p>
<p>To keep things simple, let’s start from scratch. We will first delete all the Docker resources we have created so far by running the following command:</p>
<pre><code class="lang-bash">docker compose -f docker-compose-basic.yml down -v
</code></pre>
<p>Running <code>docker-compose down -v</code> will delete all the topics and messages we created so far, but don’t worry –we’ll create them again.</p>
<h3 id="heading-how-cruise-control-works">How Cruise Control Works</h3>
<p>You can think of Cruise Control as a metric-monitoring and action-taking tool. Kafka brokers collect internal metrics (CPU, disk, network traffic, partition sizes), and a metric reporter running inside each broker sends these metrics to a Kafka topic.</p>
<p>Cruise Control then reads from that topic and analyzes the data. Based on that analysis, it proposes partition movements. We’ll see this in action shortly.</p>
<h3 id="heading-setting-up-cruise-control">Setting Up Cruise Control</h3>
<p>As of this writing, I couldn’t find a compatible Kafka and Cruise Control image that supports <code>KRaft</code> (Kafka Consensus Algorithm), so I decided to create Kafka and Cruise Control public images that will help with the tutorial. I don’t recommend using these images in production. For production usage, you should either wait for community to provide an image or create one of your own.</p>
<p>Change the <code>docker-compose-basic.yml</code> file to look like the below:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">version:</span> <span class="hljs-string">'3.8'</span>

<span class="hljs-attr">services:</span>
  <span class="hljs-attr">kafka-1:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">justramesh2000/kafka-apache-cc:3.8.1</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">kafka-1</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"9092:9092"</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-attr">KAFKA_NODE_ID:</span> <span class="hljs-number">1</span>
      <span class="hljs-attr">KAFKA_PROCESS_ROLES:</span> <span class="hljs-string">broker,controller</span>
      <span class="hljs-attr">KAFKA_CONTROLLER_QUORUM_VOTERS:</span> <span class="hljs-number">1</span><span class="hljs-string">@kafka-1:29093,2@kafka-2:29093,3@kafka-3:29093</span>
      <span class="hljs-attr">KAFKA_LISTENERS:</span> <span class="hljs-string">PLAINTEXT://0.0.0.0:29092,CONTROLLER://0.0.0.0:29093,PLAINTEXT_HOST://0.0.0.0:9092</span>
      <span class="hljs-attr">KAFKA_ADVERTISED_LISTENERS:</span> <span class="hljs-string">PLAINTEXT://kafka-1:29092,PLAINTEXT_HOST://localhost:9092</span>
      <span class="hljs-attr">KAFKA_LISTENER_SECURITY_PROTOCOL_MAP:</span> <span class="hljs-string">PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT</span>
      <span class="hljs-attr">KAFKA_CONTROLLER_LISTENER_NAMES:</span> <span class="hljs-string">CONTROLLER</span>
      <span class="hljs-attr">KAFKA_INTER_BROKER_LISTENER_NAME:</span> <span class="hljs-string">PLAINTEXT</span>
      <span class="hljs-attr">KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR:</span> <span class="hljs-number">2</span>
      <span class="hljs-attr">KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR:</span> <span class="hljs-number">2</span>
      <span class="hljs-attr">KAFKA_TRANSACTION_STATE_LOG_MIN_ISR:</span> <span class="hljs-number">1</span>
      <span class="hljs-attr">CLUSTER_ID:</span> <span class="hljs-string">'MkU3OEVBNTcwNTJENDM2Qk'</span>
      <span class="hljs-attr">KAFKA_LOG_DIRS:</span> <span class="hljs-string">/var/lib/kafka/data</span>
      <span class="hljs-comment"># Cruise Control Metrics Reporter</span>
      <span class="hljs-attr">KAFKA_METRIC_REPORTERS:</span> <span class="hljs-string">'com.linkedin.kafka.cruisecontrol.metricsreporter.CruiseControlMetricsReporter'</span>
      <span class="hljs-attr">KAFKA_CRUISE_CONTROL_METRICS_REPORTER_BOOTSTRAP_SERVERS:</span> <span class="hljs-string">'kafka-1:29092,kafka-2:29092,kafka-3:29092'</span>
      <span class="hljs-attr">KAFKA_CRUISE_CONTROL_METRICS_TOPIC_AUTO_CREATE:</span> <span class="hljs-string">'true'</span>
      <span class="hljs-attr">KAFKA_CRUISE_CONTROL_METRICS_TOPIC_NUM_PARTITIONS:</span> <span class="hljs-string">'1'</span>
      <span class="hljs-attr">KAFKA_CRUISE_CONTROL_METRICS_TOPIC_REPLICATION_FACTOR:</span> <span class="hljs-string">'2'</span>
      <span class="hljs-attr">KAFKA_CRUISE_CONTROL_METRICS_REPORTER_KUBERNETES_MODE:</span> <span class="hljs-string">'false'</span>
      <span class="hljs-attr">KAFKA_CRUISE_CONTROL_METRICS_REPORTER_METRICS_REPORTING_INTERVAL_MS:</span> <span class="hljs-string">'60000'</span>
    <span class="hljs-attr">volumes:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">kafka-1-data:/var/lib/kafka/data</span>

  <span class="hljs-attr">kafka-2:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">justramesh2000/kafka-apache-cc:3.8.1</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">kafka-2</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"9093:9093"</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-attr">KAFKA_NODE_ID:</span> <span class="hljs-number">2</span>
      <span class="hljs-attr">KAFKA_PROCESS_ROLES:</span> <span class="hljs-string">broker,controller</span>
      <span class="hljs-attr">KAFKA_CONTROLLER_QUORUM_VOTERS:</span> <span class="hljs-number">1</span><span class="hljs-string">@kafka-1:29093,2@kafka-2:29093,3@kafka-3:29093</span>
      <span class="hljs-attr">KAFKA_LISTENERS:</span> <span class="hljs-string">PLAINTEXT://0.0.0.0:29092,CONTROLLER://0.0.0.0:29093,PLAINTEXT_HOST://0.0.0.0:9093</span>
      <span class="hljs-attr">KAFKA_ADVERTISED_LISTENERS:</span> <span class="hljs-string">PLAINTEXT://kafka-2:29092,PLAINTEXT_HOST://localhost:9093</span>
      <span class="hljs-attr">KAFKA_LISTENER_SECURITY_PROTOCOL_MAP:</span> <span class="hljs-string">PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT</span>
      <span class="hljs-attr">KAFKA_CONTROLLER_LISTENER_NAMES:</span> <span class="hljs-string">CONTROLLER</span>
      <span class="hljs-attr">KAFKA_INTER_BROKER_LISTENER_NAME:</span> <span class="hljs-string">PLAINTEXT</span>
      <span class="hljs-attr">KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR:</span> <span class="hljs-number">2</span>
      <span class="hljs-attr">KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR:</span> <span class="hljs-number">2</span>
      <span class="hljs-attr">KAFKA_TRANSACTION_STATE_LOG_MIN_ISR:</span> <span class="hljs-number">1</span>
      <span class="hljs-attr">CLUSTER_ID:</span> <span class="hljs-string">'MkU3OEVBNTcwNTJENDM2Qk'</span>
      <span class="hljs-attr">KAFKA_LOG_DIRS:</span> <span class="hljs-string">/var/lib/kafka/data</span>
      <span class="hljs-attr">KAFKA_METRIC_REPORTERS:</span> <span class="hljs-string">com.linkedin.kafka.cruisecontrol.metricsreporter.CruiseControlMetricsReporter</span>
      <span class="hljs-attr">KAFKA_CRUISE_CONTROL_METRICS_REPORTER_BOOTSTRAP_SERVERS:</span> <span class="hljs-string">kafka-1:29092,kafka-2:29092,kafka-3:29092</span>
      <span class="hljs-attr">KAFKA_CRUISE_CONTROL_METRICS_REPORTER_KUBERNETES_MODE:</span> <span class="hljs-string">'false'</span>
      <span class="hljs-attr">KAFKA_CRUISE_CONTROL_METRICS_TOPIC:</span> <span class="hljs-string">__CruiseControlMetrics</span>
      <span class="hljs-attr">KAFKA_CRUISE_CONTROL_METRICS_TOPIC_AUTO_CREATE:</span> <span class="hljs-string">'true'</span>
      <span class="hljs-attr">KAFKA_CRUISE_CONTROL_METRICS_TOPIC_NUM_PARTITIONS:</span> <span class="hljs-string">'1'</span>
      <span class="hljs-attr">KAFKA_CRUISE_CONTROL_METRICS_TOPIC_REPLICATION_FACTOR:</span> <span class="hljs-string">'2'</span>
      <span class="hljs-attr">KAFKA_CRUISE_CONTROL_METRICS_REPORTER_METRICS_REPORTING_INTERVAL_MS:</span> <span class="hljs-string">'60000'</span>
    <span class="hljs-attr">volumes:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">kafka-2-data:/var/lib/kafka/data</span>

  <span class="hljs-attr">kafka-3:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">justramesh2000/kafka-apache-cc:3.8.1</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">kafka-3</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"9094:9094"</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-attr">KAFKA_NODE_ID:</span> <span class="hljs-number">3</span>
      <span class="hljs-attr">KAFKA_PROCESS_ROLES:</span> <span class="hljs-string">broker,controller</span>
      <span class="hljs-attr">KAFKA_CONTROLLER_QUORUM_VOTERS:</span> <span class="hljs-number">1</span><span class="hljs-string">@kafka-1:29093,2@kafka-2:29093,3@kafka-3:29093</span>
      <span class="hljs-attr">KAFKA_LISTENERS:</span> <span class="hljs-string">PLAINTEXT://0.0.0.0:29092,CONTROLLER://0.0.0.0:29093,PLAINTEXT_HOST://0.0.0.0:9094</span>
      <span class="hljs-attr">KAFKA_ADVERTISED_LISTENERS:</span> <span class="hljs-string">PLAINTEXT://kafka-3:29092,PLAINTEXT_HOST://localhost:9094</span>
      <span class="hljs-attr">KAFKA_LISTENER_SECURITY_PROTOCOL_MAP:</span> <span class="hljs-string">PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT</span>
      <span class="hljs-attr">KAFKA_CONTROLLER_LISTENER_NAMES:</span> <span class="hljs-string">CONTROLLER</span>
      <span class="hljs-attr">KAFKA_INTER_BROKER_LISTENER_NAME:</span> <span class="hljs-string">PLAINTEXT</span>
      <span class="hljs-attr">KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR:</span> <span class="hljs-number">2</span>
      <span class="hljs-attr">KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR:</span> <span class="hljs-number">2</span>
      <span class="hljs-attr">KAFKA_TRANSACTION_STATE_LOG_MIN_ISR:</span> <span class="hljs-number">1</span>
      <span class="hljs-attr">CLUSTER_ID:</span> <span class="hljs-string">'MkU3OEVBNTcwNTJENDM2Qk'</span>
      <span class="hljs-attr">KAFKA_LOG_DIRS:</span> <span class="hljs-string">/var/lib/kafka/data</span>
      <span class="hljs-attr">KAFKA_METRIC_REPORTERS:</span> <span class="hljs-string">com.linkedin.kafka.cruisecontrol.metricsreporter.CruiseControlMetricsReporter</span>
      <span class="hljs-attr">KAFKA_CRUISE_CONTROL_METRICS_REPORTER_BOOTSTRAP_SERVERS:</span> <span class="hljs-string">kafka-1:29092,kafka-2:29092,kafka-3:29092</span>
      <span class="hljs-attr">KAFKA_CRUISE_CONTROL_METRICS_REPORTER_KUBERNETES_MODE:</span> <span class="hljs-string">'false'</span>
      <span class="hljs-attr">KAFKA_CRUISE_CONTROL_METRICS_TOPIC:</span> <span class="hljs-string">__CruiseControlMetrics</span>
      <span class="hljs-attr">KAFKA_CRUISE_CONTROL_METRICS_TOPIC_AUTO_CREATE:</span> <span class="hljs-string">'true'</span>
      <span class="hljs-attr">KAFKA_CRUISE_CONTROL_METRICS_TOPIC_NUM_PARTITIONS:</span> <span class="hljs-string">'1'</span>
      <span class="hljs-attr">KAFKA_CRUISE_CONTROL_METRICS_TOPIC_REPLICATION_FACTOR:</span> <span class="hljs-string">'2'</span>
      <span class="hljs-attr">KAFKA_CRUISE_CONTROL_METRICS_REPORTER_METRICS_REPORTING_INTERVAL_MS:</span> <span class="hljs-string">'60000'</span>
    <span class="hljs-attr">volumes:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">kafka-3-data:/var/lib/kafka/data</span>
  <span class="hljs-comment"># Adding kafka-UI service start</span>
  <span class="hljs-attr">kafka-ui:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">provectuslabs/kafka-ui:latest</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">kafka-ui</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"8080:8080"</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-attr">DYNAMIC_CONFIG_ENABLED:</span> <span class="hljs-string">'true'</span>
      <span class="hljs-attr">KAFKA_CLUSTERS_0_NAME:</span> <span class="hljs-string">freecodecamp-cluster</span>
      <span class="hljs-attr">KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS:</span> <span class="hljs-string">kafka-1:29092,kafka-2:29092,kafka-3:29092</span>
    <span class="hljs-attr">depends_on:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">kafka-1</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">kafka-2</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">kafka-3</span>
    <span class="hljs-attr">volumes:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">./config:/opt/cruise-control/config</span>  
  <span class="hljs-comment"># Adding kafka-UI service end</span>
  <span class="hljs-comment"># Adding cruise-control start</span>
  <span class="hljs-attr">cruise-control:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">justramesh2000/cruise-control-kraft:2.5.142</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">cruise-control</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"9090:9090"</span>
    <span class="hljs-attr">volumes:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">./config/cruisecontrol.properties:/opt/cruise-control/config/cruisecontrol.properties</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">./config/capacityJBOD.json:/opt/cruise-control/config/capacityJBOD.json:ro</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">./config/log4j.properties:/opt/cruise-control/config/log4j.properties:ro</span>
    <span class="hljs-attr">depends_on:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">kafka-1</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">kafka-2</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">kafka-3</span>
   <span class="hljs-comment"># Adding cruise-control end    </span>
<span class="hljs-attr">volumes:</span>
  <span class="hljs-attr">kafka-1-data:</span>
  <span class="hljs-attr">kafka-2-data:</span>
  <span class="hljs-attr">kafka-3-data:</span>
</code></pre>
<p>You should have made the following changes to the file:</p>
<ul>
<li><p>Changed Kafka image from <code>confluentinc/cp-kafka:7.6.0</code> to <code>justramesh2000/kafka-apache-cc:3.8.1</code>. The new image contains the Cruise Control metrics exporter which will export metrics data from Kafka brokers to be used by Cruise Control.</p>
</li>
<li><p>Added the following environment variables:</p>
<ul>
<li><p><strong>KAFKA_METRIC_REPORTERS</strong> – This variable tells Kafka to load a plugin called the <code>Cruise Control Metrics Reporter</code>. It runs inside each Kafka broker process, and hooks into Kafka’s internal metrics system. This helps with data collection.</p>
</li>
<li><p><strong>KAFKA_CRUISE_CONTROL_METRICS_REPORTER_BOOTSTRAP_SERVERS</strong> – This tells the <code>Cruise Control Metrics Reporter</code> where to send metrics to, meaning which Kafka brokers and which port.</p>
</li>
<li><p><strong>KAFKA_CRUISE_CONTROL_METRICS_REPORTER_KUBERNETES_MODE</strong> – This disables specific Kubernetes behaviors (Pod name, id instead of Host). We are using Docker, so we don’t need K8s behaviors.</p>
</li>
<li><p><strong>KAFKA_CRUISE_CONTROL_METRICS_TOPIC</strong> – Specifies the name of the topic where metrics will be published. Default is <code>__CruiseControlMetrics</code> but you can customize using this variable if you want to.</p>
</li>
<li><p><strong>KAFKA_CRUISE_CONTROL_METRICS_TOPIC_AUTO_CREATE</strong> – Automatically creates a <code>__CruiseControlMetrics</code> topic if it doesn’t exist. Without this metric, the reporter will fail reporting until you manually create this topic.</p>
</li>
<li><p><strong>KAFKA_CRUISE_CONTROL_METRICS_TOPIC_NUM_PARTITIONS</strong> – Defines the number of partitions for the topic <code>__CruiseControlMetrics</code>.</p>
</li>
<li><p><strong>KAFKA_CRUISE_CONTROL_METRICS_TOPIC_REPLICATION_FACTOR</strong> – Tells Kafka how many copies of metrics data to keep. In our case, we’re keeping 2 copies of the data.</p>
</li>
<li><p><strong>KAFKA_CRUISE_CONTROL_METRICS_REPORTER_METRICS_REPORTING_INTERVAL_MS</strong> – Tells Kafka how often to send metrics. We’re sending every minute.</p>
</li>
</ul>
</li>
<li><p>Added Cruise-control service using image <code>justramesh2000/cruise-control-kraft:2.5.142</code>. For clarity, I have kept this change between the <code>start</code> and <code>end</code> comments.</p>
</li>
<li><p>Under cruise control, we’ve mounted <code>three</code> Cruise Control configurations files. We’ll talk about those files next.</p>
</li>
</ul>
<h3 id="heading-cruise-control-configuration-file">Cruise Control Configuration File</h3>
<p>To run Cruise Control, we need to provide several configuration files. Among the key pieces of information are:</p>
<ul>
<li><p>Where the Kafka cluster is located</p>
</li>
<li><p>The capacity of each broker</p>
</li>
</ul>
<p>Create a config directory and add the following files:</p>
<pre><code class="lang-bash">mkdir config
</code></pre>
<h4 id="heading-cruisecontrolproperties">cruisecontrol.properties</h4>
<p>This is Cruise Control’s main configuration file.</p>
<p>Save the following content as <code>cruisecontrol.properties</code> in the config directory:</p>
<pre><code class="lang-abap"># Kafka cluster. Tells how <span class="hljs-keyword">to</span> connect <span class="hljs-keyword">to</span> brokers
bootstrap.servers=kafka-<span class="hljs-number">1</span>:<span class="hljs-number">29092</span>,kafka-<span class="hljs-number">2</span>:<span class="hljs-number">29092</span>,kafka-<span class="hljs-number">3</span>:<span class="hljs-number">29092</span>

# <span class="hljs-keyword">Topic</span> <span class="hljs-keyword">from</span> which metrics are <span class="hljs-keyword">to</span> be <span class="hljs-keyword">read</span>
metric.reporter.<span class="hljs-keyword">topic</span>=__CruiseControlMetrics

# Aggregated partition <span class="hljs-keyword">data</span>
partition.metric.sample.store.<span class="hljs-keyword">topic</span>=__KafkaCruiseControlPartitionMetricSamples

#Aggregated broker <span class="hljs-keyword">data</span>
broker.metric.sample.store.<span class="hljs-keyword">topic</span>=__KafkaCruiseControlModelTrainingSamples

# Enable broker failure detection <span class="hljs-keyword">for</span> KRaft <span class="hljs-keyword">mode</span> (no ZooKeeper)
kafka.broker.failure.detection.enable=true

# Capacity. Tells <span class="hljs-keyword">where</span> the capacity file <span class="hljs-keyword">is</span> 
capacity.config.file=config/capacityJBOD.json

# Goals. What <span class="hljs-keyword">to</span> optimize <span class="hljs-keyword">for</span> <span class="hljs-keyword">during</span> cluster balancing. These are the riles <span class="hljs-keyword">for</span> CC <span class="hljs-keyword">to</span> abide <span class="hljs-keyword">to</span> <span class="hljs-keyword">during</span> rebalancing
<span class="hljs-keyword">default</span>.goals=com.linkedin.kafka.cruisecontrol.<span class="hljs-keyword">analyzer</span>.goals.RackAwareGoal,\
com.linkedin.kafka.cruisecontrol.<span class="hljs-keyword">analyzer</span>.goals.ReplicaCapacityGoal,\
com.linkedin.kafka.cruisecontrol.<span class="hljs-keyword">analyzer</span>.goals.DiskCapacityGoal,\
com.linkedin.kafka.cruisecontrol.<span class="hljs-keyword">analyzer</span>.goals.NetworkInboundCapacityGoal,\
com.linkedin.kafka.cruisecontrol.<span class="hljs-keyword">analyzer</span>.goals.NetworkOutboundCapacityGoal,\
com.linkedin.kafka.cruisecontrol.<span class="hljs-keyword">analyzer</span>.goals.CpuCapacityGoal,\
com.linkedin.kafka.cruisecontrol.<span class="hljs-keyword">analyzer</span>.goals.ReplicaDistributionGoal,\
com.linkedin.kafka.cruisecontrol.<span class="hljs-keyword">analyzer</span>.goals.DiskUsageDistributionGoal,\
com.linkedin.kafka.cruisecontrol.<span class="hljs-keyword">analyzer</span>.goals.LeaderReplicaDistributionGoal,\
com.linkedin.kafka.cruisecontrol.<span class="hljs-keyword">analyzer</span>.goals.LeaderBytesInDistributionGoal

# hard goals. 
hard.goals=com.linkedin.kafka.cruisecontrol.<span class="hljs-keyword">analyzer</span>.goals.RackAwareGoal,\
com.linkedin.kafka.cruisecontrol.<span class="hljs-keyword">analyzer</span>.goals.ReplicaCapacityGoal,\
com.linkedin.kafka.cruisecontrol.<span class="hljs-keyword">analyzer</span>.goals.DiskCapacityGoal,\
com.linkedin.kafka.cruisecontrol.<span class="hljs-keyword">analyzer</span>.goals.NetworkInboundCapacityGoal,\
com.linkedin.kafka.cruisecontrol.<span class="hljs-keyword">analyzer</span>.goals.NetworkOutboundCapacityGoal,\
com.linkedin.kafka.cruisecontrol.<span class="hljs-keyword">analyzer</span>.goals.CpuCapacityGoal

# Webserver. <span class="hljs-keyword">For</span> WebApi access
webserver.http.port=<span class="hljs-number">9090</span>
webserver.http.address=<span class="hljs-number">0.0</span>.<span class="hljs-number">0.0</span>

# Execution
num.broker.metrics.windows=<span class="hljs-number">1</span>
num.partition.metrics.windows=<span class="hljs-number">1</span>
</code></pre>
<p>I’ve added in line comments to explain much of the above configuration, but I think the <code>Goals</code> need special attention. These are the rules that we as users have set for Cruise Control to abide by.</p>
<p>By defining goals, we tell Cruise Control to do the following:</p>
<ul>
<li><p><code>RackAwareGoal</code> – Spread replicas across racks (or in our case, brokers)</p>
</li>
<li><p><code>ReplicaCapacityGoal</code> – Don't overload brokers with too many replicas</p>
</li>
<li><p><code>DiskCapacityGoal</code> – Don't fill up disk</p>
</li>
<li><p><code>NetworkInboundCapacityGoal</code> – Balance incoming network traffic</p>
</li>
<li><p><code>NetworkOutboundCapacityGoal</code> – Balance outgoing network traffic</p>
</li>
<li><p><code>CpuCapacityGoal</code> – Balance CPU usage</p>
</li>
<li><p><code>ReplicaDistributionGoal</code> – Evenly distribute replicas</p>
</li>
<li><p><code>DiskUsageDistributionGoal</code> – Ensure even disk usage across brokers</p>
</li>
<li><p><code>LeaderReplicaDistributionGoal</code> – Evenly distribute leader replicas</p>
</li>
<li><p><code>LeaderBytesInDistributionGoal</code> – Balance data flowing to leaders</p>
</li>
</ul>
<p>Via Cruise Control configuration, you can define two types of goals: <code>Default goals</code> and <code>Hard goals</code>. Hard goals must be met. Default goals that aren’t part of the hard goals become soft goals. This means that Cruise Control will give its best effort to satisfy them but won’t reject a proposal if it can’t.</p>
<p>Here’s a little summary:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Type</td><td>Meaning</td><td>What CC Does</td></tr>
</thead>
<tbody>
<tr>
<td><strong>Hard Goals</strong></td><td>Must-haves (capacity limits)</td><td><strong>Never violates</strong> – rejects proposal if can't satisfy</td></tr>
<tr>
<td><strong>Soft Goals</strong></td><td>Nice-to-haves (better balance)</td><td><strong>Tries to satisfy</strong> – skips if conflicts with hard goals</td></tr>
<tr>
<td><strong>Default Goals</strong></td><td>Hard + Soft together</td><td><strong>Optimizes for all</strong> – prioritizes hard over soft</td></tr>
</tbody>
</table>
</div><p>Cruise control collects metrics for a defined period (default: 5 minutes) and creates a monitoring window. The following settings control how many windows Cruise Control needs before it’s ready to generate proposals (shortly, we will see what proposals are):</p>
<ul>
<li><p><code>num.broker.metrics.windows=1</code>: Wait for 1 monitoring window before generating proposals. Each window in Cruise Control is 5 minutes by default. This means that Cruise Control will be ready after 5 minutes. I’ve set this to 1 for quick testing. The recommendation is to use a large window in production to avoid false proposals from temporary spikes.</p>
</li>
<li><p><code>num.partition.metrics.windows=1</code>: Wait for 1 window of partition metrics. Same reasoning as above.</p>
</li>
</ul>
<h4 id="heading-capacity">Capacity</h4>
<p>This informs cruise control about the capacity (CPU, DISK) of each broker, which then helps it to make decisions. Using the below file, we’re telling Cruise Control the following:</p>
<ul>
<li><p>What are the brokerIds</p>
</li>
<li><p>What is the disk path <code>/var/lib/kafka/data</code> and disk capacity (<code>100000000</code> MB = 100 GB). This is used by <code>DiskCapacityGoal</code> that we set up in the above <code>cruisecontrol.properties</code> file.</p>
</li>
<li><p>What is the CPU 100% (1 Core). Used by <code>CpuCapacityGoal</code>.</p>
</li>
<li><p>What is the <code>NW_IN</code> Network Inbound Capacity (125,000 KB/s = 1 MB/s –Megabytes per second) = 1 Gbps – Giga <code>bits</code> per second). Used by <code>NetworkInboundCapacityGoal</code>.</p>
</li>
<li><p>What is the <code>NW_OUT</code> Network Outbound Capacity (125,000 KB/s). Used by <code>NetworkOutboundCapacityGoal</code></p>
</li>
</ul>
<p>Save the following content as <code>capacityJBOD.json</code> in the config directory:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"brokerCapacities"</span>:[
    {
      <span class="hljs-attr">"brokerId"</span>: <span class="hljs-string">"1"</span>,
      <span class="hljs-attr">"capacity"</span>: {
        <span class="hljs-attr">"DISK"</span>: {<span class="hljs-attr">"/var/lib/kafka/data"</span>: <span class="hljs-string">"100000000"</span>},
        <span class="hljs-attr">"CPU"</span>: <span class="hljs-string">"100"</span>,
        <span class="hljs-attr">"NW_IN"</span>: <span class="hljs-string">"125000"</span>,
        <span class="hljs-attr">"NW_OUT"</span>: <span class="hljs-string">"125000"</span>
      }
    },
    {
      <span class="hljs-attr">"brokerId"</span>: <span class="hljs-string">"2"</span>,
      <span class="hljs-attr">"capacity"</span>: {
        <span class="hljs-attr">"DISK"</span>: {<span class="hljs-attr">"/var/lib/kafka/data"</span>: <span class="hljs-string">"100000000"</span>},
        <span class="hljs-attr">"CPU"</span>: <span class="hljs-string">"100"</span>,
        <span class="hljs-attr">"NW_IN"</span>: <span class="hljs-string">"125000"</span>,
        <span class="hljs-attr">"NW_OUT"</span>: <span class="hljs-string">"125000"</span>
      }
    },
    {
      <span class="hljs-attr">"brokerId"</span>: <span class="hljs-string">"3"</span>,
      <span class="hljs-attr">"capacity"</span>: {
        <span class="hljs-attr">"DISK"</span>: {<span class="hljs-attr">"/var/lib/kafka/data"</span>: <span class="hljs-string">"100000000"</span>},
        <span class="hljs-attr">"CPU"</span>: <span class="hljs-string">"100"</span>,
        <span class="hljs-attr">"NW_IN"</span>: <span class="hljs-string">"125000"</span>,
        <span class="hljs-attr">"NW_OUT"</span>: <span class="hljs-string">"125000"</span>
      }
    }
  ]
}
</code></pre>
<h4 id="heading-logging">Logging</h4>
<p>This is not important for Cruise Control to work properly, but it’ll help you debug if there are issues. Save the following content as <code>log4j.properties</code> in the config directory. When you execute commands to start Cruise Control and If you see unexpected behaviors like container exiting, you can use the <code>docker logs</code> command to see what happened.</p>
<pre><code class="lang-abap"># Root logger - INFO level, output <span class="hljs-keyword">to</span> console
rootLogger.level=INFO
appenders=console

# Console output (<span class="hljs-keyword">for</span> docker logs)
appender.console.<span class="hljs-keyword">type</span>=Console
appender.console.name=STDOUT
appender.console.layout.<span class="hljs-keyword">type</span>=PatternLayout
appender.console.layout.pattern=[%d] %p %m (%c)%n

# <span class="hljs-keyword">Send</span> root logger <span class="hljs-keyword">to</span> console
rootLogger.appenderRef.console.<span class="hljs-keyword">ref</span>=STDOUT
</code></pre>
<p>Now that we have all the configurations in place, let’s run the following command to start Kafka brokers with Kafka UI and Cruise Control:</p>
<pre><code class="lang-bash">docker compose -f docker-compose-basic.yml up -d
</code></pre>
<p>Using the following command, verify that the three Kafka brokers, Kafka UI, and Cruise Control containers are running:</p>
<pre><code class="lang-bash">docker ps
</code></pre>
<p>You should see something like this:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768085885265/b196c5ce-77b3-4563-b72a-20c6de7123f0.png" alt="Docker running containers" class="image--center mx-auto" width="2634" height="214" loading="lazy"></p>
<p>Now that we have Cruise Control up and running, let’s create some Imbalance and see how much better of an experience we get by using Cruise Control versus mitigating the imbalance manually.</p>
<h3 id="heading-creating-the-imbalance">Creating the Imbalance</h3>
<p>An imbalance is a scenario where some brokers are handling more messages than others – and they may run into high disk usage or high IOPS.</p>
<p>To create the imbalance in our cluster, we’ll have to create a few topics and then produce messages unevenly. Now that you have Kafka UI running, you can create topics using that method or you can create topics using commands. I’m going to use the commands because it’ll be easier for you to reproduce my work (but I recommend UI for production operations because it prevents typos).</p>
<p>If you also decide to use commands, run the following command. Then using UI, verify that the topics have been created.</p>
<p>Note: You’ll find that the commands are different from previous commands. This is because, previously in our <code>docker-compose-basic.yml</code> file, we were using the <code>confluentinc/cp-kafka:7.6.0</code> image for Kafka. But now we’re using the <code>justramesh2000/kafka-apache-cc:3.8.1</code> image which is based off of the <code>apache/kafka:3.8.1</code> image. For different images, the tools are located at different places, so the command needs to be adjusted to account for that.</p>
<pre><code class="lang-bash">docker <span class="hljs-built_in">exec</span> -it kafka-1 bash -c <span class="hljs-string">'
/opt/kafka/bin/kafka-topics.sh --create \
  --topic freecodecamp-logs \
  --bootstrap-server kafka-1:29092 \
  --partitions 12 \
  --replication-factor 2 \
  --config retention.ms=604800000 \
  --config compression.type=snappy

/opt/kafka/bin/kafka-topics.sh --create \
  --topic freecodecamp-views \
  --bootstrap-server kafka-1:29092 \
  --partitions 20 \
  --replication-factor 2 \
  --config retention.ms=604800000 \
  --config compression.type=snappy

/opt/kafka/bin/kafka-topics.sh --create \
  --topic freecodecamp-analytics \
  --bootstrap-server kafka-1:29092 \
  --partitions 3 \
  --replication-factor 2 \
  --config retention.ms=604800000 \
  --config compression.type=snappy

/opt/kafka/bin/kafka-topics.sh --create \
  --topic freecodecamp-articles \
  --bootstrap-server kafka-1:29092 \
  --partitions 5 \
  --replication-factor 2 \
  --config retention.ms=604800000 \
  --config compression.type=snappy
'</span>
</code></pre>
<p>Run the following command to produce uneven messages on different topics we created above.</p>
<p>Heavy Load on <code>freecodecamp-logs</code>:</p>
<pre><code class="lang-bash">docker <span class="hljs-built_in">exec</span> -it kafka-1 bash -c <span class="hljs-string">"
for i in {1..100000}; do 
  echo '{\"log_id\":\"'\$i'\",\"level\":\"INFO\",\"message\":\"Log entry '\$i'\"}'
done | /opt/kafka/bin/kafka-console-producer.sh --topic freecodecamp-logs --bootstrap-server kafka-1:29092"</span>
</code></pre>
<p>Heavy load on <code>freecodecamp-views</code>:</p>
<pre><code class="lang-bash">docker <span class="hljs-built_in">exec</span> -it kafka-1 bash -c <span class="hljs-string">"
for i in {1..80000}; do 
  echo '{\"view_id\":\"'\$i'\",\"page\":\"/article/'\$((i % 100))'\",\"user\":\"user_'\$((i % 1000))'\"}'
done | /opt/kafka/bin/kafka-console-producer.sh --topic freecodecamp-views --bootstrap-server kafka-1:29092"</span>
</code></pre>
<p>Moderate load on <code>freecodecamp-analytics</code>:</p>
<pre><code class="lang-bash">docker <span class="hljs-built_in">exec</span> -it kafka-1 bash -c <span class="hljs-string">"
for i in {1..30000}; do 
  echo '{\"event\":\"page_view\",\"user\":\"user_'\$i'\"}'
done | /opt/kafka/bin/kafka-console-producer.sh --topic freecodecamp-analytics --bootstrap-server kafka-1:29092"</span>
</code></pre>
<p>Now, produce a message with a <code>fixed key</code> to force all data into one Partition. This is a fast way to create strong disk imbalance. Run the following command:</p>
<pre><code class="lang-bash">docker <span class="hljs-built_in">exec</span> -it kafka-1 bash -c <span class="hljs-string">"
for i in {1..300000}; do
  echo 'hotkey:{\"log_id\":'\$i',\"msg\":\"big payload\"}'
done | /opt/kafka/bin/kafka-console-producer.sh \
  --topic freecodecamp-logs \
  --bootstrap-server kafka-1:29092 \
  --property parse.key=true \
  --property key.separator=:"</span>
</code></pre>
<p>After running the above commands, come back to the UI, refresh, and you will see a number of messages like this:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768088817389/13c285b2-308a-4e6c-80f1-4de774f34662.png" alt="Kafka Topics with Message Count" class="image--center mx-auto" width="3738" height="1004" loading="lazy"></p>
<p>Now, go to brokers tab and see the imbalance in Disk Usage:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768088880398/5e615012-a35f-4820-8daa-b46746b65b56.png" alt="Kafka Brokers Disk usage" class="image--center mx-auto" width="4128" height="810" loading="lazy"></p>
<p>You should be able to see that <strong>Broker-2 has only about 47% of the data that Broker-1 has</strong>, and <strong>Broker-3 has about 11% more data than Broker-1</strong>. Broker-2 is significantly underutilized, while Broker-1 and Broker-3 hold most of the data.</p>
<h3 id="heading-attempting-manual-rebalancing">Attempting Manual Rebalancing</h3>
<p><strong>Step 1</strong>: First, we need to find out which topic is heavy – meaning which one handles more data. My setup shows the <code>freecodecamp-logs</code> topic with 8MB of data:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768089339438/e57c1b10-ebac-4824-ad0d-1c8d3ec6ff71.png" alt="Kafka Topics with Message Count" class="image--center mx-auto" width="3716" height="988" loading="lazy"></p>
<p><strong>Step 2</strong>: Let’s see where the heavy partitions are.</p>
<p>Click on <strong>freecodecamp-logs</strong> in Kafka UI and see the partition table. Look at the message count: partition 4 is bigger than the others. The table also gives information about replicas of partitions: partition 4 has replicas on Broker 1 and 3. Broker 2 doesn’t have partition 4 at all. This explains why Broker 2 was underutilized.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768089513001/8993d5ce-c70f-45e3-b643-b8a759dda138.png" alt="Kafka Topic Partitions" class="image--center mx-auto" width="3498" height="1428" loading="lazy"></p>
<p><strong>Step 3:</strong> To balance the cluster, we need to move partition 4 around.</p>
<p>We can move partition 4 to Broker 2. But before that, let’s do some math to be able to rationalize our decision. Note that the calculation doesn’t have to be precise – we just want a relative sense of data between brokers.</p>
<p>Current state:</p>
<ul>
<li><p><strong>Broker 1</strong>: 4.55 MB</p>
</li>
<li><p><strong>Broker 2</strong>: 2.29 MB (underutilized)</p>
</li>
<li><p><strong>Broker 3</strong>: 5.11 MB (over-utilized)</p>
</li>
</ul>
<p>Note that roughly the compressed data size for partition 4 is 2.25 MB (exact size is not critical).</p>
<p>If we move partition 4 from [1,3] to [2,3]:</p>
<ul>
<li><p><strong>Broker 1:</strong> Loses partition 4, so 4.55 + 2.25 = <strong>~2.3 MB</strong></p>
</li>
<li><p><strong>Broker 2:</strong> Gains Partition 4, so 2.33 + 2.25 = ~<strong>4.58 MB</strong></p>
</li>
<li><p><strong>Broker 3:</strong> Already has partition 4, so = <strong>5.11 MB (no change)</strong></p>
</li>
</ul>
<p>The result is that Broker 1 becomes underutilized.</p>
<p>How about if we move partition 4 from [1,3] to [1,2]?</p>
<ul>
<li><p><strong>Broker 1:</strong> Already has partition 4 = <strong>4.55 MB (no change)</strong></p>
</li>
<li><p><strong>Broker 2:</strong> Gains Partition 4, so 2.33 + 2.25 = ~<strong>4.58 MB</strong></p>
</li>
<li><p><strong>Broker 3:</strong> Loses partition 4, so 5.11 + 2.25 = ~<strong>2.8 MB</strong></p>
</li>
</ul>
<p>Hmm, this still creates an imbalance (broker 3 becomes too light).</p>
<p>So basically, manual rebalancing requires complex calculations. Moving a single partition impacts disk usage, leader distribution, and network traffic across multiple brokers. One poorly planned move can create a new imbalance elsewhere. </p>
<p>But, let’s say you somehow landed on a perfect mathematical calculation and you’re ready to make the move to balance. We’ll assume that the perfect plan is to move Partition 4 from [1, 3] to [2, 3]. I know it’s not the perfect move but the point is to see the pain afterwards.</p>
<p><strong>Step 4</strong>: it’s time to move the partition manually.</p>
<p>We need to tell Kafka to move partition 4's replicas from brokers [1,3] to brokers [2,3].</p>
<p>To do that, you need create a file called <code>reassignment.json</code> on your machine:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"version"</span>: <span class="hljs-number">1</span>,
  <span class="hljs-attr">"partitions"</span>: [
    {
      <span class="hljs-attr">"topic"</span>: <span class="hljs-string">"freecodecamp-logs"</span>,
      <span class="hljs-attr">"partition"</span>: <span class="hljs-number">4</span>,
      <span class="hljs-attr">"replicas"</span>: [<span class="hljs-number">2</span>, <span class="hljs-number">3</span>],
      <span class="hljs-attr">"log_dirs"</span>: [<span class="hljs-string">"any"</span>, <span class="hljs-string">"any"</span>]
    }
  ]
}
</code></pre>
<p><strong>What this means:</strong></p>
<ul>
<li><p>"partition": 4 – Target Partition</p>
</li>
<li><p>"replicas": [2, 3] – New placement: brokers 2 and 3</p>
</li>
<li><p>"log_dirs": ["any", "any"] – Let Kafka choose the disk directory</p>
</li>
</ul>
<p>Save this file somewhere accessible.</p>
<p>Then run the following command to copy the JSON to the Kafka cluster:</p>
<pre><code class="lang-bash">docker cp reassignment.json kafka-1:/tmp/reassignment.json
</code></pre>
<p>This copies your local file into the kafka-1 container's /tmp directory.</p>
<p>Run following command to verify the file is there:</p>
<pre><code class="lang-bash">docker <span class="hljs-built_in">exec</span> -it kafka-1 cat /tmp/reassignment.json
</code></pre>
<p>You should see your JSON file content.</p>
<p>Now run the actual reassignment command:</p>
<pre><code class="lang-bash">docker <span class="hljs-built_in">exec</span> -it kafka-1 /opt/kafka/bin/kafka-reassign-partitions.sh \
  --bootstrap-server kafka-1:29092,kafka-2:29092,kafka-3:29092 \
  --reassignment-json-file /tmp/reassignment.json \
  --execute
</code></pre>
<p>You will get a message from Kafka that will tell you if Kafka has accepted the reassignment and started moving the data.</p>
<p>You can monitor the reassignment using the following command:</p>
<pre><code class="lang-bash">docker <span class="hljs-built_in">exec</span> -it kafka-1 /opt/kafka/bin/kafka-reassign-partitions.sh \
  --bootstrap-server kafka-1:29092,kafka-2:29092,kafka-3:29092 \
  --reassignment-json-file /tmp/reassignment.json \
  --verify
</code></pre>
<p>I’m not going to run the manual reassignment because I want to keep the imbalance and show how Cruise Control can help reduce the manual steps. Next, let’s see how Cruise Control handles the same imbalance automatically.</p>
<h3 id="heading-rebalancing-using-cruise-control">Rebalancing Using Cruise Control</h3>
<p>After creating the topic and messages, I have let Cruise Control run for a couple minutes. During that time, it collected metrics and trained its linear regression model. You can run the following command to verify if Cruise Control is running fine and it has data (following is a REST API call using curl):</p>
<pre><code class="lang-bash">curl http://localhost:9090/kafkacruisecontrol/state
</code></pre>
<p>You will get multiple JSON object outputs as part of the response. Each JSON object holds some information about the state of Cruise Control and the Kafka cluster. Let’s see each of these one at a time:</p>
<pre><code class="lang-json">MonitorState: {
  state: RUNNING(<span class="hljs-number">20.000</span>% trained),
  NumValidWindows: (<span class="hljs-number">1</span>/<span class="hljs-number">1</span>) (<span class="hljs-number">100.000</span>%),
  NumValidPartitions: <span class="hljs-number">105</span>/<span class="hljs-number">105</span> (<span class="hljs-number">100.000</span>%),
  flawedPartitions: <span class="hljs-number">0</span>
}
</code></pre>
<p>This tells about the state of monitoring based on data collected by Cruise Control:</p>
<ul>
<li><p><code>state: RUNNING(20.000% trained)</code> – Cruise Control is <strong>actively collecting metrics</strong> from your Kafka cluster. Right now it has <strong>trained its model on 20% of the expected monitoring data</strong>.</p>
</li>
<li><p><code>NumValidWindows: (1/1) (100%)</code> – Cruise Control has collected 1 complete monitoring window out of 1 required (100% ready). Remember, we had set <code>num.broker.metrics.windows=1</code> in the <code>cruisecontrol.properties</code> configuration file.</p>
</li>
<li><p><code>NumValidPartitions: 105/105 (100%)</code> – Cruise Control analyzed all 105 partitions and has metrics for <strong>all.</strong></p>
</li>
<li><p><code>flawedPartitions: 0</code> – None of the partitions have problematic or missing metrics.</p>
</li>
</ul>
<pre><code class="lang-json">ExecutorState: {state: NO_TASK_IN_PROGRESS}
</code></pre>
<p>The above response indicates the execution engine is idle – no partition moves or leadership changes are currently in progress. This makes sense since we haven't asked Cruise Control to do anything yet.</p>
<pre><code class="lang-json">AnalyzerState: {
  isProposalReady: <span class="hljs-literal">true</span>,
  readyGoals: [
    NetworkInboundCapacityGoal,
    LeaderBytesInDistributionGoal,
    DiskCapacityGoal,
    ReplicaDistributionGoal,
    RackAwareGoal,
    NetworkOutboundCapacityGoal,
    CpuCapacityGoal,
    DiskUsageDistributionGoal,
    LeaderReplicaDistributionGoal,
    ReplicaCapacityGoal
  ]
}
</code></pre>
<p>AnalyzerState tells whether Cruise Control is ready to show a proposal or not. In this case it’s ready.</p>
<ul>
<li><p><code>isProposalReady: true</code> – Cruise Control has <strong>calculated a potential rebalancing plan</strong> (a proposal) that satisfies the configured goals.</p>
</li>
<li><p><code>readyGoals</code> – These are the goals that are considered <strong>ready and valid</strong> for rebalancing. Examples:</p>
<ul>
<li><p><code>DiskCapacityGoal</code>: balance disk usage among brokers</p>
</li>
<li><p><code>ReplicaDistributionGoal</code>: balance number of replicas per broker</p>
</li>
<li><p><code>RackAwareGoal</code>: maintain replicas across racks for fault tolerance</p>
</li>
<li><p><code>LeaderBytesInDistributionGoal</code>: balance network traffic from leaders</p>
</li>
<li><p><code>DiskUsageDistributionGoal</code>: ensures partitions are spread to prevent skew</p>
</li>
</ul>
</li>
</ul>
<p>Note that these are the goals we had set earlier in the <code>cruisecontrol.properties</code> file.</p>
<pre><code class="lang-json">AnomalyDetectorState: {
  selfHealingEnabled:[],
  selfHealingDisabled:[BROKER_FAILURE, DISK_FAILURE, GOAL_VIOLATION, METRIC_ANOMALY, TOPIC_ANOMALY, MAINTENANCE_EVENT],
  selfHealingEnabledRatio:{...},
  recentGoalViolations:[],
  recentBrokerFailures:[],
  recentMetricAnomalies:[],
  recentDiskFailures:[],
  recentTopicAnomalies:[],
  recentMaintenanceEvents:[],
  metrics:{...},
  ongoingSelfHealingAnomaly:None,
  balancednessScore:<span class="hljs-number">100.000</span>
}
</code></pre>
<p>Anomaly detection shows information about any existing anomaly and healing properties.</p>
<ul>
<li><p><code>selfHealingEnabled: []</code> – Automatic self-healing is <strong>currently off</strong>. Cruise Control will <strong>not move partitions automatically</strong> in response to anomalies.</p>
</li>
<li><p><code>selfHealingDisabled: [...]</code> – Lists the anomaly types that are <strong>disabled for automatic self-healing</strong>, including broker failures, disk failures, and goal violations.</p>
</li>
<li><p><code>recentGoalViolations: []</code> – No goals have been violated recently.</p>
</li>
<li><p><code>balancednessScore: 100.000</code> – This is <strong>how balanced the cluster is according to Cruise Control’s hard goals</strong>. 100% means the cluster is perfectly balanced according to the metrics and hard goals currently active. This metric only cares about Hard Goals (Disk Capacity, CPU capacity) being violated – that’s why it shows 100% even though we know there are some disk usage imbalances in our cluster.</p>
</li>
</ul>
<h4 id="heading-the-proposal">The Proposal</h4>
<p>Via AnalyzerState information, Cruise Control told us that it has a proposal for the cluster. Let’s see what it is. We can fetch the proposal using the proposal end point:</p>
<pre><code class="lang-bash">curl -s <span class="hljs-string">"http://localhost:9090/kafkacruisecontrol/proposals?json=true"</span>
</code></pre>
<p>The JSON response is quite large. Let's focus on the key parts that show our cluster's imbalance and how Cruise Control plans to fix it:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"summary"</span>: {
    <span class="hljs-attr">"numReplicaMovements"</span>: <span class="hljs-number">13</span>,    <span class="hljs-comment">// CC wants to move 13 partition replicas</span>
    <span class="hljs-attr">"numLeaderMovements"</span>: <span class="hljs-number">6</span>,      <span class="hljs-comment">// And reassign 6 partition leaders</span>
    <span class="hljs-attr">"onDemandBalancednessScoreBefore"</span>: <span class="hljs-number">84.67</span>,   <span class="hljs-comment">// Current: 84.67% balanced</span>
    <span class="hljs-attr">"onDemandBalancednessScoreAfter"</span>: <span class="hljs-number">89.76</span>.    <span class="hljs-comment">// After: 89.76% balanced</span>
  },
  <span class="hljs-attr">"goalSummary"</span>: [
    {
      <span class="hljs-attr">"goal"</span>: <span class="hljs-string">"DiskUsageDistributionGoal"</span>,
      <span class="hljs-attr">"status"</span>: <span class="hljs-string">"VIOLATED"</span>
    },
    {
      <span class="hljs-attr">"goal"</span>: <span class="hljs-string">"LeaderBytesInDistributionGoal"</span>,
      <span class="hljs-attr">"status"</span>: <span class="hljs-string">"VIOLATED"</span>
    }
  ]
}
</code></pre>
<p>Based on the calculations, Cruise Control thinks:</p>
<ol>
<li><p>Moving 13 partition replicas will help. Note that manually we decided to move just 1 partition, that is partition 4.</p>
</li>
<li><p>Reassigning 6 partition leaders will help. Manually we didn’t account for any leadership reassignment.</p>
</li>
<li><p><code>DiskUsageDistributionGoal</code> has been violated. We know that the disk usage is not distributed perfectly.</p>
</li>
<li><p><code>LeaderBytesInDistributionGoal</code> has also been violated. We couldn’t find this out manually. Technically, you could find out but it would take a decent amount of manual calculations and would still be error-prone.</p>
</li>
</ol>
<p>Note: While we're focusing on disk usage imbalance, Cruise Control optimizes for 10 different goals (disk, CPU, network, leaders, and so on). This holistic approach gives it a better chance of achieving true cluster balance versus balancing manually.</p>
<h4 id="heading-executing-the-proposal">Executing the proposal</h4>
<p>Let’s run the actual rebalancing using Cruise Control. The command is:</p>
<pre><code class="lang-bash">curl -X POST <span class="hljs-string">'http://localhost:9090/kafkacruisecontrol/rebalance?dryrun=false&amp;json=true'</span>
</code></pre>
<p>Again, you’ll get a huge JSON file similar to the proposal.</p>
<p>You can track the status using following API call:</p>
<pre><code class="lang-bash">curl <span class="hljs-string">"http://localhost:9090/kafkacruisecontrol/user_tasks"</span>
</code></pre>
<p>You will get something like this:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768095347466/64131c47-5884-4894-95df-d46e9eb8cd97.png" alt="Cruise Control Tasks" class="image--center mx-auto" width="2152" height="344" loading="lazy"></p>
<p>Note that the 4th item in the list is our rebalance API call and it’s complete. This was quick for our small Dev cluster, but in large clusters you may see status as <code>InExecution</code>.</p>
<p>Let’s look at the UI to see what is the state of Imbalance now that Cruise Control has completed its execution of the proposal. The UI shows the following for me:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768095510743/16db64f7-d14b-4120-95c9-ac9b1d43f47e.png" alt="Kafka balanced Disk Usage" class="image--center mx-auto" width="3674" height="834" loading="lazy"></p>
<h4 id="heading-comparison">Comparison</h4>
<p>Before rebalancing:</p>
<ul>
<li><p>Broker 1: 4.52 MB, 69 partitions, 35 leaders</p>
</li>
<li><p>Broker 2: 2.22 MB, 69 partitions, 35 leaders (<strong>underutilized</strong>)</p>
</li>
<li><p>Broker 3: 5.05 MB, 72 partitions, 35 leaders (<strong>overutilized</strong>)</p>
</li>
<li><p><strong>Disk range:</strong> 2.83 MB (5.05 - 2.22)</p>
</li>
</ul>
<p>After rebalancing:</p>
<ul>
<li><p>Broker 1: 4.66 MB, 69 partitions, 38 leaders</p>
</li>
<li><p>Broker 2: 3.87 MB, 77 partitions, 31 leaders</p>
</li>
<li><p>Broker 3: 4.87 MB, 64 partitions, 36 leaders</p>
</li>
<li><p><strong>Disk range:</strong> 1.00 MB (4.87 - 3.87)</p>
</li>
</ul>
<p>Results:</p>
<ul>
<li><p><strong>Disk usage balanced</strong> – Range reduced from 2.83 MB to 1.00 MB (64% improvement!)</p>
</li>
<li><p><strong>Replicas redistributed</strong> – Broker 2 gained 8 replicas, Broker 3 lost 8 replicas</p>
</li>
<li><p><strong>Leaders balanced</strong> – Changed from 35-35-35 to 38-31-36. Cruise Control prioritized balancing actual network traffic over leader count.</p>
</li>
</ul>
<p>The cluster is now more balanced across all metrics. Congrats!</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>We covered a lot in this tutorial, so let’s take a step back and look at what we did.</p>
<p>You started by experiencing the reality of manual Kafka management – the endless CLI commands, the tedious calculations, the JSON files, and the potential for costly mistakes. If you felt frustrated during that section, that’s to be expected. That frustration is exactly what thousands of engineering teams deal with every day.</p>
<p>Then you were presented with two complementary tools:</p>
<ol>
<li><p><strong>Kafka UI</strong> gave you visibility. No more grepping through command outputs or manually counting partition leaders. Everything you need, broker health, topic configurations, consumer lag is right there in a clean web interface. For small teams and development environments, this alone is a game-changer.</p>
</li>
<li><p><strong>Cruise Control</strong> gave you intelligence. It didn't just automate what you'd do manually – it also did a fundamentally better job. While you were focused on moving one partition (partition 4), Cruise Control analyzed all 105 partitions across 10 different optimization goals and proposed a comprehensive rebalancing plan. That's the difference between human effort and automated intelligence.</p>
</li>
</ol>
<p>I want to call out that this tutorial used a simplified setup. For production, you’ll expect complex configurations like”</p>
<ul>
<li><p>Kafka and Cruise Control running on separate machines</p>
</li>
<li><p>Larger monitoring window for Cruise Control</p>
</li>
<li><p>Some self healing capabilities enabled</p>
</li>
</ul>
<p>If there's one thing you take away from this article, let it be this: you should stop managing your Kafka cluster manually. You've seen there's a better way. Use it. Thanks for reading!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ A Beginner’s Guide to Automation with n8n ]]>
                </title>
                <description>
                    <![CDATA[ Automation has become one of the most valuable skills for any technical team. It helps eliminate repetitive work, speeds up business operations, and lets you focus on creative or strategic tasks.  Whether it’s moving data between apps, triggering act... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/a-beginners-guide-to-automation-with-n8n/</link>
                <guid isPermaLink="false">6908c9fe147981c65916a40b</guid>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Mon, 03 Nov 2025 15:27:58 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1762183395684/27b7a207-3768-46a6-8c44-de08ccccd40d.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Automation has become one of the most valuable skills for any technical team. It helps eliminate repetitive work, speeds up business operations, and lets you focus on creative or strategic tasks. </p>
<p>Whether it’s moving data between apps, triggering actions when something changes, or building smart systems that run on their own, automation can save hours every week.</p>
<p>The problem is that most automation platforms make you choose between flexibility and simplicity. </p>
<p>Tools like Zapier are easy to use but limited when you need customisation. Writing your own scripts in Python or JavaScript gives you full control but takes more time to build and maintain. </p>
<p><a target="_blank" href="https://n8n.io/">n8n</a> changes that balance. It is an open-source workflow automation platform that provides both control and simplicity.</p>
<p>n8n lets you automate anything from simple tasks to complex systems using a visual interface. You can drag and connect nodes to create workflows or write code when needed. It’s built for technical teams who want freedom without losing ease of use.</p>
<p>In this article, we’ll learn how to build and deploy your own automation workflows using n8n. By the end, you’ll have a working automation server and the knowledge to create smart, self-running workflows for any use case.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-what-n8n-does">What n8n Does</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-n8n-is-open-source">n8n Is Open Source</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-get-started-with-n8n">How to Get Started with n8n</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-building-a-n8n-workflow">Building a</a> <a class="post-section-overview" href="#heading-how-to-get-started-with-n8n">n8n</a> <a class="post-section-overview" href="#heading-building-a-n8n-workflow">Workflow</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-running-n8n-in-production-using-sevalla">Running</a> <a class="post-section-overview" href="#heading-how-to-get-started-with-n8n">n8n</a> <a class="post-section-overview" href="#heading-running-n8n-in-production-using-sevalla">in Production using Sevalla</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-where-n8n-becomes-powerful">Where n8n Becomes Powerful</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-ai-driven-automations">AI-Driven Automations</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-n8n-does">What n8n Does</h2>
<p>n8n connects the apps and systems you already use. </p>
<p>Each connection is called a node, and every node performs an action. You can combine multiple nodes into a workflow that runs automatically. </p>
<p>For example, you could create a workflow where a new form submission in Typeform triggers a Slack message and stores the data in Google Sheets. You can then add logic to send an email only if certain conditions are met.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1761909480196/dc79c6ec-36d1-4145-bc7a-eed7b60433f6.png" alt="n8n Workflow" class="image--center mx-auto" width="1000" height="695" loading="lazy"></p>
<p>This approach allows anyone to build automation visually, yet it stays developer-friendly. You can use JavaScript or Python inside the workflow for custom logic, import npm packages, or connect to any API that doesn’t have a prebuilt node yet.</p>
<p>The platform supports over four hundred integrations out of the box, from GitHub and AWS to OpenAI and Telegram. This large library of ready-to-use nodes means you can connect most tools you use every day without needing to write any code at all.</p>
<h2 id="heading-n8n-is-open-source">n8n is Open Source</h2>
<p>The open source nature of n8n is what makes it stand out. </p>
<p>Most automation tools like <a target="_blank" href="https://zapier.com/">Zapier</a> are closed systems that hide their inner workings. With n8n, the <a target="_blank" href="https://github.com/n8n-io/n8n">source code</a> is publicly available. You can host it on your own server, modify it, and inspect how everything works.</p>
<p>This matters for both privacy and flexibility. </p>
<p>When you self-host n8n, your data never leaves your environment. This is especially useful for industries like finance, healthcare, and security where sensitive data must stay private. Teams can build automations without sending information through third-party servers.</p>
<p>Being open source also means you are never locked into one vendor. You can add your own nodes, extend the platform, or even contribute back to the community. </p>
<p>The fair-code license ensures that while the project stays sustainable for the developers who maintain it, it remains accessible to anyone who wants to use or modify it.</p>
<h2 id="heading-how-to-get-started-with-n8n">How to Get Started with n8n</h2>
<p>Getting started with n8n takes only a few minutes. If you already have Node.js installed, you can launch it right from your terminal using the command:</p>
<pre><code class="lang-python">npx n8n
</code></pre>
<p>This will start n8n locally and open the visual editor at <a target="_blank" href="http://localhost:5678/">http://localhost:5678</a>.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1761909546583/c11eec5e-21d5-488a-8724-ace5bc472e3f.png" alt="n8n Local Setup" class="image--center mx-auto" width="1000" height="509" loading="lazy"></p>
<p>You can also <a target="_blank" href="https://docs.n8n.io/hosting/installation/docker/">deploy n8n with Docker</a> using a few simple commands. Docker is often the easiest option if you want a persistent setup where your data and workflows are saved automatically.</p>
<p>Once the editor is open, you’ll see an empty canvas where you can drag and drop nodes. For beginners, the best way to learn is by building small workflows. </p>
<h2 id="heading-building-a-n8n-workflow">Building a n8n Workflow</h2>
<p>Let’s build a simple n8n workflow.</p>
<p><strong>Step 1:</strong> After logging in, click on “Create Workflow” at the top. This will open a blank workspace. Give your workflow a name such as “RSS to Email”. You’ll be building a simple chain of steps, where one action leads to another.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1761910279996/256d80c3-1bda-47b8-9434-bf94e24c6c58.png" alt="Empty Workflow" class="image--center mx-auto" width="1906" height="896" loading="lazy"></p>
<p><strong>Step 2:</strong> Every workflow in n8n starts with a trigger, which decides when the workflow should run. In this example, we’ll use the Schedule Trigger so the workflow runs once a day.</p>
<p>Click the plus icon to add a new node and search for “On a Schedule”. Select it and choose the option that says “Every Day”. You can set the exact time you want it to run, for example, every morning at 9am. This means that once your workflow is activated, n8n will automatically start it daily at that time.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1761910455288/eb9a763e-6754-49a9-a063-cbf687fee48d.png" alt="Daily Trigger" class="image--center mx-auto" width="1895" height="878" loading="lazy"></p>
<p><strong>Step 3:</strong> Now that the workflow knows when to run, it needs to know what to do. The next step is to fetch the latest articles from a blog’s RSS feed. Click the plus icon again to add another node and search for “RSS Read”.</p>
<p>In the URL field, type the link to a blog’s feed such as <a target="_blank" href="https://blog.cloudflare.com/rss/"><code>https://blog.cloudflare.com/rss/</code></a>. Click “Execute Node” to test it. You should now see a list of recent blog posts with their titles, descriptions, and links. This confirms that the feed is working correctly.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1761910548855/88062fd3-b6e5-4847-9513-d921e8ace2c6.png" alt="RSS Feed reading" class="image--center mx-auto" width="1908" height="885" loading="lazy"></p>
<p><strong>Step 4:</strong> Sometimes you may not want all the items from the RSS feed. For instance, you might only want the top three posts. To do this, you can add a Function node between the RSS and email steps. In that node, enter a short JavaScript snippet like <code>return items.slice(0, 3);</code>. This will trim the list and only keep the first three results. You can also choose to skip this step if you want to send all the posts in the email.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1761910658111/e559daf8-8cd2-4c3c-890e-12db78019308.png" alt="Javascript Node" class="image--center mx-auto" width="1908" height="887" loading="lazy"></p>
<p><strong>Step 5:</strong> The next step is to send the RSS feed items to your email inbox. Add another node and search for “Email”. You can use your preferred email service such as Gmail or Outlook, or configure it manually using SMTP settings.</p>
<p>For Gmail, choose “Send an email”. For the settings, <a target="_blank" href="https://docs.n8n.io/integrations/builtin/credentials/google/oauth-single-service/#set-up-oauth">get your oauth keys</a> from Google. In the subject field, write something like “Daily Blog Updates”. In the message field, you can include the data from the RSS feed using expressions such as <code>{{ $json["title"] }} - {{ $json["link"] }}</code>.</p>
<p>This will automatically replace those variables with the actual titles and links when the workflow runs. You can test the email by clicking “Execute Node” and checking your inbox.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1761910870431/cad1b6f6-02e1-4d39-a8ec-310dc9710744.png" alt="Gmail Node" class="image--center mx-auto" width="1899" height="869" loading="lazy"></p>
<p><strong>Step 6:</strong> Once you have added all three nodes, Schedule Trigger, RSS Feed Read, and Email, you need to connect them in that order. The arrows show the flow of data.</p>
<p>Click “Execute Workflow” to test everything. If the setup is correct, you should receive an email with the latest blog posts. When you’re satisfied with the result, turn on the workflow by clicking the toggle switch in the top right corner. It will now run automatically every day without you having to open n8n again.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1761913653256/2366e2f4-725a-4207-9a16-43816ad41ec4.png" alt="Complete Workflow" class="image--center mx-auto" width="1259" height="453" loading="lazy"></p>
<p>As you get comfortable, you can start chaining multiple services together, add conditional logic, or include custom code nodes for specific cases. The live execution view helps you see how data moves between nodes in real time.</p>
<h2 id="heading-running-n8n-in-production-using-sevalla">Running n8n in Production using Sevalla</h2>
<p>When you are ready to move beyond testing, n8n gives you two main options. You can self-host it using your own infrastructure or use their managed cloud version at <a target="_blank" href="https://n8n.io/">n8n.io</a>.</p>
<p>Self-hosting gives you full control and is usually preferred by technical teams who want to integrate with private APIs or keep sensitive data in-house. </p>
<p>You can choose any cloud provider, like AWS, DigitalOcean, or others to set up N8N. But I will be using Sevalla.</p>
<p><a target="_blank" href="https://sevalla.com/">Sevalla</a> is a PaaS provider designed for developers and dev teams shipping features and updates constantly in the most efficient way. It offers application hosting, database, object storage, and static site hosting for your projects.</p>
<p>I am using Sevalla for two reasons:</p>
<ul>
<li><p>Every platform will charge you for creating a cloud resource. Sevalla comes with a $50 credit for us to use, so we won’t incur any costs for this example.</p>
</li>
<li><p>Sevalla has a <a target="_blank" href="https://docs.sevalla.com/templates/overview">template for n8n</a>, so it simplifies the manual installation and setup for each resource you will need for installation.</p>
</li>
</ul>
<p><a target="_blank" href="https://app.sevalla.com/login">Log in</a> to Sevalla and click on Templates. You can see n8n as one of the templates.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1761910008898/9da4e4d3-bc09-4790-a65b-bfab3a288b89.png" alt="Sevalla Templates" class="image--center mx-auto" width="1000" height="247" loading="lazy"></p>
<p>Click on the “N8N” template. You will see the resources needed to provision the application. Click on “Deploy Template”.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1761910087070/69dc3ea7-3762-42a7-bf34-badbebef7ded.png" alt="N8N template resources" class="image--center mx-auto" width="1000" height="374" loading="lazy"></p>
<p>You can see the resource being provisioned. Once the resources are provisioned, go to your n8n application and click on the current deployment. </p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1761910111036/b9c7b447-a320-4ee4-9fe7-4cfe03d62b5f.png" alt="N8N Deployment" class="image--center mx-auto" width="1000" height="425" loading="lazy"></p>
<p>Wait for a few minutes. Once the deployment is complete, you will see a green checkmark. </p>
<p>Click on “Visit app”. You will get a cloud url eg. <a target="_blank" href="https://n8n-9u6kc.sevalla.app/">https://n8n-9u6kc.sevalla.app/</a>. </p>
<p>You now have a production-grade n8n server running on the cloud. You can use this to build your automations in your self hosted cloud environment. </p>
<h2 id="heading-where-n8n-becomes-powerful">Where n8n Becomes Powerful</h2>
<p>Most users begin with simple automations. But n8n’s true power shows up when you start building complex, multi-step workflows. You can create sequences that involve APIs, data transformation, and logic-based decision making.</p>
<p>For example, a marketing team could build a system that monitors mentions on Twitter, classifies them with an AI model, adds potential leads to a CRM, and sends a Slack alert for high-priority mentions. </p>
<p>A developer could build a workflow that triggers deployment pipelines automatically when code is merged into a branch.</p>
<p>Because n8n supports both no-code and full-code modes, you never outgrow it. As your automations become more advanced, you can still use the same platform to handle them.</p>
<h2 id="heading-ai-driven-automations">AI-Driven Automations</h2>
<p>n8n is also built for the era of AI. It comes with native support for connecting large language models and tools like <a target="_blank" href="https://www.langchain.com/">LangChain</a>. This means you can build AI workflows that use your own data and logic.</p>
<p>Imagine setting up a workflow that reads new support tickets, summarizes them with an AI model, and routes them to the right team. Or one that takes blog posts, generates summaries, and posts them automatically to your social channels. </p>
<p>You can design these workflows visually while letting the AI handle the heavy lifting.</p>
<p>Because n8n allows you to control how and where AI models are called, it gives teams flexibility without sacrificing data security. You can integrate your own OpenAI key, run local models, or use third-party APIs in the same environment.</p>
<p>The real value of n8n lies in how it combines flexibility, transparency, and control. It doesn’t hide complexity from you but gives you tools to manage it better. You can start small with visual automation and grow into advanced logic and AI-driven workflows.</p>
<p>Because it’s open source, you never risk losing access to your automations. You can run it anywhere, connect it with anything, and inspect everything that happens under the hood. This level of freedom is rare among modern automation platforms.</p>
<p>For beginners, n8n is an opportunity to understand how automation works without needing to learn full-stack programming. For developers, it’s a scalable system that can power serious production workflows.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Automation is becoming an essential part of every technical process. The challenge is finding a tool that balances simplicity with power. n8n achieves that balance by being open source, extensible, and flexible enough for both no-code users and developers.</p>
<p>n8n is not just another automation app. It is a complete, open, and developer-friendly platform built to make automation accessible to everyone.</p>
<p><em>Hope you enjoyed this article. Find me on</em> <a target="_blank" href="https://linkedin.com/in/manishmshiva"><em>Linkedin</em></a> <em>or</em> <a target="_blank" href="https://manishshivanandhan.com/"><strong><em>visit my website</em></strong></a><em>.</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Test JavaScript Apps: From Unit Tests to AI-Augmented QA ]]>
                </title>
                <description>
                    <![CDATA[ As a software engineer, you should always be open to the challenges this field brings. Two months ago, my project manager assigned me a task: write test cases for an API. I was super excited because it meant I got to learn something new beyond just c... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-test-javascript-apps-from-unit-tests-to-ai-augmented-qa/</link>
                <guid isPermaLink="false">68e68c3655c4d79b6db4f4c4</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Testing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ajay Yadav ]]>
                </dc:creator>
                <pubDate>Wed, 08 Oct 2025 16:07:18 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1759939599135/507c5e9a-954b-497b-b3b8-c8d89b2d1a03.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>As a software engineer, you should always be open to the challenges this field brings. Two months ago, my project manager assigned me a task: write test cases for an API. I was super excited because it meant I got to learn something new beyond just coding features.</p>
<p>Now, if you’re thinking “writing test cases isn’t my job as a frontend or backend developer”, then you’re missing the point. That mindset holds you back.</p>
<p>At the very least, every engineer should understand Unit Testing and Integration Testing. Writing test cases isn’t rocket science, it’s as simple as English and feels very similar to writing JavaScript code.</p>
<p>That said, if you’ve ever tried setting up testing in a JavaScript application, you probably know how complicated and frustrating it can get.</p>
<p>The JavaScript ecosystem is massive, with endless libraries and frameworks. Things shift constantly, new tools replace old ones, and community standards evolve almost overnight. That’s exactly why I decided to write this article.</p>
<p>In it, we’ll explore a modern approach to JavaScript testing, covering practical patterns, workflows, and even how AI-assisted tools are changing the game.</p>
<p>Let’s dive in.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-the-evolution-of-testing">The Evolution of Testing</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-the-core-layers-of-testing">The Core Layers of Testing</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-unit-testing">Unit testing</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-integration-testing">Integration testing</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-end-to-end-testing">End-to-End testing</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-ai-augmented-testing">AI-Augmented testing</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-future-of-javascript-testing">Future of JavaScript Testing</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-before-we-end">Before We End</a></p>
</li>
</ul>
<h2 id="heading-the-evolution-of-testing">The Evolution of Testing</h2>
<p>Software testing has been around for as long as software itself. According to IBM (2016), testing started right alongside the very first programs. After World War II, three computer scientists wrote what’s considered to be the <a target="_blank" href="https://en.wikipedia.org/wiki/Manchester_Baby">first piece of software</a>.</p>
<p>It ran on June 21, 1948, at the University of Manchester in England, performing mathematical calculations with basic machine code instructions.</p>
<p>Since then, testing methods and principles have continuously evolved. As software became more complex and development cycles got faster, the need for reliable and systematic testing grew stronger.</p>
<p>In the early days, the concept of the <strong>Testing Pyramid</strong> became popular. At the base, you had unit tests, in the middle integration tests, and at the very top a thin layer of end-to-end (E2E) tests. This approach worked well for simpler applications.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1759395722389/0067bc6e-f038-40a6-905c-61406f41e430.png" alt="Image of the testing pyramid showing the different layers" class="image--center mx-auto" width="994" height="618" loading="lazy"></p>
<p>But as apps grew more dynamic and interconnected, the pyramid approach began to show its limits. That’s where the <strong>Testing Trophy model</strong> came in. Instead of overloading with unit tests, it puts greater emphasis on integration testing while still keeping E2E tests and unit tests in balance.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1759395841713/b92ea402-5002-4c48-be7c-aee6f1dfacfd.png" alt="Diagram of a &quot;Testing Trophy&quot; pyramid. Top to bottom: &quot;End-to-End Tests&quot; (Slow, Few, Expensive), &quot;Integration Tests&quot; (Moderate Speed, Fewer, Moderate Cost), &quot;Unit Tests&quot; (Fast, Numerous, Cheap), &quot;Static Analysis&quot; (Instant, Numerous, Cheapest). Left axis: Confidence increases up, Speed decreases down. Right axis: Cost increases up, Frequency decreases down." class="image--center mx-auto" width="993" height="676" loading="lazy"></p>
<p>Now, with the rise of AI in QA, testing has entered a new phase. AI-driven tools don’t just run tests, they help generate, maintain, and even self-heal them. This shift is creating a future-ready testing framework designed to handle the complexity of modern software in 2025 and beyond.</p>
<h2 id="heading-the-core-layers-of-testing">The Core Layers of Testing</h2>
<p>Testing is not just about finding bugs, but also ensuring reliability, scalability, and user satisfaction. Every testing strategy should cover four main layers:</p>
<h3 id="heading-unit-testing">Unit Testing</h3>
<p>Unit testing is a method where you test individual components or units of software in isolation to make sure they work as expected. A unit can be a simple function, a React component, or even a utility module.</p>
<p>When building JavaScript apps, we usually create separate modules or components that later get combined. If any one of those small pieces is broken, the entire application can fail. That’s why unit tests are essential, they catch problems early and ensure reliability before integration.</p>
<p>In the JavaScript ecosystem, there are several tools you can use for writing unit tests:</p>
<ul>
<li><p><a target="_blank" href="https://vitest.dev/"><strong>Vitest</strong></a> – a modern, fast, and developer-friendly testing framework built to work seamlessly with Vite projects.</p>
</li>
<li><p><a target="_blank" href="https://jestjs.io/"><strong>Jest</strong></a> – one of the most widely used testing frameworks, great for React apps among others.</p>
</li>
</ul>
<p>For this section, we’ll focus on <strong>Vitest</strong>, because it’s lightweight, super-fast, and feels very natural for modern frontend development. Let’s write a test case for a small module.</p>
<p>Imagine we have a simple utility function that adds two numbers:</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// sum.ts</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> sum = <span class="hljs-function"><span class="hljs-keyword">function</span> (<span class="hljs-params">a: <span class="hljs-built_in">number</span>, b: <span class="hljs-built_in">number</span></span>) </span>{
  <span class="hljs-keyword">return</span> a + b;
};
</code></pre>
<p>Every test typically has 3 parts:</p>
<ol>
<li><p>A description (string).</p>
</li>
<li><p>The code execution.</p>
</li>
<li><p>The assertion.</p>
</li>
</ol>
<p>Now, let’s write a unit test for the above function using Vitest.</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// sum.test.ts</span>
<span class="hljs-keyword">import</span> { describe, expect, it } <span class="hljs-keyword">from</span> <span class="hljs-string">"vitest"</span>;
<span class="hljs-keyword">import</span> { sum } <span class="hljs-keyword">from</span> <span class="hljs-string">"./sum"</span>;

describe(<span class="hljs-string">"sum function"</span>, <span class="hljs-function">() =&gt;</span> {
  it(<span class="hljs-string">"should return the sum of two numbers"</span>, <span class="hljs-function">() =&gt;</span> { <span class="hljs-comment">// 1. description</span>
    <span class="hljs-keyword">const</span> result = sum(<span class="hljs-number">2</span>, <span class="hljs-number">3</span>); <span class="hljs-comment">// 2. code execution</span>
    expect(result).toBe(<span class="hljs-number">5</span>);   <span class="hljs-comment">// 3. assertion</span>
  });

  <span class="hljs-comment">// ... other test cases</span>
});

<span class="hljs-comment">// ... other describe blocks</span>
</code></pre>
<p>Breaking it down:</p>
<ul>
<li><p><code>describe</code> groups related test cases together. Here, we group everything about the <code>sum</code> function.</p>
</li>
<li><p><code>it</code> (or <code>test</code>) defines a single test case. In this example: “should return the sum of two numbers.”</p>
</li>
<li><p><code>expect</code> makes the actual assertion. It checks if the result from <code>sum(2,3)</code> equals <code>5</code>.</p>
</li>
</ul>
<p>When you run this test, Vitest will quickly execute it and show you whether the function passed or failed.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1759399251713/3c051bbb-4813-40ed-8656-d1bd2730dc38.png" alt="Command line interface showing test results using &quot;vitest&quot; in a development environment. Two test files, &quot;sum.test.ts&quot; and &quot;App.test.tsx&quot;, have passed successfully. Total test duration was 828ms." class="image--center mx-auto" width="1020" height="307" loading="lazy"></p>
<p>If the function works, you’ll see <code>1 passed</code> in green. If it fails, the output will be red with details about what went wrong.</p>
<h3 id="heading-integration-testing">Integration Testing</h3>
<p>Now that we’ve covered unit testing, let’s move one step up to integration testing. While unit tests focus on testing individual pieces in isolation, integration tests ensure those pieces work together as expected.</p>
<p>Think of it like assembling Lego blocks: each piece might work fine on its own, but when you connect them, something might not fit right. Integration testing helps you catch those issues early.</p>
<p>In simple terms, Integration testing checks how components and modules interact with each other.</p>
<p>Let’s say we have a React component that fetches user data from an API and displays it on the screen.<br>We’re no longer just testing one function – we’re testing how the component behaves when it calls an API, manages loading states, and renders data dynamically.</p>
<p>Here’s a simple example:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { useEffect, useState } <span class="hljs-keyword">from</span> <span class="hljs-string">"react"</span>;

<span class="hljs-keyword">const</span> User = <span class="hljs-function">() =&gt;</span> {
  <span class="hljs-keyword">const</span> [users, setUsers] = useState&lt;{ name: <span class="hljs-built_in">string</span>; email: <span class="hljs-built_in">string</span> }[]&gt;([]);
  <span class="hljs-keyword">const</span> [loading, setLoading] = useState(<span class="hljs-literal">false</span>);

  <span class="hljs-keyword">const</span> fetchUsers = <span class="hljs-keyword">async</span> () =&gt; {
    setLoading(<span class="hljs-literal">true</span>);
    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">"https://api.escuelajs.co/api/v1/users"</span>);
      <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> res.json();
      setUsers(data);
    } <span class="hljs-keyword">catch</span> (e) {
      <span class="hljs-built_in">console</span>.log(e);
    } <span class="hljs-keyword">finally</span> {
      setLoading(<span class="hljs-literal">false</span>);
    }
  };

  useEffect(<span class="hljs-function">() =&gt;</span> {
    fetchUsers();
  }, []);

  <span class="hljs-keyword">return</span> (
    &lt;&gt;
      {loading ? (
        &lt;h2&gt;Loading...&lt;/h2&gt;
      ) : (
        &lt;div&gt;
          {users.map(<span class="hljs-function">(<span class="hljs-params">user, index</span>) =&gt;</span> (
            &lt;p key={index}&gt;
              {user.name}: {user.email}
            &lt;/p&gt;
          ))}
        &lt;/div&gt;
      )}
    &lt;/&gt;
  );
};

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> User;
</code></pre>
<p>This component does a few things:</p>
<ul>
<li><p>Calls an external API when the component mounts.</p>
</li>
<li><p>Sets a loading state while fetching data.</p>
</li>
<li><p>Renders the fetched users on the screen once the data is ready.</p>
</li>
</ul>
<p>Now, our job is to test the complete flow, from the API call to the rendered UI, using Vitest and <a target="_blank" href="https://testing-library.com/">React Testing Library</a>.</p>
<p>Here’s what the test file looks like:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { render, screen, waitFor } <span class="hljs-keyword">from</span> <span class="hljs-string">"@testing-library/react"</span>;
<span class="hljs-keyword">import</span> User <span class="hljs-keyword">from</span> <span class="hljs-string">"../components/User"</span>;
<span class="hljs-keyword">import</span> { describe, test, expect } <span class="hljs-keyword">from</span> <span class="hljs-string">"vitest"</span>;

describe(<span class="hljs-string">"User Component"</span>, <span class="hljs-function">() =&gt;</span> {
  test(<span class="hljs-string">"fetches and displays users successfully"</span>, <span class="hljs-keyword">async</span> () =&gt; {
    render(&lt;User /&gt;);

    <span class="hljs-comment">// 1. Initially shows loading</span>
    expect(screen.getByText(<span class="hljs-string">"Loading..."</span>)).toBeInTheDocument();

    <span class="hljs-comment">// 2. Wait for API response and UI update</span>
    <span class="hljs-keyword">await</span> waitFor(<span class="hljs-function">() =&gt;</span> {
      expect(
        screen.getByText(<span class="hljs-string">"Ajay Yadav: ajay.yadav@example.com"</span>)
      ).toBeInTheDocument();
      expect(
        screen.getByText(<span class="hljs-string">"Jane Smith: jane.smith@example.com"</span>)
      ).toBeInTheDocument();
    });

    <span class="hljs-comment">// 3. Loading should disappear</span>
    expect(screen.queryByText(<span class="hljs-string">"Loading..."</span>)).not.toBeInTheDocument();
  });
});
</code></pre>
<p>This test looks simple, but it covers the entire flow of our component. Let’s understand it step-by-step:</p>
<ul>
<li><p><strong>Render the component:</strong> Render the <code>&lt;User /&gt;</code> component inside the test environment.</p>
</li>
<li><p><strong>Check the loading state:</strong> As soon as the component mounts, the <strong>“Loading…”</strong> text should appear, indicating that data is being fetched.</p>
</li>
<li><p><strong>Wait for the data to load:</strong> Since the API call is asynchronous, use <code>waitFor()</code> to wait until the users are fetched and displayed.</p>
</li>
<li><p><strong>Verify the data:</strong> Once the API resolves, check if the user names and emails are correctly rendered on the screen.</p>
</li>
<li><p><strong>Confirm loading disappears:</strong> Finally, ensure that the “Loading…” text is removed once the data is displayed, confirming a proper state update.</p>
</li>
</ul>
<p>You can also test how your component behaves when the API fails. For example, you can mock the <code>fetch()</code> call to reject and then verify if an error message appears on the screen.</p>
<p>Vitest and React Testing Library make it easy to mock responses and simulate both success and failure cases, helping you ensure that your app handles real-world scenarios gracefully.</p>
<h3 id="heading-end-to-end-testing">End-to-End Testing</h3>
<p>Now that we’ve seen how integration testing ensures that different components work together, let’s move to the third layer, End-to-End (E2E) testing.</p>
<p>While unit and integration tests run in isolated or simulated environments, E2E tests mimic how real users interact with your app.</p>
<p>They open a browser and perform actions like clicking buttons, typing in fields, and verifying what appears on the screen, exactly like a real person would.</p>
<p>Think of E2E testing as putting your entire app on stage and watching if it performs flawlessly in front of the audience. In simple words, E2E testing verifies the full user journey from start to finish.</p>
<p>Let’s take a common example, a login flow. As a developer, you’ve probably built dozens of login forms, but how do you know if they truly work under real conditions? That’s where E2E testing comes in.</p>
<p>Using tools like <a target="_blank" href="https://playwright.dev/">Playwright</a> or <a target="_blank" href="https://www.cypress.io/">Cypress</a>, you can perform effective E2E testing. Both Playwright and Cypress are powerful tools and are popular among developers.</p>
<p>We can simulate a real browser, fill out the login form, submit it, and confirm that the user is redirected to the dashboard. Here’s what a simple E2E test looks like using Playwright:</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// tests/login.e2e.ts</span>
<span class="hljs-keyword">import</span> { test, expect } <span class="hljs-keyword">from</span> <span class="hljs-string">"@playwright/test"</span>;

test(<span class="hljs-string">"should login successfully"</span>, <span class="hljs-keyword">async</span> ({ page }) =&gt; {
  <span class="hljs-comment">// 1. Visit the login page</span>
  <span class="hljs-keyword">await</span> page.goto(<span class="hljs-string">"http://localhost:3000/login"</span>);

  <span class="hljs-comment">// 2. Fill in the form</span>
  <span class="hljs-keyword">await</span> page.fill(<span class="hljs-string">'input[name="email"]'</span>, <span class="hljs-string">"user@example.com"</span>);
  <span class="hljs-keyword">await</span> page.fill(<span class="hljs-string">'input[name="password"]'</span>, <span class="hljs-string">"password123"</span>);

  <span class="hljs-comment">// 3. Click login button</span>
  <span class="hljs-keyword">await</span> page.click(<span class="hljs-string">'button[type="submit"]'</span>);

  <span class="hljs-comment">// 4. Wait for navigation and verify success message or dashboard</span>
  <span class="hljs-keyword">await</span> expect(page).toHaveURL(<span class="hljs-string">"http://localhost:3000/dashboard"</span>);
  <span class="hljs-keyword">await</span> expect(page.getByText(<span class="hljs-string">"Welcome back!"</span>)).toBeVisible();
});
</code></pre>
<p>Let’s understand what’s happening here step-by-step:</p>
<ul>
<li><p><strong>Visit the page:</strong> The test opens your web app in a real browser. It navigates to <code>http://localhost:3000/login</code>.</p>
</li>
<li><p><strong>Simulate user input:</strong> Playwright fills in the email and password fields, just like a real user typing into the form.</p>
</li>
<li><p><strong>Perform actions:</strong> It clicks the login button, triggering all the same logic your frontend and backend would normally handle.</p>
</li>
<li><p><strong>Verify the outcome:</strong> Once the user logs in, check if the URL changes to <code>/dashboard</code> and whether a welcome message appears on the screen.</p>
</li>
</ul>
<p>That’s it, you just automated your first user journey from login to dashboard. Both frameworks achieve the same goal, ensuring your app behaves correctly in a real browser, not just in isolated tests.</p>
<h3 id="heading-ai-augmented-testing">AI-Augmented Testing</h3>
<p>As testing evolves, a new layer has emerged that is <strong>AI-Augmented QA</strong>. This isn’t just another tool in the developer’s toolkit. It’s a complete transformation in how software quality is managed.</p>
<p>Traditionally, testing has been a manual process. Engineers wrote, maintained, and updated test cases whenever the product changed. But with AI entering the scene, that manual burden is decreasing.</p>
<p>AI models can now analyze your codebase, understand logic, and generate relevant test cases almost instantly, covering edge cases you might never think of. Tools like <a target="_blank" href="https://github.com/features/copilot">GitHub Copilot</a> and <a target="_blank" href="https://www.codium.ai/qodo/">CodiumAI</a> already assist in generating smart test suites, while continuously learning from your coding style and past patterns.</p>
<p>Beyond code suggestions, complete AI QA platforms are changing automation itself. For example, an AI QA agent like <a target="_blank" href="https://bug0.com/">Bug0</a> can adjust to UI changes automatically. If a button label or DOM structure changes, its self-healing tests find elements visually instead of depending on fixed selectors.</p>
<p>It also produces real-time test reports with detailed logs and video recordings, helping developers pinpoint UI or data changes causing failures.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1759925194041/7b3a5b82-6313-4ce8-8ae8-6d80dafbc5be.png" alt="A screenshot of a code editor displaying a test script, including code snippets for page navigation and URL checks. Below the code, there is a section labeled &quot;Videos&quot; with a video player showing" class="image--center mx-auto" width="800" height="921" loading="lazy"></p>
<p>With CI/CD integrations like GitHub or GitLab, it can automatically start and validate test runs for every pull request, updating PR checks just like a human QA engineer would.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1759924826000/ff55cf75-0b8d-4d01-9f12-2f1920be6862.png" alt="A screenshot of a GitHub interface showing a failed Vercel deployment, a skipped public API test, and six successful checks. An arrow points to the successful &quot;Bug0 QA Agent&quot; test. Notifications also indicate that a review is required, and the branch is out-of-date with the base branch." class="image--center mx-auto" width="800" height="537" loading="lazy"></p>
<p>While AI-assisted testing is powerful, it’s not a full replacement for human judgment. Developers still play a vital role in the following ways:</p>
<ul>
<li><p>AI can generate test cases, but humans must decide what truly matters for business logic and user experience.</p>
</li>
<li><p>Reviewing AI-generated tests to ensure they are relevant and to avoid false positives.</p>
</li>
<li><p>Interpreting failures contextually means understanding whether a test failure indicates a real bug or an expected change.</p>
</li>
<li><p>Maintaining ethical and data-safe workflows involves avoiding the exposure of sensitive data when using cloud-based AI tools.</p>
</li>
</ul>
<p>When used responsibly, AI becomes a testing partner, automating the tedious tasks while leaving creative problem-solving, decision-making, and domain understanding to developers.</p>
<p>This shift marks the beginning of intelligent, autonomous QA. AI isn’t just automating repetitive testing, it’s transforming the process into a continuous, adaptive feedback loop, capable of predicting and resolving failures on its own.</p>
<p>In the coming years, expect testing to evolve into a collaborative process between human engineers and AI copilots, ensuring every release is not just faster, but smarter and more reliable than ever before.</p>
<h2 id="heading-future-of-javascript-testing">Future of JavaScript Testing</h2>
<p>JavaScript testing is changing faster than ever. A few years ago, developers had to deal with tons of testing libraries and confusing setups. Now, things are becoming much more unified, smarter, and easier to work with.</p>
<p>In the future, testing will move from being reactive to proactive. That means instead of catching bugs after they happen, tools will be smart enough to predict and prevent them before they appear.</p>
<p>With AI-powered test generation and real-time monitoring, every commit you make could be automatically checked for reliability and performance without you even running a command.</p>
<p>Frameworks like <code>Vitest</code>, <code>Playwright</code>, and <code>React Testing Library</code> will still be the core tools, but the real progress will come from how they integrate and learn.</p>
<p>We’ll also see tighter CI/CD integrations, where pipelines can automatically adjust based on your test coverage and code risk. Testing won’t feel like an extra step anymore, it’ll become a natural part of development, powered by both human logic and machine intelligence.</p>
<p>In short, the future of JavaScript testing is about speed, intelligence, and automation. A world where developers spend more time building and less time debugging.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Testing isn’t just about preventing bugs, it’s about building confidence. Confidence that your code works, your features scale, and your users have a seamless experience.</p>
<p>Whether it’s unit tests ensuring logic, integration tests validating flow, E2E tests simulating real behavior, or AI-enhanced automation managing it all. Testing is the silent force that makes great software possible.</p>
<p>As a developer, understanding how testing fits into your workflow is no longer optional. Rather, it’s a skill that sets you apart. The more you test, the better you code and the faster you ship with peace of mind.</p>
<p>So, the next time someone says <strong>writing tests isn’t your job</strong>, you’ll know the truth: Testing isn’t extra work. Instead, it’s part of writing better, more reliable software.</p>
<h2 id="heading-before-we-end"><strong>Before We End</strong></h2>
<p>I hope you found this article insightful. I’m Ajay Yadav, a software developer and content creator.</p>
<p>You can connect with me on:</p>
<ul>
<li><p><a target="_blank" href="https://x.com/atechajay">Twitter/X</a> and <a target="_blank" href="https://www.linkedin.com/in/atechajay/">LinkedIn</a>, where I share insights to help you improve 0.01% each day.</p>
</li>
<li><p>Check out my <a target="_blank" href="https://github.com/ATechAjay">GitHub</a> for more projects.</p>
</li>
<li><p>I also run a <a target="_blank" href="http://youtube.com/@atechajay">YouTube Channel</a> where I share content about careers, software engineering, and technical writing.</p>
</li>
</ul>
<p>See you in the next article — until then, keep learning!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Use Postman Scripts to Simplify Your API Authentication Process ]]>
                </title>
                <description>
                    <![CDATA[ Postman is a platform used by developers, API testers, technical writers and DevOps teams for testing, documenting and collaborating on API development. It provides a user-friendly interface for making different types of API requests (HTTP, GraphQL, ... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-use-postman-scripts/</link>
                <guid isPermaLink="false">68bee731d2147595571c5b44</guid>
                
                    <category>
                        <![CDATA[ authentication ]]>
                    </category>
                
                    <category>
                        <![CDATA[ APIs ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Orim Dominic Adah ]]>
                </dc:creator>
                <pubDate>Mon, 08 Sep 2025 14:24:49 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1757341168209/dc77dc00-a0a6-40f7-b766-ce07d0d8a637.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Postman is a platform used by developers, API testers, technical writers and DevOps teams for testing, documenting and collaborating on API development. It provides a user-friendly interface for making different types of API requests (HTTP, GraphQL, gRPC), inspecting responses, and organizing API calls into collections for collaboration and automation.</p>
<p>Performing repetitive tasks while testing APIs is stressful and time-wasting. For example, the process of retrieving, copying and pasting new authentication tokens for use in Postman is repetitive. You can simplify this process by using Postman scripts to store auth tokens and then reuse them without repeating the copy and paste steps.</p>
<p>To practice along with this guide, you should have:</p>
<ul>
<li><p>The <a target="_blank" href="https://www.postman.com/downloads/">Postman API client</a> installed on your computer</p>
</li>
<li><p>Experience in making API requests with Postman</p>
</li>
<li><p>A backend application that uses JWT authentication and has its documentation in your Postman client</p>
</li>
</ul>
<p>If you don’t have a backend application setup, I created one that you can clone from GitHub at <a target="_blank" href="https://github.com/orimdominic/freeCodeCamp-postman-api-jwt">orimdominic/freeCodeCamp-postman-api-jwt</a>.</p>
<p>By the end of this article, you should be able to simplify the process of obtaining and reusing authentication tokens across your API requests. You should also have a practical understanding of some scripts necessary for use in other areas of software testing with Postman.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-table-of-contents">Table of Contents</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-are-postman-scripts">What are Postman Scripts?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-simplify-your-jwt-authentication-process">How to Simplify Your JWT Authentication Process</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-authenticate-to-get-the-token">Authenticate to Get the Token</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-save-the-token-in-a-variable-with-a-postman-script">How to Save the Token in a Variable with a Postman Script</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-use-the-variable-in-a-request">How to Use the Variable in a Request</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-next-steps">Next Steps</a></p>
</li>
</ul>
<h2 id="heading-what-are-postman-scripts">What are Postman Scripts?</h2>
<p><a target="_blank" href="https://learning.postman.com/docs/tests-and-scripts/tests-and-scripts/">Postman scripts</a> are blocks of JavaScript code that you can write and run within the Postman API client to automate and enhance API testing workflows. You can use Postman scripts to add code to run before and after API requests. These scripts can be used to:</p>
<ul>
<li><p>Add logic and process data from API requests</p>
</li>
<li><p>Write test assertions for API responses</p>
</li>
<li><p>Run automated tests on API endpoints</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1756577771526/161bd327-fbf7-48cb-acab-317ab1cad4c5.jpeg" alt="The Postman scripts tab" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>You can find Postman scripts under the <strong>Scripts</strong> tab of an API request. Code written in the <strong>Pre-request</strong> tab runs before the request is made and code written in the <strong>Post-response</strong> tab runs after the response is made.</p>
<h2 id="heading-how-to-simplify-your-jwt-authentication-process">How to Simplify Your JWT Authentication Process</h2>
<p>In summary, you will carry out the following steps to achieve the objective of this tutorial:</p>
<ol>
<li><p>Authenticate to get the token</p>
</li>
<li><p>Save the token in a collection variable with Postman scripts</p>
</li>
<li><p>Use the variable in an API request</p>
</li>
</ol>
<h3 id="heading-authenticate-to-get-the-token">Authenticate to Get the Token</h3>
<p>To get started, carry out the following steps:</p>
<ol>
<li><p>Start your backend application and make sure it is running successfully.</p>
</li>
<li><p>Open up your Postman application and go to the API request for signing in to get a JWT.</p>
</li>
<li><p>Make an API request to the sign in endpoint and take note of the JSON response schema.</p>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1756573137191/b5aad14c-5094-4a84-8876-1bbbb869064c.jpeg" alt="Authentication request response" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>The highlighted part of the image above shows the JSON response from a successful sign in request. In the response schema, the auth token to be used for authorization is in the <code>data.token</code> field. You will use Postman scripts to store this token in a variable and then use the variable in the <code>Authorization</code> header of requests that require authorization.</p>
<h3 id="heading-how-to-save-the-token-in-a-variable-with-a-postman-script">How to Save the Token in a Variable with a Postman Script</h3>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1756575948975/2b43493d-2803-45cd-aefe-0ca5694f75e8.jpeg" alt="Add logic in Post-response Postman script" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>In Postman, click on the <strong>Scripts</strong> tab next to the <strong>Body</strong> tab. If the Postman application window is small, you may need to click a dropdown to see it. Next, click on the <strong>Post-response</strong> tab. In the text area to the right, you will write the script to capture the auth token from the response and store it in a Postman variable. Copy the JavaScript code below and paste it into the text area.</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">if</span> (pm.response.code == <span class="hljs-number">200</span>) {
    <span class="hljs-keyword">const</span> token = pm.response.json().data.token
    pm.collectionVariables.set(<span class="hljs-string">"auth_token"</span>, token)
}
</code></pre>
<p>Postman scripts use the <a target="_blank" href="https://learning.postman.com/docs/tests-and-scripts/write-scripts/postman-sandbox-api-reference/"><code>pm</code> identifier</a> to access and modify information in the Postman environment. The script above uses <code>pm</code> to first ensure that the request was successful by checking if the response status code is <code>200</code>.</p>
<p>Inside the conditional statement, <code>pm.response.json().data.token</code> is used to get the authentication token from the JSON response and store it in a collection variable called <code>auth_token</code>. If <code>auth_token</code> doesn’t exist already, it is created and its value is set to the value of <code>token</code>. If it exists already, its value is replaced.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1756581970294/bed1fe89-9c00-4b94-9f71-173ea3bf1cd1.png" alt="Postman collection variable set by a script" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>To confirm that <code>auth_token</code> has been set, click on the name of the collection (labelled 1 in the screenshot above) and then click on the <strong>Variables</strong> tab (labelled 2 in the screenshot above). Next, instead of repeatedly copying the token and pasting it in the <code>Authorization</code> header of your requests, you will use <code>auth_token</code> in the <code>Authorization</code> header of your requests.</p>
<h3 id="heading-how-to-use-the-variable-in-a-request">How to Use the Variable in a Request</h3>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1756583915681/d3bf0f56-c406-4d3e-b7f1-df4cbc2a3cfc.png" alt="Use the Variable in a Request" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>Reference the collection variable in the <code>Authorization</code> header by surrounding it with double curly braces <code>{{auth_token}}</code>. When you make an API request, Postman will use the value referenced by <code>{{auth_token}}</code> as the <code>Authorization</code> header.</p>
<p>If another authentication request causes the value of <code>auth_token</code> to be updated, you no longer need to copy the new auth token. The script in the post-response tab will update the <code>auth_token</code> value and you can go on with making API requests smoothly. No need for repeatedly copying and pasting - <strong>Don’t Repeat Yourself (DRY)</strong>.</p>
<h2 id="heading-next-steps">Next Steps</h2>
<p>In this tutorial, you have learnt how to use Postman scripts to set environment variables in Postman. You have also learnt how to eliminate the process of repeatedly copying and pasting auth tokens for use in API requests.</p>
<p>For guides on writing assertion tests for your APIs, check out the <a target="_blank" href="https://learning.postman.com/docs/tests-and-scripts/test-apis/test-apis/">Test API Functionality and Performance in Postman</a> guide by Postman.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Free Up and Automatically Manage Disk Space for WSL on Windows 10/11 ]]>
                </title>
                <description>
                    <![CDATA[ Windows Subsystem for Linux (WSL) lets you run a Linux environment directly on Windows. This is particularly useful for web development where you can develop and test applications in a Linux environme ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-free-up-and-automatically-manage-disk-space-for-wsl-on-windows-1011/</link>
                <guid isPermaLink="false">6893e671640b08f689368ee6</guid>
                
                    <category>
                        <![CDATA[ WSL ]]>
                    </category>
                
                    <category>
                        <![CDATA[ wsl2 ]]>
                    </category>
                
                    <category>
                        <![CDATA[ disk management ]]>
                    </category>
                
                    <category>
                        <![CDATA[ disk ]]>
                    </category>
                
                    <category>
                        <![CDATA[ disk space ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Powershell ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Windows ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Windows 10 ]]>
                    </category>
                
                    <category>
                        <![CDATA[ windows 11 ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ brooklyn ]]>
                </dc:creator>
                <pubDate>Wed, 06 Aug 2025 23:34:09 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1754523230294/70893973-fddf-42a9-b41a-2a8f94a47e22.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Windows Subsystem for Linux (<a href="https://learn.microsoft.com/en-us/windows/wsl/install">WSL</a>) lets you run a Linux environment directly on Windows. This is particularly useful for web development where you can develop and test applications in a Linux environment without leaving Windows. You can even run <a href="https://contribute.freecodecamp.org/how-to-setup-wsl/">freeCodeCamp locally</a> with it!</p>
<p>But managing disk space can be a quite a challenge, as WSL uses virtual hard disks that do not automatically free up unused space.</p>
<p>This tutorial will guide you through the process of manually compacting your WSL virtual hard disks. We’ll automate this task using a PowerShell script, ensuring that your WSL environment remains efficient and clutter-free.</p>
<h2 id="heading-reclaim-your-space">Reclaim Your Space</h2>
<p>WSL uses a virtualization platform to install Linux distributions on your Windows system. Each distribution you add gets its own Virtual Hard Disk (VHD), which uses the ext4 file system (common in Linux). It’s saved on your Windows drive as an ext4.vhdx file.</p>
<p>Key issues here:</p>
<ul>
<li><p>Inefficient storage: by default, VHD files <strong>do not reclaim</strong> unused space. This means that when you delete a file in WSL, the associated disk space isn’t immediately freed up.</p>
</li>
<li><p>Disk space consumption: due to that inefficient storage, the <strong>VHD files can grow large</strong> thanks to that accumulated data, especially if you’re a WSL heavy user.</p>
</li>
<li><p>Need for maintenance: you may not know that you need to <strong>compact</strong> your VHD files in order to reclaim disk space.</p>
</li>
</ul>
<p>If you notice that your free disk space is shrinking even after deleting files and apps, WSL might be the reason. This tutorial will help you keep your WSL and Windows environment running smoothly.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-part-1-how-to-manually-compact-your-virtual-hard-disk">Part 1: How to Manually Compact Your Virtual Hard Disk</a></p>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-step-1-verify-your-wsl-version-and-status">Step 1: Verify your WSL version and status</a></p>
</li>
<li><p><a href="#heading-step-2-list-all-installed-distributions-verbosely">Step 2: List all installed distributions verbosely</a></p>
</li>
<li><p><a href="#heading-step-3-locate-your-linux-virtual-hard-drive-vhdx-path">Step 3: Locate your linux Virtual Hard Drive (VHDX) path</a></p>
</li>
<li><p><a href="#heading-step-4-shut-down-all-wsl-instances">Step 4: Shut down all WSL instances</a></p>
</li>
<li><p><a href="#heading-step-5-compact-the-linux-virtual-hard-drive-using-diskpart">Step 5: Compact the Linux virtual hard drive using DiskPart</a></p>
</li>
<li><p><a href="#heading-step-6-restart-wsl-and-verify">Step 6: Restart WSL and verify</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-2-how-to-make-your-life-easier-with-automation">Part 2: How to Make Your Life Easier with Automation</a></p>
<ul>
<li><p><a href="#heading-prerequisites-1">Prerequisites</a></p>
</li>
<li><p><a href="#heading-step-1-find-out-installed-wsl2-distributions">Step 1: Find out installed WSL2 distributions</a></p>
</li>
<li><p><a href="#heading-step-2-select-a-distro-to-compact">Step 2: Select a distro to compact</a></p>
</li>
<li><p><a href="#heading-step-3-locate-the-ext4vhdx-file">Step 3: Locate the ext4.vhdx File</a></p>
</li>
<li><p><a href="#heading-step-4-the-confirmation-prompt">Step 4: The confirmation prompt</a></p>
</li>
<li><p><a href="#heading-step-5-shut-down-wsl-and-compact">Step 5: Shut Down WSL and compact</a></p>
</li>
<li><p><a href="#heading-step-6-run-a-diskpart-script">Step 6: Run a DiskPart script</a></p>
</li>
</ul>
</li>
</ul>
<h2 id="heading-part-1-how-to-manually-compact-your-virtual-hard-disk"><strong>Part 1: How to Manually Compact Your Virtual Hard Disk</strong></h2>
<p>Let's start by going through the process manually. This section will guide you through checking your WSL version and associated Linux distributions, finding VHD files, shutting down WSL, and compacting the virtual disk.</p>
<h3 id="heading-prerequisites"><strong>Prerequisites</strong></h3>
<ul>
<li><p>Windows 10 (20H1/2004+) or Windows 11 with WSL2 installed</p>
</li>
<li><p>The PowerShell or Command Prompt running as <strong>Administrator</strong> (from the Windows menu, right click the icon and choose run as Administrator).</p>
</li>
</ul>
<h3 id="heading-step-1-verify-your-wsl-version-and-status"><strong>Step 1: Verify your WSL version and status</strong></h3>
<p>First, make sure you’re running on WSL version 2 (commonly referred as WSL2). The first version is outdated and WSL2 provides significant improvements. Open PowerShell (as Admin) or Command Prompt (as Admin) and run:</p>
<pre><code class="language-powershell">wsl -v

wsl --status
</code></pre>
<p>These commands display the WSL client version and whether your default distro is using WSL 2. Here’s the output of the <code>wsl -v</code> command:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1754250376279/ef11af4b-ba5b-43f9-9532-db2634eed154.png" alt="Command prompt displaying WSL version 2.5.9.0, with corrupted or incomplete text following &quot;Kernel version:&quot;, &quot;WSLg version:&quot;, and other version labels." width="600" height="400" loading="lazy">

<p>And here’s the output of the <code>wsl --status</code> command.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1754250364365/6cea2d97-0796-4320-8f84-58d1b5e62c5e.png" alt="Command line text showing &quot;C:sers>wsl --status&quot; with the information &quot;Default Distribution: Ubuntu&quot; and &quot;Default Version: 2&quot;." width="600" height="400" loading="lazy">

<h3 id="heading-step-2-list-all-installed-distributions-verbosely"><strong>Step 2: List all installed distributions verbosely</strong></h3>
<p>To see a detailed list of your WSL distributions (including which version each uses), run:</p>
<pre><code class="language-powershell">wsl.exe --list --verbose
</code></pre>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1754250281542/1826814a-5516-483e-b8ab-6477fd950e21.png" alt="Output of  the WSL --list --verbose command." width="600" height="400" loading="lazy">

<p>Above you can see the output of the WSL <code>--list --verbose</code> command.</p>
<p>Look for your distro name (for example, “<em>Ubuntu</em>”) and note its WSL version. If it shows “Version 2”, you can proceed with compaction.</p>
<h3 id="heading-step-3-locate-your-linux-virtual-hard-drive-vhdx-path"><strong>Step 3: Locate your linux Virtual Hard Drive (VHDX) path</strong></h3>
<p>Each WSL distro’s files live in a <a href="https://en.wikipedia.org/wiki/VHD_(file_format)">VHDX file</a> on your Windows drive. To find the path for any Linux distribution, use this PowerShell snippet:</p>
<pre><code class="language-powershell">(Get-ChildItem `

-Path HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss `

| Where-Object { $_.GetValue("DistributionName") -eq 'YOUR_DISTRO_NAME' }

).GetValue("BasePath") + "\ext4.vhdx"
</code></pre>
<p>Where you replace <code>YOUR_DISTRO_NAME</code> with yours (Ubuntu, Debian, Kali-linux..). Here’s the output of the command shown above in PowerShell (the filepath has been anonymized):</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1754251303855/ea3b3880-5804-4f50-97c8-327ffd017084.png" alt="A PowerShell command is displayed, used to locate the ext4.vhdx file for the Ubuntu distribution. The command retrieves the Windows Subsystem for Linux (WSL) base path for Ubuntu." width="600" height="400" loading="lazy">

<p>This command reads the registry key for your linux distribution, then appends “\ext4.vhdx” to build the full file path.</p>
<p>Make sure you copy the whole line. We will need it in later stages.</p>
<h3 id="heading-step-4-shut-down-all-wsl-instances"><strong>Step 4: Shut down all WSL instances</strong></h3>
<p>Before you can compact any virtual drive, make sure WSL is completely shut down. In PowerShell or Command Prompt (still as Administrator), run:</p>
<pre><code class="language-powershell">wsl.exe --shutdown
</code></pre>
<h3 id="heading-step-5-compact-the-linux-virtual-hard-drive-using-diskpart"><strong>Step 5: Compact the Linux virtual hard drive using DiskPart</strong></h3>
<p>You successfully gathered all the needed information (about your system, the available distros, and their VHDX filepath) to proceed with the main task. In this step, you actually proceed with the compaction.</p>
<ol>
<li>Launch DiskPart in the same elevated (<em>admin</em>) shell:</li>
</ol>
<pre><code class="language-powershell">diskpart
</code></pre>
<p>DiskPart will open in a new window. It's a Windows command-line tool for managing disk partitions. Be cautious when using it, as incorrect actions can cause serious data loss.</p>
<ol>
<li>In the DiskPart prompt, select the VHDX file you found earlier. Replace the path as displayed below with your actual path (the line you copied before):</li>
</ol>
<pre><code class="language-powershell">select vdisk file="C:\Users\username\AppData\path\to\ext4.vhdx"
</code></pre>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1754251748072/91795798-0896-4fcc-994c-0ab311955bee.png" alt="Screenshot of a command prompt window showing Microsoft DiskPart version information. A command is entered to select a virtual disk file, and a message confirms successful selection." width="600" height="400" loading="lazy">

<p>The above is the output of the select vdisk command (some data has been anonymized).</p>
<ol>
<li>Attach the virtual drive in read-only mode:</li>
</ol>
<p>Compaction only needs to scan the empty blocks in the file, not write to the Linux filesystem inside. Read-only mode guarantees that DiskPart only inspect the blocks for zero‐trimming without any chance of damaging or altering your Linux filesystem.</p>
<pre><code class="language-powershell">attach vdisk readonly
</code></pre>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1754251937734/a90af720-1511-42cc-bc37-63439d855907.png" alt="Command prompt showing the output for &quot;DISKPART> attach vdisk readonly&quot; with a successful attachment message." width="600" height="400" loading="lazy">

<p>You can see in the screenshot above that the virtual hard drive has been successfully attached.</p>
<ol>
<li>Compact the disk:</li>
</ol>
<p>This frees up the disk space by shrinking the physical size of the <code>.vhdx</code> file to match the <strong>actual used data</strong> inside.</p>
<pre><code class="language-powershell">compact vdisk
</code></pre>
<p>This operation might take a while. When you see the <code>“DiskPart successfully compacted the virtual disk file”</code> message, proceed with the next step. In the image below, the virtual hard drive has been successfully compacted.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1754252148605/51cc9739-775b-4a84-a526-7c2a6d2a9722.png" alt="Terminal output showing &quot;DISKPART> compact vdisk&quot; with &quot;100 percent completed,&quot; indicating successful compaction of the virtual disk file." width="600" height="400" loading="lazy">

<ol>
<li>Detach the virtual drive:</li>
</ol>
<pre><code class="language-powershell">detach vdisk
</code></pre>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1754252460797/e29387d7-4f03-4ddb-80f7-f14a5051b0fe.png" alt="Command prompt showing &quot;DISKPART> detach vdisk&quot; and a confirmation message that DiskPart successfully detached the virtual disk file." width="600" height="400" loading="lazy">

<p>There you go – the virtual hard drive has been successfully detached.</p>
<p>This command releases any locks on the virtual drive and effectively dismounts it. If you don't use this command, the file remains "in use," preventing WSL (or you) from accessing it until you reboot or manually force it closed.</p>
<p>6. Exit DiskPart:</p>
<pre><code class="language-powershell">exit
</code></pre>
<h3 id="heading-step-6-restart-wsl-and-verify"><strong>Step 6: Restart WSL and verify</strong></h3>
<p>Back in PowerShell or Command Prompt, you can relaunch your distro:</p>
<pre><code class="language-powershell">wsl -d YOUR_DISTRO_NAME
</code></pre>
<p>You can even try the Unix <code>df -h</code> command in your WSL prompt to check your new available disk spaces.</p>
<p>Congrats, you just achieved a maintenance task that can free up lots of gigabytes of storage over time. Now, it’s time to automate.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1754321783829/4325a626-806e-47e7-9147-a76f4c91a93a.jpeg" alt="A minimalistic white rectangular button with a cable on a pink surface." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<h2 id="heading-part-2-how-to-make-your-life-easier-with-automation"><strong>Part 2: How to Make Your Life Easier with Automation</strong></h2>
<p>Since it's often hard to remember exactly where your WSL distro is located and you probably won't use it very often, this PowerShell script will automate the entire process we covered in part 1. Here's a preview of the steps you'll follow:</p>
<ul>
<li><p>Detect installed WSL distributions.</p>
</li>
<li><p>Select one (and handle the cases there are more than one).</p>
</li>
<li><p>Locate the corresponding <code>ext4.vhdx</code> file.</p>
</li>
<li><p>Shut down WSL and use DiskPart to compact the virtual disk.</p>
</li>
</ul>
<h3 id="heading-prerequisites"><strong>Prerequisites</strong></h3>
<ul>
<li><p>Windows 10 (20H1/2004+) or Windows 11 with WSL 2 enabled.</p>
</li>
<li><p>PowerShell or Command Prompt (as Administrator).</p>
</li>
</ul>
<p>You’ll also need a code editor. The Windows notepad is enough for completing this task. You can also use an IDE (Integrated Development Environment) like VS Code or an ISE (Integrated Scripting Environment) like PowerShell ISE (included with Windows).</p>
<p>To test the script, download it from <a href="https://github.com/hyperphantasia/WSL-VHDX-Compact/blob/c5c2e346ab0dd1a8dbc6130f8d372af8022ddd60/wsl_compactor.ps1">GitHub</a>. Open an elevated PowerShell or Command Prompt and navigate to the script’s folder. With just the command below, you will be able to run it and free up some disk space:</p>
<pre><code class="language-powershell">powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\wsl_compactor.ps1
</code></pre>
<h3 id="heading-step-1-find-out-installed-wsl2-distributions"><strong>Step 1: Find out installed WSL2 distributions</strong></h3>
<p>One of the main challenges is to find the Linux distributions available on the host system. Let’s check the first block and see what’s it about:</p>
<pre><code class="language-powershell">$lxssKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss'
\(distros = Get-ChildItem \)lxssKey | ForEach-Object {
    \(p = Get-ItemProperty \)_.PSPath
    [PSCustomObject]@{
        Name     = $p.DistributionName
        BasePath = $p.BasePath
    }
}
</code></pre>
<p>WSL records each distribution under this windows registry key:</p>
<p><code>HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss</code></p>
<p>Each subkey has two important values:</p>
<ul>
<li><p><strong>DistributionName</strong> (for example, Ubuntu)</p>
</li>
<li><p><strong>BasePath</strong> (This is where the distribution files are stored. It’s the directory that contains the <code>ext4.vhdx</code> file.)</p>
</li>
</ul>
<p>The script uses <code>Get-ChildItem</code> and <code>Get-ItemProperty</code> to enumerate these subkeys and build a list of available Linux distributions.</p>
<pre><code class="language-powershell">if ($distros.Count -eq 0) {
Throw-And-Exit "No WSL distros found in the registry."
}
</code></pre>
<p>If no distributions are found, the script terminates and prints this error message on the terminal: <code>"No WSL distros found in the registry.”</code></p>
<h3 id="heading-step-2-select-a-distro-to-compact"><strong>Step 2: Select a distro to compact</strong></h3>
<p>Here, the process has two steps:</p>
<ul>
<li>If multiple distros are found, it displays all the distros with a numbered menu and prompts you to choose one:</li>
</ul>
<pre><code class="language-powershell">if ($distros.Count -gt 1) {
    Write-Host "Multiple distros detected. Please choose one:`n"

    for (\(i = 0; \)i -lt \(distros.Count; \)i++) {
        Write-Host "[\((\)i+1)] \((\)distros[$i].Name)"
    }
    \(selected = \)distros[[int]$choice - 1]
}
</code></pre>
<p>The computed menu will look like this:</p>
<pre><code class="language-markdown">Multiple distros detected. Please choose one:

[1] Ubuntu 20.04

[2] Debian

[3] Alpine
</code></pre>
<ul>
<li>If only one distribution is found on the host system, the script selects it automatically:</li>
</ul>
<pre><code class="language-powershell">else {
\(selected = \)distros[0]
}
</code></pre>
<p>When setting up a distribution, whether chosen manually by the user or selected automatically, <strong>the important information is the path to each distribution's virtual hard disk</strong>. This path is saved in two main variables: <code>'distro'</code> (which identifies the specific distribution) and <code>'basePath'</code> (which shows where its virtual disk is located).</p>
<pre><code class="language-powershell">\(distro = \)selected.Name
\(basePath = \)selected.BasePath

Write-Host "`nSelected distro: $distro" -ForegroundColor DarkYellow
Write-Host "BasePath: $basePath"
</code></pre>
<p>The lines above display an output that looks like this:</p>
<pre><code class="language-markdown">Selected distro: Ubuntu (or any other distro)
BasePath: C:\Users\&lt;User_name&gt;\AppData\Local\Packages\…
</code></pre>
<p>Like for all other steps, it’s important to consider the case of something going wrong, by throwing an error and exiting the program:</p>
<pre><code class="language-powershell">if (-not (Test-Path $basePath)) {
Throw-And-Exit "BasePath '$basePath' does not exist on disk."
}
</code></pre>
<h3 id="heading-step-3-locate-the-ext4vhdx-file"><strong>Step 3: Locate the ext4.vhdx File</strong></h3>
<p>In the first step, we collected the information we need about the available distributions and where they are stored on the Windows system. By choosing an entry (either manually or automatically), we can find the correct file. Sometimes, the ext4 file is located between the base path and a <em>LocalState</em> folder. This script manages both situations. It builds the usual locations where the file can be found.</p>
<p>They look like this:</p>
<ul>
<li><p><code>$BasePath\ext4.vhdx</code></p>
</li>
<li><p><code>$BasePath\LocalState\ext4.vhdx</code></p>
</li>
</ul>
<p>This can translate into something like this on your system (option 1):</p>
<pre><code class="language-markdown">C:\Users\Alice\AppData\Local\Packages\CanonicalGroupLimited.Ubuntu20.04onWindows_79rhkp1fndgsc\ext4.vhdx
</code></pre>
<p>or like this (option 2):</p>
<pre><code class="language-markdown">C:\Users\Alice\AppData\Local\Packages\CanonicalGroupLimited.Ubuntu20.04onWindows_79rhkp1fndgsc\LocalState\ext4.vhdx
</code></pre>
<p>(You might find out that your WSL2 distro is located in some other directory than “Packages” – but don’t worry, your BasePath will match the correct folders).</p>
<p>The idea is to build the two possible path options:</p>
<pre><code class="language-powershell">$possible = @(
Join-Path $basePath 'ext4.vhdx'
Join-Path $basePath 'LocalState\ext4.vhdx'
)
</code></pre>
<p>And pick the first one that actually contains the file:</p>
<pre><code class="language-powershell">\(vhdx = \)possible | Where-Object { Test-Path $_ } | Select-Object -First 1
</code></pre>
<p>Again, we throw an error message if no suitable file is found:</p>
<pre><code class="language-powershell">if (-not $vhdx) {
Throw-And-Exit "No ext4.vhdx found under '$basePath'."
}
</code></pre>
<h3 id="heading-step-4-the-confirmation-prompt"><strong>Step 4: The confirmation prompt</strong></h3>
<p>Disk management tools require caution and you need to understand the potential consequences of your actions. A confirmation prompt is always a good safeguard to prevent accidental data loss or unwanted system changes.</p>
<p>Before proceeding, the script shows you:</p>
<ul>
<li><p>a Distro name</p>
</li>
<li><p>its BasePath</p>
</li>
<li><p>the VHDX file path</p>
</li>
</ul>
<pre><code class="language-powershell">Write-Host "`nAbout to compact this WSL distro:" -ForegroundColor Magenta
Write-Host " Distro : $distro"
Write-Host " BasePath : $basePath"
Write-Host " VHDX file: $vhdx`n"
</code></pre>
<p>It then prompts <strong>“Are you sure you want to proceed? (Y/N)”:</strong></p>
<pre><code class="language-powershell">Write-Host "Are you sure you want to proceed? (Y/N) " -ForegroundColor DarkCyan -NoNewline

# Then read the response
$answer = Read-Host
</code></pre>
<p>You’re then prompted to Type Y (case-insensitive) to continue or anything else to cancel.</p>
<pre><code class="language-powershell">if ($answer.ToUpper() -ne 'Y') {
    Write-Warning "Operation canceled"
    exit
}
</code></pre>
<p>For the two steps above, I had to use a trick to print the question in color but a simple option (without colors) could be:</p>
<pre><code class="language-powershell">if ((Read-Host 'Are you sure you want to proceed? (Y/N)').ToUpper() -ne 'Y') { 
    Write-Warning 'Operation canceled'
    exit 
}
</code></pre>
<h3 id="heading-step-5-shut-down-wsl-and-compact"><strong>Step 5: Shut down WSL and compact</strong></h3>
<p>Before proceeding to the DiskPart utility, it’s important to stop all running WSL instances. Pass the shutdown command directly in PowerShell.</p>
<pre><code class="language-powershell">Write-Host "Shutting down WSL…" -ForegroundColor Cyan
wsl.exe –shutdown
</code></pre>
<p>A common mistake is to forget to launch PowerShell or the Command Prompt with Administrator rights. You can prevent this case with a message:</p>
<pre><code class="language-powershell">if ($LASTEXITCODE -ne 0) {
     Throw-And-Exit "Failed to shut down WSL (exit code $LASTEXITCODE). Are you running as Administrator?"
}
</code></pre>
<h3 id="heading-step-6-run-a-diskpart-script"><strong>Step 6: Run a DiskPart script</strong></h3>
<h4 id="heading-building-the-script">Building the script:</h4>
<p>The process is the same as in the manual part, but this time, we ‘inject’ the ready-to-go DiskPart commands into the script.</p>
<pre><code class="language-powershell">$dpScript = @"
select vdisk file="$vhdx"
attach vdisk readonly
compact vdisk
detach vdisk
exit
"@
</code></pre>
<p>Before launching, there are two steps you need to take:</p>
<ol>
<li>The PowerShell script writes the lines above to a temporary file:</li>
</ol>
<pre><code class="language-powershell">$tempFile = [IO.Path]::GetTempFileName()
Set-Content -LiteralPath \(tempFile -Value \)dpScript -Encoding ASCII
</code></pre>
<p>This is the equivalent to the commands passed in the manual part:</p>
<p><code>select vdisk file="</code><a href="/home/C:/"><code>C:\</code></a><code>…\ext4.vhdx" # full path to the vdisk file</code></p>
<p><code>attach vdisk readonly</code></p>
<p><code>compact vdisk</code></p>
<p><code>detach vdisk</code></p>
<p><code>exit</code></p>
<ol>
<li>Compacting can take a while, especially if you’ve never de-cluttered your virtual drive before. It’s wise to show a warning before proceeding:</li>
</ol>
<pre><code class="language-powershell">Write-Host "Running DiskPart to compact the VHDX. Be patient, this might take a while..." -ForegroundColor Cyan
</code></pre>
<h4 id="heading-invoke-diskpart"><strong>Invoke DiskPart:</strong></h4>
<pre><code class="language-powershell"># Run DiskPart with the script saved to the temporary file and process each output line as it arrives
diskpart /s $tempFile | ForEach-Object {
    # Grab any "NN percent" type message from the line
    if ($_ -match '(\d+)\s+percent') {
        # Only print when the percentage actually changes
        Write-Host "\((\)Matches[1])% completed"
    }
    else {
        # Just echo all over line-types, verbatim
        Write-Host $_
    }
}
</code></pre>
<p>Several points to note here:</p>
<ul>
<li><p>It runs <code>diskpart /s $tempFile</code>: DiskPart reads and executes commands from the temporary file into the PowerShell loop for on-the-fly processing.</p>
</li>
<li><p>For a better user-experience: the snippet below does the trick of filtering out repeated status values by comparing <code>\(pct</code> with the sentinel <code>\)lastPct</code>, and only writing new lines when they differ.</p>
</li>
</ul>
<p>How?</p>
<p>Before entering the loop, we initialize:</p>
<pre><code class="language-powershell">$lastPct with -1
$lastPct = -1 # We initiate a sentinel value
</code></pre>
<p>We are having a guaranteed “first” value that no real percent (0–100) will equal. That way, as soon as you see the first 0 percent, 10 percent, or whatever, it differs from -1.</p>
<p>Then:</p>
<pre><code class="language-powershell">if ($_ -match '(\d+)\s+percent') {
    # Print only when the percentage changes
    Write-Host "\((\)Matches[1])% completed"
}
</code></pre>
<p>This guarantees that on the very first percentage update (say “0 percent” or “10 percent”), <code>\(pct –ne \)lastPct</code> will be <code>true</code>, so it emits the first line. Afterwards, <code>$lastPct</code> holds the last real percentage, and it only prints again when a new, different progress percentage comes in.</p>
<p>The output looks more clean:</p>
<pre><code class="language-markdown">10% completed
20% completed
…
</code></pre>
<p>Otherwise, it’ll flood the screen with dozens of identical “20 percent completed” (for example) updates.</p>
<p>Of course we handle the case of other values (non percent lines) normally:</p>
<pre><code class="language-powershell">else {
    # non-percent lines: print verbatim
    if ($_ -match '\S') {
        Write-Host $_
    }
}
</code></pre>
<p>Once the process is done, don’t forget to clean up the tempfile.</p>
<pre><code class="language-powershell">Remove-Item $tempFile -ErrorAction SilentlyContinue
</code></pre>
<p>By the end of the process, you should see something like this</p>
<pre><code class="language-markdown">Leaving DiskPart...
</code></pre>
<p>Okay, that’s it for the scripting! If you collected all the snippets so far, just save them with a <code>.ps1</code> file extension, or download the full example from this <a href="https://github.com/hyperphantasia/WSL-VHDX-Compact">GitHub repository</a>.</p>
<h3 id="heading-usage"><strong>Usage:</strong></h3>
<p>You now have a complete understanding of what's happening. Ready to get started? In Windows, open the Command Prompt or PowerShell <strong>as an administrator.</strong></p>
<ul>
<li><p>Navigate to the directory containing your <code>.ps1</code>&nbsp;script.</p>
</li>
<li><p>Execute the script with the following command, replacing <code>&lt;File_name_here&gt;</code> with your actual file name:</p>
</li>
</ul>
<pre><code class="language-powershell">powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\&lt;File_name_here&gt;.ps1
</code></pre>
<p>The <code>-NoProfile -ExecutionPolicy Bypass</code> parameters launch PowerShell in a clean, unrestricted environment that ignores user-specific settings and allows script execution without security restrictions. Don’t worry, in this case, it’s okay to do that.</p>
<p><em>Wait…wait…wait...</em></p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1754315563256/a709192a-6626-4a12-b411-46c8591c5eb6.jpeg" alt="Screenshot of a command line interface showing the execution of a PowerShell script to compact a WSL (Windows Subsystem for Linux) distro. The script confirms the selected distro &quot;Ubuntu&quot; and proceeds to compact the VHDX file using DiskPart. Progress is shown in percentage increments, with final messages indicating successful completion of the compacting process." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>Well bravo! You’ve just reclaimed all that unused space inside your WSL2 (almost) hands free!</p>
<p>Now, you can take it a step further by modifying this script to run completely automatically, without the confirmation prompt (step 4). You can also schedule it as a regular maintenance task using a task scheduler:</p>
<pre><code class="language-powershell">schtasks /create /tn "Schedule_name" /tr "powershell.exe -ExecutionPolicy Bypass -File C:\path\to\script.ps1" /sc monthly /d 15 /st 09:00
</code></pre>
<p>This is an example for a monthly execution where <code>/d 15</code> means 15th of each month and <code>/st 09:00</code> is a start time set at 9 am.</p>
<p>That's it! Remember, regular maintenance, whether you do it manually or automatically, is essential to prevent unnecessary disk space usage and ensure a smooth experience with WSL.</p>
<h3 id="heading-thanks-for-reading">Thanks for reading!</h3>
<p>You can find a list of my current projects on <a href="https://github.com/hyperphantasia?tab=repositories">GitHub</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Learn Embedded Systems Firmware Basics – A Handbook for Developers ]]>
                </title>
                <description>
                    <![CDATA[ Have you ever wondered how your fridge knows when to cool, or how a coffee machine knows when to stop pouring? Behind the scenes, these devices are powered by embedded systems – small, dedicated computers designed to perform specific tasks reliably a... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/learn-embedded-systems-firmware-basics-handbook-for-devs/</link>
                <guid isPermaLink="false">6859c55cad0bcef0be044476</guid>
                
                    <category>
                        <![CDATA[ embedded systems ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Firmware Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ sensors ]]>
                    </category>
                
                    <category>
                        <![CDATA[ embeddedcourses ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ debugging ]]>
                    </category>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Soham Banerjee ]]>
                </dc:creator>
                <pubDate>Mon, 23 Jun 2025 21:21:32 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1750701027343/86918e8c-4348-4845-b048-6203ae0fcb38.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Have you ever wondered how your fridge knows when to cool, or how a coffee machine knows when to stop pouring? Behind the scenes, these devices are powered by embedded systems – small, dedicated computers designed to perform specific tasks reliably and efficiently.</p>
<p>An embedded system typically goes through a simple but powerful cycle:</p>
<ol>
<li><p>Sense – Gather information from the environment using sensors.</p>
</li>
<li><p>Process – Use software logic to decide what to do with the data.</p>
</li>
<li><p>Act – Trigger a response, like turning on a motor or lighting an LED.</p>
</li>
</ol>
<p>Each project begins with a use case – a specific goal like brewing coffee or controlling a car’s fuel injection. From that, engineers define system requirements, which are split into:</p>
<ul>
<li><p>Hardware (for example, microcontrollers, sensors, actuators)</p>
</li>
<li><p>Software (what we call embedded software)</p>
</li>
</ul>
<p>This handbook focuses on the software side of embedded systems: how we write code to make embedded systems intelligent. Embedded software runs on resource-constrained devices like microcontrollers, which may have just a few kilobytes of memory. The software might need to be highly efficient, reliable, and often capable of working in real-time.</p>
<p>But embedded software isn't just about writing code – it’s also about understanding:</p>
<ul>
<li><p>How hardware works</p>
</li>
<li><p>How to manage memory and power</p>
</li>
<li><p>How to handle timing and communication</p>
</li>
<li><p>How to build robust, fail-safe systems</p>
</li>
</ul>
<p>While embedded systems development isn’t typically research-focused in most industry roles, it demands a broad skill set, from low-level programming to system-level design. What makes this field especially exciting is how it brings together diverse domains like machine learning, digital signal processing (DSP), and control systems, all of which can be applied directly in real-world devices.</p>
<p>In this article, I’ll give you:</p>
<ul>
<li><p>A high-level overview of what embedded software involves</p>
</li>
<li><p>Key concepts every developer should know</p>
</li>
<li><p>A tour of commonly used tools and frameworks</p>
</li>
<li><p>Resources to help you learn and understand basics.</p>
</li>
</ul>
<p>Whether you're just curious or planning a career in embedded systems, this guide is your launchpad.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-hw-layer-microcontroller">HW Layer: Microcontroller</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-firmware-design-and-tools">Firmware Design and Tools</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-tools-and-concepts-for-embedded-development">Tools and Concepts for Embedded Development</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-bare-metal-rtos-and-embedded-operating-systems">Bare Metal, RTOS, and Embedded Operating Systems</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-designing-drivers-for-embedded-systems">Designing Drivers for Embedded Systems</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-security-in-embedded-systems">Security in Embedded Systems</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-debugging-and-forensics-in-embedded-systems">Debugging and Forensics in Embedded Systems</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-automation-and-testing-in-embedded-systems">Automation and Testing in Embedded Systems</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-where-to-go-from-here">Where to Go from Here</a></p>
</li>
</ul>
<p>This article offers a broad overview of embedded firmware development, but it doesn’t cover every aspect, particularly advanced software architecture frameworks or comprehensive lists of open source software and tools. Where appropriate, I have included external resources that were valuable in expanding my own understanding.</p>
<h3 id="heading-prerequisites">Prerequisites</h3>
<p>You don’t need to be an expert to follow this guide, but some prior knowledge will help you get the most out of it:</p>
<ul>
<li><p>Basic C or C++ programming**:** Familiarity with functions, pointers, and memory concepts is helpful.</p>
</li>
<li><p>Computer architecture fundamentals**:** Understanding what a CPU does, how memory works, and basic instruction execution will make embedded concepts clearer.</p>
</li>
<li><p>Electronics basics (optional)<strong>:</strong> Knowing how sensors, resistors, or microcontrollers interact at a circuit level is useful but not mandatory.</p>
</li>
<li><p>Comfort with the command line**:** Especially for working with build systems, compilers, and flashing tools.</p>
</li>
</ul>
<p>This guide is ideal for students, engineers, or hobbyists looking to deepen their understanding of how software interacts with hardware in real-world systems.</p>
<p>With that, let’s start from the ground up, hardware. Throughout this guide, most examples will reference ARM Cortex-M microcontrollers, as they are among the most commonly used in the embedded world.</p>
<h2 id="heading-hw-layer-microcontroller">HW Layer: Microcontroller</h2>
<p>One of the most important knowledge blocks in embedded firmware development is understanding how a microcontroller (MCU) works and how it connects to sensors, actuators, and other microcontrollers.</p>
<p>If you’re familiar with basic computer architecture (like instruction sets and memory organization), that knowledge translates well to embedded systems. In fact, Computer System Organization, often taught in computer science and electrical engineering programs, is a great foundation for understanding microcontrollers.</p>
<h3 id="heading-what-is-a-microcontroller">What is a Microcontroller?</h3>
<p>A microcontroller is a compact computing unit that includes:</p>
<ul>
<li><p>A CPU (Central Processing Unit or Microprocessor)</p>
</li>
<li><p>Memory (Flash and RAM)</p>
</li>
<li><p>Peripherals (for I/O, timers, communication, and so on)</p>
</li>
</ul>
<p>In essence, it's a tiny computer-on-a-chip, optimized for specific control tasks like reading sensors or driving motors.</p>
<p>By contrast, a microprocessor is just the CPU. It requires external memory and peripherals to function. Microcontrollers are self-contained and better suited for embedded applications.</p>
<p>For example, this <a target="_blank" href="https://www.st.com/resource/en/reference_manual/dm00031020-stm32f405-415-stm32f407-417-stm32f427-437-and-stm32f429-439-advanced-arm-based-32-bit-mcus-stmicroelectronics.pdf">reference manual</a> for the STM32F4 series (from STMicroelectronics) provides detailed documentation on not just the CPU but each peripheral’s functionality and the register map.</p>
<h3 id="heading-instruction-set-architecture-isa">Instruction Set Architecture (ISA)</h3>
<p>A microprocessor executes a series of instructions defined by its Instruction Set Architecture (ISA). ISA as defined by <a target="_blank" href="https://www.arm.com/glossary/isa">ARM</a> is a part of the abstract model of a computer that defines how the CPU is controlled by the software. The ISA acts as an interface between the hardware and the software, specifying both what the processor is capable of doing as well as how it gets done.</p>
<p>For example:</p>
<ul>
<li><p>ARMv7 – used in ARM Cortex-M3.</p>
</li>
<li><p>ARMv7E – used in Cortex-M4 and M7.</p>
</li>
</ul>
<p>Many vendors (for example, STMicroelectronics, NXP, TI) manufacture MCUs that support ARM ISAs but include their own peripheral sets. Understanding the ISA is essential for low-level coding and interpreting assembly instructions.</p>
<p>This <a target="_blank" href="https://developer.arm.com/documentation/ddi0403/ee/?lang=en">ARMv7-M architecture reference manual</a> provides more details on v7 Architecture.</p>
<h3 id="heading-memory-in-microcontrollers">Memory in Microcontrollers</h3>
<p>Most microcontrollers typically feature two types of memory:</p>
<ul>
<li><p><strong>Flash</strong> – Stores your code and read-only data.</p>
</li>
<li><p><strong>RAM</strong> – Used during program execution to hold:</p>
<ul>
<li><p>The heap (for dynamic memory)</p>
</li>
<li><p>The stack</p>
</li>
<li><p>The .data and .bss sections (initialized/uninitialized global/static variables)</p>
</li>
</ul>
</li>
</ul>
<p>Later sections have resources that go deeper into memory mapping and how these regions interact during runtime.</p>
<h3 id="heading-clock-and-power-management">Clock and Power Management</h3>
<p>Microcontrollers are digital logic devices built from:</p>
<ul>
<li><p>Combinatorial logic – Logic gates that evaluate outputs instantly</p>
</li>
<li><p>Sequential logic – Relies on clocks to move through states</p>
</li>
</ul>
<p>The clock tree distributes timing signals across the CPU and peripherals. MCUs often support multiple clock sources (internal RC, external crystal, PLL), and use prescalers to drive components at different frequencies.</p>
<p>For power-sensitive applications, MCUs offer multiple low-power modes:</p>
<ul>
<li><p>Sleep – CPU off, timers and peripherals are mostly active, memory is retained</p>
</li>
<li><p>Deep Sleep – CPU off, most clocks off, memory is retained, wake-up is slower than sleep, power consumption is lower than Sleep</p>
</li>
<li><p>Standby – CPU off, few interrupts are active, everything else is powered down, memory is not retained. Lowest power mode.</p>
</li>
</ul>
<p>These modes reduce power consumption by turning off clocks and disabling unused peripherals. Designing the system to switch in and out of low-power states effectively is a core skill in embedded software development.</p>
<p>This article talks about <a target="_blank" href="https://www.playembedded.org/blog/arm-cortex-clock-tree-101/">Clock Trees and Oscillators</a> for the ARM Cortex microcontrollers.</p>
<h3 id="heading-interrupts">Interrupts</h3>
<p>Interrupts let MCUs react to asynchronous events, like button presses or sensor signals.</p>
<p>An interrupt temporarily pauses normal code execution to run a dedicated handler. After it’s serviced, the CPU resumes its previous task. They are vital for:</p>
<ul>
<li><p>Fast event response</p>
</li>
<li><p>Reduced polling</p>
</li>
<li><p>Efficient power use (for example, waking from sleep)</p>
</li>
</ul>
<h3 id="heading-timers">Timers</h3>
<p>Timers are built-in peripherals used to track time or generate events.</p>
<p>Common uses are:</p>
<ul>
<li><p>Implementing software delays</p>
</li>
<li><p>Creating precise software timers</p>
</li>
<li><p>Waking up from low-power modes</p>
</li>
</ul>
<p>Mastering timers helps with real-time behavior and precise event scheduling.</p>
<h3 id="heading-communication-protocols">Communication Protocols</h3>
<p>Microcontrollers often need to talk to other devices via built-in communication peripherals:</p>
<ul>
<li><p><strong>UART (Universal Asynchronous Receiver/Transmitter):</strong> Serial communication between two devices, great for logs and debugging.</p>
</li>
<li><p><strong>I²C (Inter-Integrated Circuit):</strong> Two wire protocol for talking to sensors and EEPROMs.</p>
</li>
<li><p><strong>SPI (Serial Peripheral Interface):</strong> High Speed, full-duplex protocol for devices like Flash or displays.</p>
</li>
<li><p><strong>USB (Universal Serial Bus):</strong> Complex but widely used for PCs, data acquisition and HID devices.</p>
</li>
</ul>
<p>Here’s a figure showing multiple peripherals connected to a MCU:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750017729550/799b8649-bb39-4d5d-a309-9c3b76898eb8.png" alt="A MCU that is connected to Flash over SPI, connected to another MCU2 over UART, connected to Temperature Sensor over I2C and connected to Host Computer over USB. This picture shows how multiple peripherals are connected to a Host Computer" class="image--center mx-auto" width="2068" height="786" loading="lazy"></p>
<p>DMA or Direct Memory Access is an important peripheral which can be used to transfer data to/from memory without CPU involvement. It improves performance and allows the CPU to perform other tasks or enter low power mode to reduce power consumption.</p>
<p>This <a target="_blank" href="https://www.parlezvoustech.com/en/comparaison-protocoles-communication-i2c-spi-uart/">article</a> provides a good overview of the communication protocols I2C, UART and SPI.</p>
<p>We’ve now covered the essential building blocks of microcontroller hardware – from memory and clocks to interrupts and communication buses.</p>
<p>Next, we’ll explore the software principles and tools that bring these microcontrollers to life, including compilers, debuggers, and embedded development frameworks.</p>
<h2 id="heading-firmware-design-and-tools">Firmware Design and Tools</h2>
<h3 id="heading-designing-embedded-software">Designing Embedded Software</h3>
<p>Even though embedded systems operate under unique hardware constraints, software design principles are still crucial. Applying them thoughtfully becomes even more important when memory, CPU cycles, and responsiveness are limited.</p>
<p>Most Embedded firmware projects begin with a structured design approach:</p>
<ol>
<li><p>Understand the problem statement</p>
</li>
<li><p>List assumptions</p>
</li>
<li><p>Define use cases</p>
</li>
<li><p>Define system and software requirements</p>
</li>
<li><p>Create high-level architecture</p>
</li>
<li><p>Drill down to detailed design and implementation</p>
</li>
</ol>
<p>If you’re new to software design, check out my <a target="_blank" href="https://www.freecodecamp.org/news/learn-software-design-basics/">article</a> on design principles.</p>
<p>Here’s a figure showing the five blocks of software design:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750557879213/eab45a1f-ec1a-4c3d-81ce-c67365a451d4.png" alt="Blocks of software design: Problem statement describes the problem, Use cases describe the use case for which the problem statement is valid, then comes collecting the requirements, creating the architecture and the final design  " class="image--center mx-auto" width="1880" height="326" loading="lazy"></p>
<h3 id="heading-using-design-patterns">Using Design Patterns</h3>
<p>Once you're designing individual components, design patterns help you write scalable and maintainable code. Here are some common patterns in embedded systems:</p>
<ul>
<li><p>Publisher-Subscriber (Observer) – Useful for decoupling event producers and consumers (for example, sensor data being broadcast to multiple modules).</p>
</li>
<li><p>Singleton – Ensures only one instance of a module or resource manager exists (for example, for drivers or HAL layers).</p>
</li>
<li><p>Adapter – Translates between incompatible interfaces (for example, wrapping platform-specific code into a portable application layer).</p>
</li>
<li><p>State Machine – Represents system behavior as transitions between states (for example, Bluetooth states: <code>IDLE → SCANNING → CONNECTING → CONNECTED → DISCONNECTED</code>).</p>
</li>
</ul>
<p>Design patterns often need to be adapted for memory and timing constraints, but the core concepts remain highly relevant.</p>
<p>There are lot of great resources on design patterns – here are a few that helped me:</p>
<ol>
<li><p>Book: <a target="_blank" href="https://www.amazon.com/Head-First-Design-Patterns-Object-Oriented/dp/149207800X/">Head-first Design patterns</a> - A great book to get understand the concept of design patterns</p>
</li>
<li><p>Book: <a target="_blank" href="https://www.amazon.com/Design-Patterns-Elements-Reusable-Object-Oriented/dp/0201633612/">Design Patterns: Elements of Reusable Object-Oriented Software</a></p>
</li>
<li><p>Course: <a target="_blank" href="https://www.freecodecamp.org/news/master-object-oriented-programming-and-design-patterns-in-c/">Object-Oriented Programming and Design Patterns in C#</a></p>
</li>
<li><p>Article on HSM: <a target="_blank" href="https://barrgroup.com/blog/introduction-hierarchical-state-machines">Hierarchical State Machine Overview (Barr Group)</a></p>
</li>
</ol>
<h3 id="heading-programming-languages-for-embedded-systems">Programming Languages for Embedded Systems</h3>
<p>While any language can theoretically be used if it compiles to machine code, in practice, three dominate the embedded world:</p>
<ul>
<li><p>C – The industry standard. Provides deterministic behavior and low-level access, making it ideal for memory and timing-sensitive code.</p>
</li>
<li><p>C++ – Adds object-oriented features while maintaining control. Once considered risky in embedded due to synthesized code and overhead, it’s now widely adopted where systems benefit from abstraction and modularity.</p>
</li>
<li><p>Rust – A memory-safe alternative gaining traction in safety-critical and open-source embedded development.</p>
</li>
</ul>
<p>Languages like Python (via MicroPython or CircuitPython) are used in educational or prototyping contexts but are not suitable for production due to performance and memory overhead.</p>
<p>Some resources on programming languages that might be helpful to understand concepts:</p>
<ol>
<li><p><a target="_blank" href="https://docs.rust-embedded.org/book/">The Embedded Rust Book</a></p>
</li>
<li><p><a target="_blank" href="https://www.freecodecamp.org/news/learn-c-programming-classic-book-dr-chuck/">C Programming Language by K&amp;R</a></p>
</li>
<li><p><a target="_blank" href="https://www.google.com/aclk?sa=L&amp;ai=DChcSEwi31JG8pvSNAxUpFa0GHX8lIoEYABAHGgJwdg&amp;co=1&amp;gclid=CjwKCAjw3rnCBhBxEiwArN0QE9cC5kuS7nAxauOzmDpkIoD63W3Ki8X0sTYfsUfrr8HYOdmqQQG5MBoCty4QAvD_BwE&amp;cce=1&amp;sig=AOD64_2a4D154E-aGKmSJlj_yP-RUq3HkQ&amp;ctype=5&amp;q=&amp;ved=2ahUKEwj_l428pvSNAxWaEzQIHb4eN3cQ9aACKAB6BAgLEA8&amp;adurl=">Inside the C++ Object model</a> – There are a lot of books and lectures on C++, but for embedded, understanding the object model benefits a lot.</p>
</li>
</ol>
<h3 id="heading-data-structures-matter">Data Structures Matter</h3>
<p>Embedded systems require careful data handling due to strict memory and timing constraints. Mastering core data structures is essential:</p>
<ul>
<li><p>Arrays – fixed-size data.</p>
</li>
<li><p>Linked Lists – Common in software timers, queues.</p>
</li>
<li><p>Stacks and Queues – Task scheduling, event management and data storage.</p>
</li>
<li><p>Bitfields/Flags – Memory efficient state representation.</p>
</li>
<li><p>Binary Trees – Used in routing tables or decision logic.</p>
</li>
</ul>
<p>You'll often build event queues, circular buffers, or timer lists, all of which rely on these foundational structures.</p>
<p>There are a lot of resources for understanding data structures, but I have found this one to be helpful for learning and practicing: <a target="_blank" href="https://www.geeksforgeeks.org/dsa/dsa-tutorial-learn-data-structures-and-algorithms/">GeeksForGeeks DSA Tutorial</a>. And <a target="_blank" href="https://www.freecodecamp.org/news/learn-data-structures-and-algorithms-2/">here’s a full course on DSA</a> if you want to dive deeper.</p>
<h3 id="heading-bit-manipulation-a-core-embedded-skill">Bit Manipulation: A Core Embedded Skill</h3>
<p>Unlike general-purpose software, embedded systems often require low-level access to registers and require precise bit control:</p>
<ul>
<li><p>Setting and clearing individual bits</p>
</li>
<li><p>Using bitwise operators like <code>AND (&amp;)</code>, <code>OR (|)</code>, <code>XOR (^)</code></p>
</li>
<li><p>Bit masking and shifting (<code>&lt;&lt;</code>, <code>&gt;&gt;</code>)</p>
</li>
</ul>
<p>Mastering bit hacks is essential for writing hardware drivers or manipulating control registers.</p>
<p>This resource provides a good number of examples for bit manipulation: <a target="_blank" href="https://graphics.stanford.edu/~seander/bithacks.html">Stanford Bit Hacks</a>.</p>
<h2 id="heading-tools-and-concepts-for-embedded-development">Tools and Concepts for Embedded Development</h2>
<h3 id="heading-cross-compilation">Cross Compilation</h3>
<p>Embedded code is compiled on a host (like your PC) for a target architecture using cross-compilers.</p>
<p>To do this, you need:</p>
<ul>
<li><p>A compiler (for example, <code>arm-none-eabi-gcc</code> for ARM Cortex-M) that compiles high level language code into Assembly language instructions.</p>
</li>
<li><p>A linker to layout and combine object files.</p>
</li>
<li><p>A Makefile or build system to organize and automate compilation, linking and binary creation.</p>
</li>
</ul>
<p>Here’s an example to compile a main.c to create a main.elf that can be flashed on the device:</p>
<pre><code class="lang-plaintext">arm-none-eabi-gcc main.c -o main.elf
</code></pre>
<p>A Makefile is a script used by the <code>make</code> build automation tool to compile and link programs to create a binary. It defines how to build your program from source files, manages compilation order based on dependencies and defines commands to complete the build.</p>
<p>For example, lets write a Makefile for building a project for an ARM Cortex-M4 target that has three source files: a main.c, utils.c, and sensor.c</p>
<pre><code class="lang-makefile">CC = arm-none-eabi-gcc
CFLAGS = -c -mcpu=cortex-m4 -mthumb -Wall -O2
LDFLAGS = -mcpu=cortex-m4 -mthumb
TARGET = main.elf
OBJS = main.o utils.o sensor.o
SRC = main.c utils.c sensor.c

<span class="hljs-variable">$(TARGET)</span>: <span class="hljs-variable">$(OBJS)</span>
    <span class="hljs-variable">$(CC)</span> <span class="hljs-variable">$(OBJS)</span> -o <span class="hljs-variable">$(TARGET)</span>

<span class="hljs-section">main.o: main.c</span>
    <span class="hljs-variable">$(CC)</span> <span class="hljs-variable">$(CFLAGS)</span> main.c

<span class="hljs-section">utils.o: utils.c</span>
    <span class="hljs-variable">$(CC)</span> <span class="hljs-variable">$(CFLAGS)</span> utils.c

<span class="hljs-section">sensor.o: sensor.c</span>
    <span class="hljs-variable">$(CC)</span> <span class="hljs-variable">$(CFLAGS)</span> sensor.c

<span class="hljs-section">clean:</span>
    rm -f *.o *.elf
</code></pre>
<p>In the above makefile, here’s a description of the flags:</p>
<ul>
<li><p><code>-mcpu=cortex-m4</code>: Targets the ARM Cortex-M4 processor.</p>
</li>
<li><p><code>-mthumb</code>: Enables Thumb instruction set, which is used by ARM Cortex-M series.</p>
</li>
<li><p><code>-Wall</code>: Enables all common warnings.</p>
</li>
<li><p><code>-O2</code>: Optimization level 2 for balance between performance and code size.</p>
</li>
</ul>
<p>Makefiles can seem intimidating, but they’re just scripts that define how to build your program from source. Once you understand the basics, they’re a huge productivity booster.</p>
<p>A linker script tells the linker (<code>ld</code>) how to organize the program in memory where to place code, data, stack, heap, and so on. It's crucial for embedded systems because you're working with limited memory and specific memory-mapped hardware.</p>
<p>Here’s an example of a simple linker script for a STM32F4 microcontroller:</p>
<pre><code class="lang-makefile">/* STM32F4 Cortex‑M4 Simple Linker Script */

ENTRY(Reset_Handler)

/* Define memory regions based on STM32F4 datasheet */
MEMORY
{
  FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1024K
  RAM   (rwx): ORIGIN = 0x20000000, LENGTH = 128K
}

/* Section layout */
SECTIONS
{
  /* Interrupt vectors and code go into Flash */
  .isr_vector :
  {
    KEEP(*(.isr_vector))    /* Keep vector table (reset, etc.) */
  } &gt; FLASH

  .text :
  {
    *(.text*)               /* All code */
    *(.rodata*)             /* Read-only data */
    . = ALIGN(4)
    _etext = .             /* End of code (used for data init) */
  } &gt; FLASH

  /* Initialized data: load from Flash, run in RAM */
  .data : AT(_etext)
  {
    _sdata = .            /* Start of .data in RAM */
    *(.data*)
    . = ALIGN(4)
    _edata = .            /* End of .data */
  } &gt; RAM

  /* Uninitialized data (zero-filled) */
  .bss :
  {
    _sbss = .
    *(.bss*)
    *(COMMON)
    . = ALIGN(4)
    _ebss = .
  } &gt; RAM

  /* Define stack end (top of RAM) */
  _estack = ORIGIN(RAM) + LENGTH(RAM);
}
</code></pre>
<p>Descriptions of the above file:</p>
<ul>
<li><p>MEMORY: Defines your microcontroller’s memory layout – 1 MB Flash and 128 KB SRAM.</p>
</li>
<li><p>ENTRY(Reset_Handler): Sets the reset handler as the program entry point.</p>
</li>
<li><p>.isr_vector and **.**text: Code sections placed in Flash. <code>.isr_vector</code> must use <code>KEEP()</code> so it's not removed during linking.</p>
</li>
<li><p>.data : AT(_etext): Loads initialized variables from Flash but places them in RAM.</p>
</li>
<li><p>**.**bss: Zero-initialized data, allocated in RAM</p>
</li>
<li><p>_estack: Defines the initial stack pointer using the end of RAM.</p>
</li>
</ul>
<p>Here are some sources to understand Makefiles, cross-compilation, and Linkers. And just note that using Makefile in a project is the best way to learn and master Makefiles:</p>
<ol>
<li><p>Makefiles:</p>
<ul>
<li><p><a target="_blank" href="https://www.gnu.org/software/make/manual/make.pdf">GNU Make Manual</a></p>
</li>
<li><p><a target="_blank" href="https://makefiletutorial.com/">Makefile Tutorial</a></p>
</li>
<li><p><a target="_blank" href="https://www.gnu.org/software/make/manual/make.pdf">In Pyjama</a> <a target="_blank" href="https://inpyjama.com/post/makefile-2/">M</a><a target="_blank" href="https://makefiletutorial.com/">akef</a><a target="_blank" href="https://www.gnu.org/software/make/manual/make.pdf">ile Article</a></p>
</li>
</ul>
</li>
<li><p>Linker Scripts:</p>
<ul>
<li><p><a target="_blank" href="https://interrupt.memfault.com/blog/how-to-write-linker-scripts-for-firmware">Interrupt Blog on Linker Scripts</a></p>
</li>
<li><p><a target="_blank" href="https://medium.com/%40pc0is0me/an-introduction-to-linker-file-59ce2e9c5e73">Intro to Linker Files – Medium</a></p>
</li>
</ul>
</li>
</ol>
<h3 id="heading-flashing-the-binary">Flashing the Binary</h3>
<p>Once you’ve compiled your code into a binary file, the next step is to <strong>flash</strong> it into the target microcontroller’s non-volatile memory via <strong>SWD</strong> (Serial Wire Debug) or <strong>JTAG</strong>. Flashing tools like OpenOCD, ST-Link, J-Link, or vendor-specific utilities manage this process.</p>
<h4 id="heading-what-is-flashing">What Is Flashing?</h4>
<p>Flashing is the process of writing a compiled firmware image (typically a <code>.bin</code> or <code>.hex</code> file) into the microcontroller’s Flash memory. This enables the embedded system to retain and run your code even after power is removed.</p>
<p>The flashing tool communicates with the microcontroller over SWD or JTAG to:</p>
<ul>
<li><p>Halt the MCU (if needed)</p>
</li>
<li><p>Access the internal flash controller</p>
</li>
<li><p>Erase the relevant flash sectors</p>
</li>
<li><p>Write the binary data to specific memory addresses</p>
</li>
<li><p>Verify that the data was written correctly</p>
</li>
</ul>
<p>OpenOCD (Open On-Chip Debugger) is a powerful, open-source utility that facilitates debugging and flashing of ARM-based microcontrollers. It supports a wide variety of hardware interfaces and microcontroller families, including STM32.</p>
<p>OpenOCD provides:</p>
<ul>
<li><p>Flashing capabilities for <code>.elf</code>, <code>.bin</code>, and <code>.hex</code> files</p>
</li>
<li><p>Debugging via GDB (GNU’s open source debugger) integration</p>
</li>
<li><p>Support for multiple debug probes (J-Link, ST-Link, CMSIS-DAP)</p>
</li>
<li><p>Scripting via configuration files for board-specific and target-specific setups</p>
</li>
</ul>
<p>A simple command to flash a binary using OpenOCD might look like this:</p>
<pre><code class="lang-makefile">bashCopyEditopenocd -f interface/stlink.cfg -f target/stm32f4x.cfg -c <span class="hljs-string">"program main.elf verify reset exit"</span>
</code></pre>
<p>This tells OpenOCD to:</p>
<ul>
<li><p>Use the ST-Link interface</p>
</li>
<li><p>Load the STM32F4 target configuration</p>
</li>
<li><p>Program <code>main.elf</code> into flash</p>
</li>
<li><p>Verify it was written correctly</p>
</li>
<li><p>Reset the MCU</p>
</li>
<li><p>Exit the session</p>
</li>
</ul>
<p>For a detailed walkthrough, check out: <a target="_blank" href="https://kickstartembedded.com/2024/03/26/openocd-one-software-to-rule-debug-them-all/">OpenOCD Deep Dive – Kickstart Embedded</a></p>
<h2 id="heading-bare-metal-rtos-and-embedded-operating-systems">Bare Metal, RTOS, and Embedded Operating Systems</h2>
<p>When writing embedded software, you can approach the problem in three main ways, each with its own trade-offs:</p>
<ol>
<li><p>Bare-Metal Programming</p>
</li>
<li><p>Real-Time Operating Systems (RTOS) (like FreeRTOS, Zephyr)</p>
</li>
<li><p>Embedded Operating Systems (like Embedded Linux)</p>
</li>
</ol>
<p>The best choice depends on your use case, application’s complexity, hardware constraints, and real-time needs.</p>
<p>Most Modern 32-bit microcontrollers (for example, STM32, NXP, Renesas) come with vendor-provided development tools that include:</p>
<ul>
<li><p>HAL (Hardware Abstraction Layer) libraries</p>
</li>
<li><p>Startup code and linker scripts</p>
</li>
<li><p>Peripheral drivers</p>
</li>
<li><p>Sometimes even middleware like USB, BLE, or file system stacks</p>
</li>
</ul>
<p>These tools (like <a target="_blank" href="https://www.st.com/en/ecosystems/stm32cube.html">STM32Cube</a> Config Tools) simplify setup and peripheral configuration, helping you get started quickly, without needing to write low-level code manually.</p>
<p><strong>Benefits of HALs</strong>:</p>
<ul>
<li><p>Rapid prototyping and development</p>
</li>
<li><p>Clean, reusable APIs for peripherals</p>
</li>
<li><p>Great for onboarding and small teams</p>
</li>
</ul>
<p><strong>Drawbacks</strong>:</p>
<ul>
<li><p>Code bloat – HALs support many edge cases and configurations, which can inflate your binary size</p>
</li>
<li><p>Extra latency – HAL often inserts unnecessary layers that reduce performance.</p>
</li>
</ul>
<p>For performance-critical systems, developers often replace HAL drivers with custom, low-level implementations.</p>
<h3 id="heading-bare-metal-programming">Bare-Metal Programming</h3>
<p>Bare-metal programming is the most direct and lightweight approach. There’s no OS, and your code runs directly on the hardware with full control.</p>
<p>Typical setup includes:</p>
<ul>
<li><p>Include the correct header files, especially MCU and peripheral-specific headers provided by the vendor’s HAL (Hardware Abstraction Layer).</p>
</li>
<li><p>Implement a <code>main()</code> function with an infinite loop (<code>while(1)</code>)</p>
</li>
<li><p>Perform all hardware initialization before entering the loop</p>
</li>
<li><p>Use Interrupts to handle asynchronous events.</p>
</li>
<li><p>Continuously check and control inputs/outputs inside the loop</p>
</li>
</ul>
<p>This assumes your toolchain provides startup code and memory setup from the vendor.</p>
<pre><code class="lang-c"><span class="hljs-meta">#<span class="hljs-meta-keyword">include</span> <span class="hljs-meta-string">"MCU_Header.h"</span></span>

<span class="hljs-function"><span class="hljs-keyword">int</span> <span class="hljs-title">main</span><span class="hljs-params">(<span class="hljs-keyword">void</span>)</span> </span>{
    <span class="hljs-comment">/* Initialize the MCU and the peripherals */</span>
    init_clock();
    init_peripherals();

    <span class="hljs-comment">/* runs in a loop forever */</span>
    <span class="hljs-keyword">while</span> (<span class="hljs-number">1</span>) {
        <span class="hljs-comment">// Task 1 : Read sensor data</span>
        read_sensor(); 
        <span class="hljs-comment">// Task 2 : Update the actuator based on the sensor data</span>
        update_actuator(); 
    }
}
</code></pre>
<h4 id="heading-how-does-it-run">How does it run?</h4>
<p>When the device powers on or resets, the startup code provided by the vendor is executed first. This code:</p>
<ul>
<li><p>Initializes the reset vector</p>
</li>
<li><p>Copies initialized data from Flash to RAM</p>
</li>
<li><p>Zeros out the <code>.bss</code> section (for uninitialized global/static variables)</p>
</li>
<li><p>Calls your <code>main()</code> function</p>
</li>
</ul>
<p>After calling <code>main()</code>, the system enters an infinite loop where your logic runs. The only other context switch occurs when an interrupt is triggered, briefly diverting control to an Interrupt Service Routine (ISR), after which it returns to the main loop.</p>
<p><strong>When to use it</strong>:</p>
<ul>
<li><p>Simpler applications (for example, blinking LEDs, reading sensors)</p>
</li>
<li><p>Ultra-low-power or ultra-low-latency needs</p>
</li>
<li><p>When every byte of Flash and RAM matters</p>
</li>
</ul>
<p><strong>Pros</strong>:</p>
<ul>
<li><p>Minimal memory usage</p>
</li>
<li><p>Maximum control</p>
</li>
<li><p>Great for learning</p>
</li>
</ul>
<p><strong>Cons</strong>:</p>
<ul>
<li><p>No built-in task management or scheduling</p>
</li>
<li><p>Can become hard to maintain for complex systems</p>
</li>
</ul>
<p>This resource provides good details and example on <a target="_blank" href="https://github.com/cpq/bare-metal-programming-guide">Bare Metal Programming</a>. For more details, this book is great as well: <a target="_blank" href="https://umanovskis.se/files/arm-baremetal-ebook.pdf">ARM Baremetal Ebook</a>.</p>
<h3 id="heading-real-time-operating-systems-rtos">Real-Time Operating Systems (RTOS)</h3>
<p>A Real-Time Operating System (like <a target="_blank" href="https://www.freertos.org/Documentation/01-FreeRTOS-quick-start/01-Beginners-guide/00-Overview">FreeRTOS</a>, <a target="_blank" href="https://docs.zephyrproject.org/latest/">Zephyr</a>) adds lightweight multitasking capabilities to your embedded application. It allows you to split your software into independent tasks that run concurrently and communicate through queues, semaphores, or message passing.</p>
<p>RTOS kernels often support different scheduling strategies like:</p>
<ul>
<li><p>Rate Monotonic Scheduling (RMS) – Tasks with shorter periods get higher priority</p>
</li>
<li><p>Earliest Deadline First (EDF) – Tasks are prioritized based on impending deadlines</p>
</li>
</ul>
<p><strong>Example use cases</strong>:</p>
<ul>
<li><p>A drone where sensor data, motor control, and telemetry need to run in parallel</p>
</li>
<li><p>A medical device where timing is critical for safety</p>
</li>
<li><p>Rockets</p>
</li>
</ul>
<p><strong>Typical RTOS features</strong>:</p>
<ul>
<li><p>Task scheduling</p>
</li>
<li><p>Timers</p>
</li>
<li><p>Inter-task communication</p>
</li>
<li><p>Interrupt handling integration</p>
</li>
<li><p>Power management</p>
</li>
</ul>
<p><strong>Pros</strong>:</p>
<ul>
<li><p>Modular code structure with tasks</p>
</li>
<li><p>Easier to scale as complexity grows</p>
</li>
<li><p>Deterministic execution (when configured correctly)</p>
</li>
</ul>
<p><strong>Cons</strong>:</p>
<ul>
<li><p>Slightly higher memory footprint than bare-metal</p>
</li>
<li><p>Learning curve for scheduling and priority tuning</p>
</li>
</ul>
<p>RTOS Scheduling techniques are interesting – this part of the docs talks about <a target="_blank" href="https://docs.zephyrproject.org/latest/kernel/services/scheduling/index.html#scheduling-algorithm">Zephyr</a> scheduling.</p>
<h3 id="heading-embedded-operating-systems">Embedded Operating Systems</h3>
<p>Sometimes an embedded system is powerful enough to run a full-fledged OS like Embedded Linux, Android Things, or Windows IoT Core. This is common on devices with a display, networking stack, or file system.</p>
<p>It’s best used when the system requires multitasking, user interfaces, file systems, or network stacks, and when there’s plenty of processing power (for example, ARM Cortex-A).</p>
<p>Think of:</p>
<ul>
<li><p>Smart home hubs</p>
</li>
<li><p>Automotive infotainment</p>
</li>
<li><p>Industrial gateways</p>
</li>
</ul>
<p>This table provides a high level methodology for choosing the right type of OS based on your application:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td><strong>Criteria</strong></td><td><strong>Bare Metal</strong></td><td><strong>RTOS</strong></td><td><strong>Embedded OS</strong></td></tr>
</thead>
<tbody>
<tr>
<td><strong>System</strong> <strong>Complexity</strong></td><td>Low</td><td>Medium</td><td>High</td></tr>
<tr>
<td><strong>Memory</strong> <strong>Footprint</strong></td><td>Very Low</td><td>Moderate</td><td>High</td></tr>
<tr>
<td><strong>Real-Time Guarantees</strong></td><td>Limited</td><td>Yes</td><td>Depends on Kernel Design</td></tr>
<tr>
<td><strong>Learning Curve</strong></td><td>Steep for scaling</td><td>Moderate</td><td>Steeper (OS internals, tools)</td></tr>
<tr>
<td><strong>Use Case Examples</strong></td><td>Blinking LED, sensor polling</td><td>Drones, medical devices</td><td>Gateways, touchscreens</td></tr>
</tbody>
</table>
</div><p>To understand OS fundamentals, this is a great book: <a target="_blank" href="https://www.amazon.com/Operating-System-Concepts-Abraham-Silberschatz/dp/0470128720">Operating System Concepts</a> and this is a great course: <a target="_blank" href="https://www.youtube.com/playlist?list=PLF2K2xZjNEf97A_uBCwEl61sdxWVP7VWC">UC Berkeley: CS162</a>.</p>
<p>So far, we’ve looked at how embedded applications are structured, whether using bare-metal loops, RTOS multitasking, or full operating systems. But regardless of which execution model you choose, your software ultimately needs to interact with the hardware.</p>
<p>This is where driver development comes in. Drivers form the crucial link between your code and the peripherals it controls, whether it's reading temperature, blinking an LED, or transmitting data over SPI. Let’s take a closer look at how to design robust, portable drivers for embedded systems.</p>
<h2 id="heading-designing-drivers-for-embedded-systems">Designing Drivers for Embedded Systems</h2>
<p>When working with embedded software, one of the most practical and common tasks you’ll encounter is driver development.</p>
<p>A driver is a piece of software that enables the microcontroller (MCU) to interface with a hardware peripheral. This could be a temperature sensor, a motor controller, a display, or even a wireless module.</p>
<p>Drivers act as a bridge between your hardware and the application logic. They abstract away the raw register-level programming so that higher-level code can use clear function calls like <code>read_temperature()</code> or <code>start_motor()</code>.</p>
<h3 id="heading-what-goes-into-a-driver">What Goes Into a Driver?</h3>
<p>A typical embedded driver will include:</p>
<ul>
<li><p>Configuration – Setting up the peripheral with initial parameters (for example, baud rate for UART)</p>
</li>
<li><p>Initialization – Preparing the peripheral for use, including enabling clocks and interrupts</p>
</li>
<li><p>Calibration (if needed) – Adjusting the peripheral based on specific environment or use case</p>
</li>
<li><p>Register Access – Reading from and writing to hardware registers (if applicable)</p>
</li>
<li><p>Power Management – Enabling/disabling the peripheral to save power or putting the peripheral into a low power mode</p>
</li>
<li><p>Interrupt Management – Handling asynchronous events triggered by the peripheral</p>
</li>
</ul>
<p>Here’s a simplified view of a sensor driver API:</p>
<pre><code class="lang-c"><span class="hljs-function"><span class="hljs-keyword">void</span> <span class="hljs-title">sensor_init</span><span class="hljs-params">(<span class="hljs-keyword">void</span>)</span></span>;
<span class="hljs-function"><span class="hljs-keyword">void</span> <span class="hljs-title">sensor_calibrate</span><span class="hljs-params">(<span class="hljs-keyword">void</span>)</span></span>;
<span class="hljs-function"><span class="hljs-keyword">float</span> <span class="hljs-title">sensor_read_temperature</span><span class="hljs-params">(<span class="hljs-keyword">void</span>)</span></span>;
<span class="hljs-function"><span class="hljs-keyword">void</span> <span class="hljs-title">sensor_sleep</span><span class="hljs-params">(<span class="hljs-keyword">void</span>)</span></span>;
<span class="hljs-function"><span class="hljs-keyword">void</span> <span class="hljs-title">sensor_write</span><span class="hljs-params">(<span class="hljs-keyword">uint8_t</span> reg, <span class="hljs-keyword">uint8_t</span> value)</span></span>; <span class="hljs-comment">// Assumption : 8 bit register address and 8 bit data value</span>
</code></pre>
<p>The actual implementation might involve:</p>
<ul>
<li><p>Register definitions from the peripheral’s datasheet</p>
</li>
<li><p>Bit manipulations for control and status registers</p>
</li>
<li><p>Interrupt Service Routines (ISRs)</p>
</li>
<li><p>Timing and delay management</p>
</li>
</ul>
<h3 id="heading-platform-abstraction-why-it-matters">Platform Abstraction: Why It Matters</h3>
<p>One of the most important principles in driver design is decoupling the application from the platform. This makes your code easier to:</p>
<ul>
<li><p>Port to different MCUs</p>
</li>
<li><p>Adapt for similar hardware (for example, different sensor models)</p>
</li>
<li><p>Test across simulated or real environments</p>
</li>
</ul>
<h4 id="heading-platform-agnostic-design-example-in-c">Platform-Agnostic Design Example (in C++) :</h4>
<p>Let’s say you're writing a driver for a temperature sensor:</p>
<pre><code class="lang-cpp"><span class="hljs-comment">// Abstracts the HW platform on which the sensor driver is being written</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">TemperatureSensorPlatform</span> {</span>
<span class="hljs-keyword">public</span>:
    <span class="hljs-function"><span class="hljs-keyword">void</span> <span class="hljs-title">i2cInit</span><span class="hljs-params">(<span class="hljs-keyword">void</span>)</span></span>;
    <span class="hljs-function"><span class="hljs-keyword">void</span> <span class="hljs-title">i2cWrite</span><span class="hljs-params">(<span class="hljs-keyword">uint8_t</span> reg, <span class="hljs-keyword">uint8_t</span> value)</span></span>;
    <span class="hljs-function"><span class="hljs-keyword">uint8_t</span> <span class="hljs-title">i2cRead</span><span class="hljs-params">(<span class="hljs-keyword">uint8_t</span> reg)</span></span>;
};

<span class="hljs-comment">// Creates a generic Temperature sensor driver interface</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">TemperatureSensor</span> {</span>
<span class="hljs-keyword">public</span>:
    <span class="hljs-function"><span class="hljs-keyword">virtual</span> <span class="hljs-keyword">void</span> <span class="hljs-title">init</span><span class="hljs-params">()</span> </span>= <span class="hljs-number">0</span>;
    <span class="hljs-function"><span class="hljs-keyword">virtual</span> <span class="hljs-keyword">float</span> <span class="hljs-title">read</span><span class="hljs-params">()</span> </span>= <span class="hljs-number">0</span>;
    <span class="hljs-function"><span class="hljs-keyword">virtual</span> <span class="hljs-keyword">void</span> <span class="hljs-title">sleep</span><span class="hljs-params">()</span> </span>= <span class="hljs-number">0</span>;
};
</code></pre>
<p>You can implement this interface differently for a specific type of temperature sensor and also add the platform support for the HW platform you are writing the driver on for example STM32.</p>
<pre><code class="lang-cpp"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">TempSensorTMP117</span> :</span> <span class="hljs-keyword">public</span> TemperatureSensor {
<span class="hljs-keyword">public</span>:

    TempSensorTMP117(TemperatureSensorPlatform platform) : 
    _platform(platform)
    TemperatureSensor()
    {}

    <span class="hljs-function"><span class="hljs-keyword">void</span> <span class="hljs-title">init</span><span class="hljs-params">()</span> <span class="hljs-keyword">override</span> </span>{
        <span class="hljs-comment">// TMP117-specific register configuration</span>
    }

    <span class="hljs-function"><span class="hljs-keyword">float</span> <span class="hljs-title">read</span><span class="hljs-params">()</span> <span class="hljs-keyword">override</span> </span>{
        <span class="hljs-comment">// Read ADC value and convert</span>
        <span class="hljs-keyword">return</span> <span class="hljs-number">25.4f</span>;
    }

    <span class="hljs-function"><span class="hljs-keyword">void</span> <span class="hljs-title">sleep</span><span class="hljs-params">()</span> <span class="hljs-keyword">override</span> </span>{
        <span class="hljs-comment">// Put sensor in low-power mode</span>
    }
<span class="hljs-keyword">private</span>:
    TemperatureSensorPlatform _platform; <span class="hljs-comment">// Implements the I2C driver for STM32</span>
};
</code></pre>
<p>Your application code now depends on the <code>TemperatureSensor</code> interface and Temperature Sensor Platform passed in the constructor making it portable and testable across temperature sensors and HW platforms.</p>
<p>One of my previous <a target="_blank" href="https://www.freecodecamp.org/news/connect-read-process-sensor-data-on-microcontrollers-for-beginners/">articles</a> provides details on how to interface a sensor and how to design a driver for it.</p>
<p>Designing robust and modular drivers helps your firmware interact seamlessly with hardware, but in today’s connected world, that’s only part of the challenge. As embedded devices increasingly communicate with other systems, security becomes just as critical as functionality.</p>
<p>Now that we’ve covered how to interface with hardware, let’s explore how to protect those systems from unauthorized access, tampering, and data breaches.</p>
<h2 id="heading-security-in-embedded-systems">Security in Embedded Systems</h2>
<p>Security is often overlooked in embedded development but it shouldn’t be. Embedded systems are increasingly connected to networks, cloud services, or other devices, which makes them vulnerable to attacks like unauthorized access, firmware tampering, or data leaks.</p>
<p>Even simple devices like smart plugs or fitness trackers can be exploited if their firmware is insecure.</p>
<h3 id="heading-key-security-practices">Key Security Practices</h3>
<ul>
<li><p><strong>Secure Boot:</strong> Ensure the firmware is cryptographically signed and verified before execution. This prevents unauthorized firmware from running.</p>
</li>
<li><p><strong>Firmware Update Integrity:</strong> Use encrypted or signed updates, especially for Over-the-Air (OTA) upgrades. Unprotected updates can be a major attack vector.</p>
</li>
<li><p><strong>Lock Debug Interfaces:</strong> After flashing the final firmware, disable or lock access to JTAG, SWD, or UART debug ports to prevent reverse engineering.</p>
</li>
<li><p><strong>Minimal Exposure:</strong> Disable unused peripherals (for example, Bluetooth, USB, network interfaces) and avoid exposing debug info (like UART prints) in production.</p>
</li>
<li><p><strong>Watchdog Timers:</strong> While not security features per se, watchdogs help ensure system recovery in the event of unexpected software behavior – which could result from attacks or bugs.</p>
</li>
</ul>
<p>Security should be layered, as no single mechanism is sufficient on its own. Build security into every stage of the development process, from boot to communication to update handling.</p>
<p>Whether you're designing a consumer product or an industrial controller, proactive security practices are essential for protecting user data, system reliability, and device reputation.</p>
<p>This resource provides a good understanding of Embedded Systems Security: <a target="_blank" href="https://blackberry.qnx.com/en/ultimate-guides/embedded-system-security">BlackBerry QNX: Embedded System Security Guide</a></p>
<h2 id="heading-debugging-and-forensics-in-embedded-systems">Debugging and Forensics in Embedded Systems</h2>
<p>Debugging embedded systems is one of the most challenging and fascinating aspects of development. Unlike in desktop or web applications, bugs in embedded systems often manifest as unexpected hardware behavior rather than error messages.</p>
<p>For example, suppose your code is supposed to blink an LED once per second:</p>
<ul>
<li><p>If the LED stays on, your delay code might be broken.</p>
</li>
<li><p>If it blinks erratically, you might have a timing bug.</p>
</li>
<li><p>If it doesn’t blink at all, you might never be reaching that part of your code or the hardware might not be configured correctly.</p>
</li>
</ul>
<h3 id="heading-why-debugging-is-critical">Why Debugging is Critical</h3>
<p>Embedded systems directly control real-world hardware, often in critical or safety-sensitive environments. A small bug can lead to large consequences.</p>
<p>Historical Note: During the Apollo 11 moon landing, the onboard computer started throwing alarms due to a task overflow. The system restarted and was able to recover itself and allowing the mission to continue safely.</p>
<p>Debugging and post-mortem analysis (forensics) are essential skills for embedded developers.</p>
<h3 id="heading-common-debugging-tools-and-techniques">Common Debugging Tools and Techniques</h3>
<h4 id="heading-1-print-statements-uart-logging">1. Print Statements (UART Logging)</h4>
<p>The simplest and most common method. They send debug messages over a serial connection (UART).</p>
<p>You can use <code>printf()</code> or similar to track variable values, function entries/exits, and system state</p>
<ul>
<li><p>Pros: Easy to implement</p>
</li>
<li><p>Cons: Can affect timing – not usable if UART is unavailable or disabled</p>
</li>
</ul>
<h4 id="heading-2-trace-variables">2. Trace Variables</h4>
<p>In systems without output peripherals (like UART), you can use trace flags, setting bits in a global variable to indicate code progress.</p>
<pre><code class="lang-c"><span class="hljs-keyword">uint32_t</span> trace_flags = <span class="hljs-number">0</span>;

<span class="hljs-function"><span class="hljs-keyword">void</span> <span class="hljs-title">init_sensor</span><span class="hljs-params">()</span> 
</span>{
    trace_flags |= (<span class="hljs-number">1</span> &lt;&lt; <span class="hljs-number">0</span>); <span class="hljs-comment">// Bit 0: sensor init started</span>
    <span class="hljs-comment">// ...</span>
    trace_flags |= (<span class="hljs-number">1</span> &lt;&lt; <span class="hljs-number">1</span>); <span class="hljs-comment">// Bit 1: sensor init complete</span>
}
</code></pre>
<p>You can then examine <code>trace_flags</code> in memory to track execution flow, even post-mortem. The trace flags can be printed out or dumped via lldb or gdb.</p>
<p><strong>3. Hardware Debugging: JTAG, SWD, and Debuggers</strong></p>
<p>Modern microcontrollers (like ARM Cortex-Ms) support hardware debugging interfaces such as:</p>
<ul>
<li><p>JTAG (Joint Test Action Group)</p>
</li>
<li><p>SWD (Serial Wire Debug)</p>
</li>
</ul>
<p>These allow a debugger to:</p>
<ul>
<li><p>Pause execution</p>
</li>
<li><p>Set breakpoints</p>
</li>
<li><p>Inspect and modify memory</p>
</li>
<li><p>Single-step through code</p>
</li>
</ul>
<p><a target="_blank" href="https://developer.arm.com/documentation/102520/0100">ARM CoreSight</a> is a debug and trace architecture developed by ARM for its processor cores (like Cortex-M, Cortex-A, Cortex-R). It provides a set of hardware modules built into ARM-based chips that allow developers to:</p>
<ul>
<li><p>Debug the system while it's running (non-intrusively)</p>
</li>
<li><p>Trace code execution, memory accesses, and peripheral activity</p>
</li>
<li><p>Analyze system performance and find hard-to-catch bugs</p>
</li>
</ul>
<p>In short: CoreSight lets you look inside your embedded system while it's alive and working, without halting it unnecessarily.</p>
<h3 id="heading-why-coresight-exists">Why CoreSight Exists</h3>
<p>Traditional debugging tools (like breakpoints or single-stepping with JTAG) are often intrusive (they pause the system), limited (can't capture what happened right before a crash), or not suitable for real-time systems.</p>
<p>CoreSight solves these by enabling real-time tracing and non-intrusive observation of what's happening inside the chip.</p>
<h4 id="heading-popular-debug-tools">Popular Debug Tools:</h4>
<ul>
<li><p>ST-Link – HW from STMicrocontrollers</p>
</li>
<li><p>J-Link – Universal debugger supporting a wide range of MCUs</p>
</li>
<li><p>OpenOCD – Open-source interface for hardware debugging</p>
</li>
<li><p>GDB / LLDB – Command-line debuggers used alongside the above</p>
</li>
</ul>
<p>Single-stepping is most effective when compiler optimizations are off. With optimization, code might be reordered, inlined, or even eliminated.</p>
<h3 id="heading-4-using-map-and-disassembly-files">4. Using Map and Disassembly Files</h3>
<p>When debugging complex issues, especially crashes or memory overflows, you'll need to go deeper.</p>
<p>Map Files show the layout of functions and variables in memory (Flash and RAM). They help you locate:</p>
<ul>
<li><p>Stack overflows</p>
</li>
<li><p>Unexpected memory usage</p>
</li>
<li><p>Function addresses</p>
</li>
</ul>
<p>Disassembly Files let you see the machine code generated from your source. This is critical when:</p>
<ul>
<li><p>Code is heavily optimized</p>
</li>
<li><p>You’re diagnosing instruction-level failures</p>
</li>
<li><p>You’re working without source code (e.g., binary-only drivers)</p>
</li>
</ul>
<p>This resource provides a good overview on Map files, linkers and ELF format: <a target="_blank" href="https://www.tenouk.com/ModuleW.html">Tenouk’s ELF/Map/Linker Guide</a></p>
<h3 id="heading-common-bug-buffer-overflows">Common Bug: Buffer Overflows</h3>
<p>Buffer overflows are one of the most frequent (and dangerous) issues in embedded systems. They happen when data is written past the end of an allocated array, overwriting nearby memory and causing unpredictable behavior.</p>
<p>Symptoms:</p>
<ul>
<li><p>Code crashes mysteriously</p>
</li>
<li><p>Data appears to “corrupt itself”</p>
</li>
<li><p>Variables change value without explanation</p>
</li>
</ul>
<p>You can learn more in my article on <a target="_blank" href="https://www.freecodecamp.org/news/how-to-debug-and-prevent-buffer-overflows-in-embedded-systems/">Debugging Buffer Overflows</a>, which walks through ways to debug a buffer overflow and build robust buffer code.</p>
<h3 id="heading-embedded-forensics">Embedded Forensics</h3>
<p>Sometimes, a device fails in the field, where you can’t attach a debugger. That’s where forensics comes in:</p>
<ul>
<li><p>Use watchdog timers to reset the system and log failure info</p>
</li>
<li><p>Save crash signatures to non-volatile memory (for example, EEPROM, Flash)</p>
</li>
<li><p>Implement assert handlers that log file names, line numbers, or fault types</p>
</li>
</ul>
<p>These techniques help you reconstruct what went wrong after the device has rebooted or been recovered.</p>
<p>You can learn more here: <a target="_blank" href="https://medium.com/@lanceharvieruntime/debugging-techniques-for-embedded-systems-94d00582074a">Debugging Techniques for Embedded Systems – Medium</a>.</p>
<p>Debugging and forensics are invaluable when something goes wrong – but a robust system should aim to catch issues before they reach deployment.</p>
<p>That’s where automated testing becomes essential. With embedded software increasingly powering critical applications, the ability to run consistent, repeatable tests across hardware configurations saves time, improves reliability, and enables faster development cycles.</p>
<p>Next, let’s explore how embedded testing works, the challenges unique to hardware, and how automation frameworks help streamline validation.</p>
<h2 id="heading-automation-and-testing-in-embedded-systems">Automation and Testing in Embedded Systems</h2>
<p>Like all other areas of software engineering, testing is essential in embedded systems. But testing embedded software comes with its own set of challenges, mainly because it interacts with hardware.</p>
<p>Manual testing can be time-consuming and resource-intensive, especially when tests need to be repeated for multiple firmware versions or configurations. That’s where automated testing becomes invaluable.</p>
<h3 id="heading-why-automated-testing">Why Automated Testing?</h3>
<p>Automated testing helps:</p>
<ul>
<li><p>Catch regressions early</p>
</li>
<li><p>Test edge cases consistently</p>
</li>
<li><p>Reduce human error</p>
</li>
<li><p>Scale testing across versions and hardware setups</p>
</li>
</ul>
<p>But automating tests for embedded systems isn’t just writing test cases – it’s about setting up an infrastructure that connects your code to the physical hardware under test.</p>
<h3 id="heading-test-architecture-host-dut">Test Architecture: Host + DUT</h3>
<p>Most embedded test setups involve two components:</p>
<ul>
<li><p>Host: Your development PC or CI test controller, which sends test commands and receives data.</p>
</li>
<li><p>DUT (Device Under Test): The microcontroller board or embedded system running the firmware.</p>
</li>
</ul>
<p>These two communicate over a physical link, commonly USB, UART, or FTDI, which carries commands and test data between them.</p>
<h4 id="heading-diagram-suggested-structure">Diagram (suggested structure)</h4>
<p>You could visualize this as:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1749953253453/4a94ae37-dd17-4be1-aece-d1c2bee0248d.png" alt="Describes the flow of automation, Automation Manager on the host that takes CSV and Config Files and is the control center of Automation. Automation Manager on the DUT helps parse commands coming from host and provide replies to the host, the automation manager on the DUT will forward queries to different modules in the DUT for actions and queries. The communication protocol between Host and DUT is over USB or UART over FTDI" class="image--center mx-auto" width="1910" height="444" loading="lazy"></p>
<h3 id="heading-key-components-of-embedded-test-automation">Key Components of Embedded Test Automation</h3>
<h4 id="heading-1-file-management">1. <strong>File Management</strong></h4>
<p>Many automated tests rely on <strong>CSV or JSON files</strong> to define:</p>
<ul>
<li><p>Input configurations</p>
</li>
<li><p>Expected outputs</p>
</li>
<li><p>Test parameters</p>
</li>
</ul>
<p>Python makes it easy to:</p>
<ul>
<li><p>Read input vectors from CSVs</p>
</li>
<li><p>Write logs or pass/fail results</p>
</li>
<li><p>Parse structured data</p>
</li>
</ul>
<h4 id="heading-2-data-communication">2. <strong>Data Communication</strong></h4>
<p>Maintaining a stable and reliable link between the Host and DUT is critical. This includes:</p>
<ul>
<li><p>Opening and managing UART or USB connections (for example, with <code>pyserial</code>)</p>
</li>
<li><p>Framing test commands using opcodes or simple protocols</p>
</li>
<li><p>Handling timeouts, retries, and error recovery</p>
</li>
</ul>
<h5 id="heading-example-python-with-pyserial">Example (Python with PySerial):</h5>
<pre><code class="lang-python"><span class="hljs-keyword">import</span> serial

ser = serial.Serial(<span class="hljs-string">'/dev/ttyUSB0'</span>, <span class="hljs-number">115200</span>) <span class="hljs-comment">#set Baud rate</span>
ser.write(<span class="hljs-string">b'\x01'</span>)  <span class="hljs-comment"># Send opcode for "start test"</span>
response = ser.read(<span class="hljs-number">64</span>)  <span class="hljs-comment"># Read 64 bytes of response</span>
</code></pre>
<h4 id="heading-3-automation-manager-dut-side">3. <strong>Automation Manager (DUT-side)</strong></h4>
<p>A lightweight software agent runs on the embedded device. Its responsibilities:</p>
<ul>
<li><p>Parse incoming commands</p>
</li>
<li><p>Trigger specific test routines</p>
</li>
<li><p>Send response data back to the host</p>
</li>
</ul>
<p>This is often implemented using a <code>switch-case</code> structure in <code>C</code> or <code>C++</code>:</p>
<pre><code class="lang-c"><span class="hljs-function"><span class="hljs-keyword">void</span> <span class="hljs-title">automation_manager</span><span class="hljs-params">(<span class="hljs-keyword">uint8_t</span> opcode)</span> </span>{
    <span class="hljs-keyword">switch</span>(opcode) {
        <span class="hljs-keyword">case</span> <span class="hljs-number">0x01</span>: run_sensor_test(); <span class="hljs-keyword">break</span>;
        <span class="hljs-keyword">case</span> <span class="hljs-number">0x02</span>: run_motor_test(); <span class="hljs-keyword">break</span>;
        <span class="hljs-keyword">default</span>: <span class="hljs-keyword">break</span>;
    }
}
</code></pre>
<h4 id="heading-4-automation-manager-host-side">4. <strong>Automation Manager (Host-side)</strong></h4>
<p>This is the control center of your test workflow:</p>
<ul>
<li><p>Sends test commands and parameters to the DUT</p>
</li>
<li><p>Waits for and logs results</p>
</li>
<li><p>Compares responses to expected output</p>
</li>
<li><p>Handles communication retries or failures</p>
</li>
</ul>
<p>Often written in Python using:</p>
<ul>
<li><p><code>pyserial</code> for communication</p>
</li>
<li><p><code>pandas</code> for file/data processing</p>
</li>
<li><p><code>unittest</code> or <code>pytest</code> for test structure</p>
</li>
</ul>
<h3 id="heading-tips-for-effective-automation">Tips for Effective Automation</h3>
<ul>
<li><p>Use unique opcodes for each test command to avoid ambiguity</p>
</li>
<li><p>Implement timeout handling to avoid hanging scripts</p>
</li>
<li><p>Log everything, responses, errors, test timestamps</p>
</li>
<li><p>Use versioned test input files to track changes over time</p>
</li>
<li><p>Include self-tests on the DUT to validate hardware state before running full tests</p>
</li>
</ul>
<p>Automated testing in embedded systems is not just about running scripts, it's about building a bridge between your host PC and your device, managing the flow of commands and data, and ensuring tests are consistent, repeatable, and reliable.</p>
<p>While this requires effort to set up, the payoff is huge: confidence in your firmware, faster development cycles, and reduced risk of bugs making it into production.</p>
<h2 id="heading-where-to-go-from-here">Where to Go from Here</h2>
<h3 id="heading-building-your-embedded-project">Building your Embedded Project</h3>
<p>After exploring the theory and tooling of embedded systems, it's time to apply what you've learned. This section walks you through the steps to create your own embedded system – from concept to code and deployment.</p>
<p>Use the checklist below to guide your first project, whether you're prototyping a sensor device or automating a simple process.</p>
<h4 id="heading-project-setup-checklist">Project Setup Checklist:</h4>
<ol>
<li><p><strong>Define the Goal</strong></p>
<ul>
<li><p>What task does the system perform?</p>
</li>
<li><p>Identify inputs (for example, temperature sensor) and outputs (for example, relay or LED).</p>
</li>
</ul>
</li>
<li><p><strong>Requirements Gathering</strong></p>
<ul>
<li><p>Functional: What features must it support?</p>
</li>
<li><p>Non-functional: Memory limits, real-time behavior, power constraints.</p>
</li>
<li><p>Any security or safety-critical elements?</p>
</li>
</ul>
</li>
<li><p><strong>Choose Your Hardware</strong></p>
<ul>
<li><p>Microcontroller (for example, STM32F4)</p>
</li>
<li><p>Sensors and actuators</p>
</li>
<li><p>Communication interfaces (UART, I2C, SPI, and so on)</p>
</li>
</ul>
</li>
<li><p><strong>Software Architecture</strong></p>
<ul>
<li><p>Bare-metal, RTOS, or embedded OS?</p>
</li>
<li><p>Driver abstraction: will you use HAL or custom low-level code?</p>
</li>
<li><p>Organize code into layers: application logic, drivers, hardware init.</p>
</li>
</ul>
</li>
<li><p><strong>Toolchain Setup</strong></p>
<ul>
<li><p>Install GCC toolchain (for example, <code>arm-none-eabi-gcc</code>)</p>
</li>
<li><p>Configure Makefile and linker script</p>
</li>
<li><p>Set up debugger and flashing tools (for example, OpenOCD, ST-Link)</p>
</li>
</ul>
</li>
<li><p><strong>Firmware Implementation</strong></p>
<ul>
<li><p>Initialize peripherals</p>
</li>
<li><p>Implement control logic inside <code>main()</code> or tasks</p>
</li>
<li><p>Use interrupts or timers for responsiveness</p>
</li>
</ul>
</li>
<li><p><strong>Flashing and Initial Tests</strong></p>
<ul>
<li><p>Use OpenOCD or ST-Link to flash the binary</p>
</li>
<li><p>Test peripheral behavior and debug with UART or GDB</p>
</li>
</ul>
</li>
<li><p><strong>Debug and Profile</strong></p>
<ul>
<li><p>Use JTAG/SWD, CoreSight, and trace logs</p>
</li>
<li><p>Check memory layout with map/disassembly files</p>
</li>
<li><p>Identify bottlenecks and edge cases</p>
</li>
</ul>
</li>
<li><p><strong>Security Hardening</strong></p>
<ul>
<li><p>Disable debug interfaces post-flash</p>
</li>
<li><p>Add firmware signing and secure boot</p>
</li>
<li><p>Minimize surface area: disable unused features</p>
</li>
</ul>
</li>
<li><p><strong>Testing and Automation</strong></p>
</li>
</ol>
<ul>
<li><p>Connect Host to DUT via UART/USB</p>
</li>
<li><p>Use Python + PySerial to send test vectors</p>
</li>
<li><p>Log, compare, and report test outcomes</p>
</li>
</ul>
<p>Embedded firmware development is a deep and rewarding field where software meets the hardware. Whether you're controlling an LED, reading from a sensor, or orchestrating multiple tasks in real time, the embedded stack teaches you how hardware, software, timing, and efficiency all come together.</p>
<h2 id="heading-summary">Summary:</h2>
<p>In this guide, we walked through the essential building blocks at a high level:</p>
<ul>
<li><p>What embedded systems are, and how they sense → process → act</p>
</li>
<li><p>How microcontrollers work, from memory layout to interrupts and protocols</p>
</li>
<li><p>How to design robust, scalable embedded software with clean architecture</p>
</li>
<li><p>When to choose bare-metal, RTOS, or full OS solutions</p>
</li>
<li><p>How to build drivers, write modular code, and interface with peripherals</p>
</li>
<li><p>Tools for debugging, tracing, and analyzing system behavior</p>
</li>
<li><p>Strategies for automating embedded testing using Python and host-device communication</p>
</li>
<li><p>And finally, why security matters, especially in a connected world</p>
</li>
</ul>
<p>Whether you're preparing for embedded job interviews, building your own IoT projects, or just exploring how software drives real-world systems, this article gives you a launchpad for deeper learning.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
