<?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[ Docker - 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[ Docker - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Wed, 12 Aug 2026 16:27:40 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/docker/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Fix the Dual-Write Problem in Node.js with the Outbox Pattern ]]>
                </title>
                <description>
                    <![CDATA[ Imagine you're building an e-commerce platform where placing an order needs to trigger several things at once: the warehouse has to be told to prepare the shipment, the email service has to send a con ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-fix-the-dual-write-problem-in-node-js-with-the-outbox-pattern/</link>
                <guid isPermaLink="false">6a736f87fcec1e65edd2a703</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ design patterns ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AWS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Gabor Koos ]]>
                </dc:creator>
                <pubDate>Wed, 05 Aug 2026 17:14:47 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/bcec9aaf-d418-4e5a-b8aa-f3c75b35f482.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Imagine you're building an e-commerce platform where placing an order needs to trigger several things at once: the warehouse has to be told to prepare the shipment, the email service has to send a confirmation, and the fraud checker has to review the transaction.</p>
<p>The order service handles the checkout, saves the order to its database, and then publishes an <code>order.created</code> event to a message queue so every downstream system can react independently.</p>
<p>This is a common and reasonable design, but it has a reliability problem that's easy to miss until something goes wrong in production.</p>
<p>When a customer places an order and the payment goes through, the application needs to do two things: save the order to the database and publish the event to the queue. These are two separate writes to two separate systems, and there's no way to make them share a single atomic transaction. If the process crashes, the network hiccups, or a deployment rolls out between the two writes, one side commits and the other does not. The order sits confirmed on the customer's screen while the warehouse has no idea it exists.</p>
<p>The <a href="https://microservices.io/patterns/data/transactional-outbox.html">transactional outbox pattern</a> is the standard solution to this problem. In this article, we'll build it from scratch in Node.js, using PostgreSQL for the order service database, SQS for the queue, and DynamoDB as the fulfillment service's database. For local development, we'll use <a href="https://floci.io">floci</a>, a free open-source AWS emulator that runs all three with a single Docker container.</p>
<h2 id="heading-what-well-cover">What We'll Cover</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-problem-with-two-writes">The Problem with Two Writes</a></p>
</li>
<li><p><a href="#heading-the-outbox-pattern">The Outbox Pattern</a></p>
</li>
<li><p><a href="#heading-what-well-build">What We'll Build</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-database-schema">Database Schema</a></p>
</li>
<li><p><a href="#heading-the-request-handler">The Request Handler</a></p>
</li>
<li><p><a href="#heading-the-relay-worker">The Relay Worker</a></p>
</li>
<li><p><a href="#heading-the-consumer">The Consumer</a></p>
</li>
<li><p><a href="#heading-running-the-whole-thing">Running the Whole Thing</a></p>
</li>
<li><p><a href="#heading-going-to-production">Going to Production</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should be comfortable with:</p>
<ul>
<li><p>Node.js and async/await</p>
</li>
<li><p>Database transactions (BEGIN, COMMIT, ROLLBACK)</p>
</li>
<li><p>The general concept of a message queue</p>
</li>
</ul>
<p>You don't need prior experience with AWS, SQS, or DynamoDB. We'll be running everything locally.</p>
<p>You will need Node.js 20 or later and Docker installed on your machine.</p>
<h2 id="heading-the-problem-with-two-writes">The Problem with Two Writes</h2>
<p>The order service scenario from the intro is one place this problem appears, but the same pattern comes up in many other contexts.</p>
<p>A user registers and the app inserts their account record, then sends a message to trigger the welcome email and the onboarding workflow. A file is uploaded and the API writes the metadata to the database, then publishes a message to kick off a processing worker for virus scanning or thumbnail generation. A payment webhook arrives, the handler records it in the database, then notifies downstream services that the payment is confirmed.</p>
<p>In every case, the application needs two writes to succeed together: one to the database and one to a queue or external system. If the second one is lost, the first one has no way of knowing.</p>
<p>If you want a deeper look at what database transactions actually guarantee and where they stop helping, see <a href="https://blog.gaborkoos.com/posts/2026-08-01-Beyond-Happy-Path-Engineering-Databases/">Beyond Happy Path Engineering: Databases</a>.</p>
<p>The naïve implementation looks straightforward:</p>
<pre><code class="language-js">await db.query('INSERT INTO orders (customer_id, amount_cents) VALUES ($1, $2)', [customerId, amountCents]);
await sqs.send(new SendMessageCommand({ QueueUrl: QUEUE_URL, MessageBody: JSON.stringify({ customerId, amountCents }) }));
</code></pre>
<p>The database write happens first, then the queue write. Under normal conditions this works fine. The problem is what happens when something goes wrong between the two.</p>
<p>If the process crashes, runs out of memory, or gets killed mid-deployment after the database write but before <code>sqs.send</code> is called, the order record exists in the database but no event is ever published. The warehouse, email service, and fraud checker never find out the order happened. From the customer's perspective the order went through. From every downstream system's perspective it doesn't exist.</p>
<p>The failure can also go the other way. If <code>sqs.send</code> succeeds but the database write is later rolled back due to a constraint violation or an error in a subsequent step, you've published an event for an order that doesn't actually exist. A consumer acting on that event may try to fulfill an order with no corresponding record, or charge a customer for something that was never saved.</p>
<p>There's also a timing window even when both writes eventually succeed. Between the database commit and the successful <code>sqs.send</code>, a consumer that queries the database after receiving the event may not find the order yet, depending on transaction isolation and replication lag. These are two separate systems with no shared transaction boundary, and no amount of careful sequencing fully closes the gap.</p>
<p>These aren't edge cases that only happen under extraordinary circumstances. Deploys restart processes mid-request. Out-of-memory kills happen without warning. Networks drop connections at any point. Any of these can interrupt the two-write sequence, and the result is a system that's silently inconsistent with no error logged and no alert fired.</p>
<p>A variation I've seen a few times that looks safer but is actually worse is wrapping both operations in a database transaction:</p>
<pre><code class="language-js">// PLEASE DO NOT EVER DO THIS
const client = await pool.connect();
await client.query('BEGIN');
await client.query('INSERT INTO orders (customer_id, amount_cents) VALUES ($1, $2)', [customerId, amountCents]);
await sqs.send(new SendMessageCommand({ QueueUrl: QUEUE_URL, MessageBody: JSON.stringify({ customerId, amountCents }) }));
await client.query('COMMIT');
</code></pre>
<p>The intent is to make the two writes feel like a unit, but a database transaction has no authority over SQS. The transaction can only roll back database operations. If <code>sqs.send</code> succeeds and then <code>COMMIT</code> fails, the message is already in the queue and can't be taken back. If the process crashes after <code>COMMIT</code> but before the function returns, the transaction committed and the message was sent, but the caller may retry, potentially inserting a duplicate order.</p>
<p>Beyond the correctness problems, this pattern holds an open database connection and any row locks for the entire duration of the SQS network call. SQS is normally fast, but under load, retries, or a degraded queue, that call can take seconds. Every other request trying to read or write the same rows has to wait. In a busy application, this is a reliable way to exhaust the connection pool and bring down unrelated parts of the service.</p>
<h2 id="heading-the-outbox-pattern">The Outbox Pattern</h2>
<p>The core idea is to stop treating the queue publish as a second write that happens after the database write, and instead make it part of the same database transaction.</p>
<p>Rather than calling <code>sqs.send</code> directly, the application inserts a row into an <code>outbox</code> table in the same transaction as the business record. A separate relay process reads the outbox table and publishes the messages to SQS. On the other end, a consumer receives the messages and writes to its own data store. In our case that is a fulfillment service writing to DynamoDB, completely separate from the order service's PostgreSQL database.</p>
<p>If the transaction rolls back for any reason, the outbox row disappears with it. There's no orphaned message in the queue because the message was never sent. If the application crashes after committing but before the relay runs, the outbox row is still there with <code>status='pending'</code>, and the relay will pick it up on its next iteration.</p>
<p>The only guarantee the pattern relies on is the one the database already provides: atomicity within a single transaction.</p>
<p>The relay worker is responsible for the eventual delivery guarantee. It runs on an interval, selects pending rows, publishes them to SQS, and marks them as sent only after SQS confirms receipt. If the relay crashes mid-run, it will reprocess the same rows on the next iteration, which means SQS may receive some messages more than once.</p>
<p>That's why the consumer needs to be <strong>idempotent</strong>: it must handle receiving the same message twice without creating duplicate fulfillment records. We'll cover how to implement that when we build the consumer.</p>
<p>This separation of concerns is what makes the pattern practical. The request handler commits one atomic database transaction and returns. The relay handles the network call to SQS asynchronously, at its own pace, with its own retry logic, without holding database connections open or blocking request handling. The consumer is fully decoupled from the order service and owns its own data store.</p>
<p>The diagram below illustrates the flow:</p>
<img src="https://cdn.hashnode.com/uploads/covers/68b08746916c71e1ed2db58e/ab0620f0-65c6-43f1-a406-00bfd4880cdc.svg" alt="Diagram: outbox pattern flow" style="display:block;margin:0 auto" width="960" height="640" loading="lazy">

<h2 id="heading-what-well-build">What We'll Build</h2>
<p>Now let's see the whole thing in practice. We'll implement a simple order placement API. When a customer sends a request to place an order, the order service saves it to PostgreSQL and inserts a row into the outbox table, all in one atomic transaction. A relay worker wakes up periodically, reads the pending outbox rows, and publishes each one as a message to SQS. A separate fulfillment service receives those messages from the queue and creates fulfillment records in DynamoDB.</p>
<p>By the end, you'll have an HTTP endpoint you can call, and you'll be able to verify that placing an order triggers the creation of a fulfillment record in a completely separate database, owned by a completely separate service, without either service ever talking to the other directly.</p>
<p>You can find the complete working code at <a href="https://github.com/gkoos/article-outbox">github.com/gkoos/article-outbox</a>.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<p>Before you can run any code, you need to get floci running so you have local instances of PostgreSQL, SQS, and DynamoDB. You'll also need Node.js 20 or later and Docker installed.</p>
<p>Start by cloning the repository and installing dependencies:</p>
<pre><code class="language-bash">git clone https://github.com/gkoos/article-outbox
cd article-outbox
npm install
</code></pre>
<p>Next, start floci. This command pulls the latest floci image and starts a Docker container that exposes a local AWS API endpoint (make sure Docker is running):</p>
<pre><code class="language-bash">npm run floci:start
</code></pre>
<p>On Linux and macOS, this just works. On Windows with Docker Desktop, <strong>you need to edit the</strong> <code>floci:start</code> <strong>script in your</strong> <code>package.json</code> <strong>to change the Docker socket mount from</strong> <code>/var/run/docker.sock</code> <strong>to</strong> <code>//var/run/docker.sock</code>.</p>
<p>The floci container is now listening on port 4566 and can spin up RDS (PostgreSQL), SQS, and DynamoDB instances on demand.</p>
<p>Now provision the AWS resources with a single setup command:</p>
<pre><code class="language-bash">npm run setup
</code></pre>
<p>This script creates an RDS PostgreSQL database instance, an SQS queue named <code>orders</code>, and a DynamoDB table named <code>fulfillments</code>. It waits for RDS to become available and then writes a <code>.env</code> file with the correct connection details. The environment variables <code>PG_PORT</code>, <code>SQS_QUEUE_URL</code>, and <code>DYNAMODB_TABLE_NAME</code> now point to the local emulated services.</p>
<p>Finally, create the PostgreSQL tables:</p>
<pre><code class="language-bash">npm run migrate
</code></pre>
<p>This creates the <code>orders</code> table and the <code>outbox</code> table in PostgreSQL. You now have a fully functional local environment ready to build against.</p>
<h2 id="heading-database-schema">Database Schema</h2>
<p>The two tables are simple. <code>orders</code> holds the business records: each order has a customer ID, an amount in cents, and a timestamp. The <code>outbox</code> table is the heart of the pattern: it's where the application writes the event that needs to be published.</p>
<pre><code class="language-sql">CREATE TABLE orders (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_id TEXT NOT NULL,
  amount_cents INTEGER NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE outbox (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  event_type TEXT NOT NULL,
  payload JSONB NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending',
  created_at TIMESTAMPTZ DEFAULT now(),
  sent_at TIMESTAMPTZ
);

CREATE INDEX ON outbox (status, created_at) WHERE status = 'pending';
</code></pre>
<p>The <code>orders</code> table needs nothing special. The <code>outbox</code> table stores the event metadata: what type of event it is (<code>event_type</code>), what data it contains (<code>payload</code> as JSON), and whether it has been sent yet (<code>status</code>).</p>
<p>The status starts as <code>pending</code>. When the relay publishes it to SQS, it will mark it as <code>sent</code> and record the timestamp. The index on <code>(status, created_at) WHERE status = 'pending'</code> lets the relay quickly find the next batch of unsent events without scanning the entire table.</p>
<h2 id="heading-the-request-handler">The Request Handler</h2>
<p>This is where the pattern starts. The request handler receives an HTTP POST, inserts an order into the database, inserts a corresponding row into the outbox table, and commits everything in a single atomic transaction. The key insight is that neither write succeeds unless both succeed.</p>
<pre><code class="language-js">const client = await pool.connect();
try {
  await client.query('BEGIN');

  // Insert the order record
  const { rows } = await client.query(
    'INSERT INTO orders (customer_id, amount_cents) VALUES ($1, $2) RETURNING *',
    [customerId, amountCents]
  );
  const order = rows[0];

  // Insert the outbox record in the same transaction
  await client.query(
    `INSERT INTO outbox (event_type, payload)
     VALUES ($1, $2)`,
    ['order.created', JSON.stringify({ orderId: order.id, customerId: order.customer_id, amountCents: order.amount_cents, createdAt: order.created_at })],
  );

  await client.query('COMMIT');
  res.status(201).json(order);
} catch (err) {
  await client.query('ROLLBACK');
  next(err);
} finally {
  client.release();
}
</code></pre>
<p>The handler gets <code>customerId</code> and <code>amountCents</code> from the request body, starts an explicit transaction with <code>BEGIN</code>, and inserts the order. Then it inserts an outbox row with the order data as the payload.</p>
<p>Everything commits atomically. If anything fails, everything rolls back and the client gets an error. If the process crashes between the commit and the response, the client won't get a 201, but the order and the outbox row are still safely committed to the database and the relay will eventually pick it up. The handler doesn't call SQS at all. That is the relay's job.</p>
<h2 id="heading-the-relay-worker">The Relay Worker</h2>
<p>The relay worker is a separate process that polls the outbox table every second and publishes pending rows to SQS. It runs independently of the HTTP server and has no shared state with it.</p>
<pre><code class="language-js">async function relay() {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    const { rows } = await client.query(`
      SELECT *
      FROM outbox
      WHERE status = 'pending'
      ORDER BY created_at
      LIMIT 10
      FOR UPDATE SKIP LOCKED -- prevents multiple relays from processing the same rows
    `);

    for (const row of rows) {
      await sqsClient.send(new SendMessageCommand({
        QueueUrl: QUEUE_URL,
        MessageBody: JSON.stringify(row.payload),
        MessageAttributes: {
          EventType: { DataType: 'String', StringValue: row.event_type },
        },
      }));

      await client.query(
        `UPDATE outbox SET status = 'sent', sent_at = now() WHERE id = $1`,
        [row.id],
      );
    }

    await client.query('COMMIT');
  } catch (err) {
    await client.query('ROLLBACK');
    console.error('Relay error:', err.message);
  } finally {
    client.release();
  }
}

setInterval(relay, 1000);
</code></pre>
<p><code>FOR UPDATE SKIP LOCKED</code> is the key to running multiple relay instances safely: when a relay picks up a batch of rows, it locks them. Any other relay instance trying to select the same rows will skip them and move to the next available ones, so you never get two relays publishing the same message from the same run.</p>
<p>The relay marks each row as <code>sent</code> only after <code>sqsClient.send</code> returns. If the relay crashes after sending to SQS but before updating the row, the row stays <code>pending</code> and the relay will resend it on the next iteration.</p>
<p>Note that the <code>UPDATE</code> happens inside the same transaction as the <code>SELECT FOR UPDATE</code>, so if the relay crashes mid-batch, the entire batch rolls back and all rows in it will be retried, including any that were already successfully sent to SQS.</p>
<p>The at-least-once delivery guarantee applies at the batch level, not the individual row level. You can read about this problem in <a href="https://blog.gaborkoos.com/posts/2026-07-01-Beyond-Happy-Path-Engineering-the-Network/">Beyond Happy Path Engineering: the Network</a>: when a response is lost, the caller can't know whether the operation succeeded, so it retries, and the receiver may see the same request twice. This means the consumer may see the same message more than once, which is why idempotency matters on the consumer side.</p>
<h2 id="heading-the-consumer">The Consumer</h2>
<p>The consumer is a completely separate service. It knows nothing about the order service's PostgreSQL database. Its only input is the SQS queue, and its only output is the DynamoDB <code>fulfillments</code> table. This is the point of the pattern: the two services are decoupled by the queue, and each owns its own data store.</p>
<p>As we saw earlier, because SQS delivers at least once (meaning a message might be delivered more than once), the consumer must be idempotent. The <code>PutItem</code> call uses a <code>ConditionExpression</code> that makes the write a no-op if a fulfillment record for that order already exists, so redelivered messages are handled safely.</p>
<pre><code class="language-js">async function consume() {
  const { Messages } = await sqsClient.send(new ReceiveMessageCommand({
    QueueUrl:              QUEUE_URL,
    WaitTimeSeconds:       20,   // long-poll: wait up to 20s for messages
    MaxNumberOfMessages:   10,
    MessageAttributeNames: ['All'],
  }));

  for (const msg of Messages ?? []) {
    const event = JSON.parse(msg.Body);

    try {
      await dynamoClient.send(new PutItemCommand({
        TableName: 'fulfillments',
        Item: {
          orderId:     { S: event.orderId },
          customerId:  { S: event.customerId },
          amountCents: { N: String(event.amountCents) },
          status:      { S: 'received' },
          createdAt:   { S: new Date().toISOString() },
        },
        ConditionExpression: 'attribute_not_exists(orderId)', // idempotency check
      }));
    } catch (err) {
      if (err.name !== 'ConditionalCheckFailedException') throw err;
      // already processed, safe to continue
    }

    // delete the message only after the write succeeds (or was already done)
    await sqsClient.send(new DeleteMessageCommand({
      QueueUrl:      QUEUE_URL,
      ReceiptHandle: msg.ReceiptHandle,
    }));
  }
}
</code></pre>
<p><code>ConditionExpression: 'attribute_not_exists(orderId)'</code> tells DynamoDB to reject the write if a record with that <code>orderId</code> already exists. When that happens, DynamoDB throws a <code>ConditionalCheckFailedException</code>. The consumer catches that specific error and ignores it, then deletes the message from the queue and moves on. Any other error is rethrown and the message stays in the queue to be retried.</p>
<p>The <code>DeleteMessage</code> call happens after the DynamoDB write, not before. If the process crashes between the write and the delete, SQS will redeliver the message and the condition check will handle it. If the process crashes before the write, the message stays in the queue and will be processed normally on the next delivery.</p>
<h2 id="heading-running-the-whole-thing">Running the Whole Thing</h2>
<p>With floci running and the resources provisioned, open three terminal tabs and start each process:</p>
<pre><code class="language-bash">node src/server.js    # the order API on port 3000
node src/relay.js     # the outbox relay
node src/consumer.js  # the fulfillment consumer
</code></pre>
<p>Now place an order:</p>
<pre><code class="language-bash">curl -X POST localhost:3000/orders \
  -H 'Content-Type: application/json' \
  -d '{"customerId":"c1","amountCents":4999}'
</code></pre>
<p>You should get back a 201 with the new order record:</p>
<pre><code class="language-bash">{"id":"1768d35b-083d-45f1-adb5-4063d8d7fcab","customer_id":"c1","amount_cents":4999,"created_at":"2026-07-30T20:27:10.628Z"}
</code></pre>
<p>Within a second the relay will pick up the outbox row and publish it to SQS. The consumer will receive the message and write a fulfillment record to DynamoDB. The repo includes a convenience script to verify this:</p>
<pre><code class="language-bash">npm run check
</code></pre>
<p>You should see a fulfillment record with the <code>orderId</code> from the order you just placed:</p>
<pre><code class="language-bash">{
  orderId: 'c335640e-bc4a-47e4-afed-484c95fbd6d3',
  customerId: 'c1',
  amountCents: '4999',
  status: 'received',
  createdAt: '2026-07-30T19:02:54.929Z'
}
</code></pre>
<h2 id="heading-going-to-production">Going to Production</h2>
<p>Because the local setup uses floci to emulate AWS, switching to real AWS requires no code changes at all. The AWS SDK reads the endpoint from <code>AWS_ENDPOINT_URL</code> in the environment. In production, you simply don't set that variable and the SDK talks to real AWS using the credentials and region from the standard environment variables (<code>AWS_REGION</code>, <code>AWS_ACCESS_KEY_ID</code>, <code>AWS_SECRET_ACCESS_KEY</code>, or an IAM role if you are running on EC2 or ECS).</p>
<p>Running multiple relay instances is safe out of the box because of <code>FOR UPDATE SKIP LOCKED</code>. You can scale the relay horizontally and each instance will pick up a different set of rows without duplicating messages.</p>
<p>One thing worth adding before going to production is handling permanent failures in the relay. Right now the relay only uses <code>pending</code> and <code>sent</code>. You should add a <code>failed</code> status and a retry counter: after a row has failed N times, mark it <code>failed</code> and stop retrying it. Then configure a dead-letter queue on the <code>orders</code> SQS queue as well, so that messages the consumer can't process after the maximum number of retries land somewhere you can inspect rather than disappearing silently.</p>
<p>For high-throughput systems where polling latency matters, <a href="https://en.wikipedia.org/wiki/Change_data_capture">change data capture</a> (CDC) is a common alternative to the polling relay. Tools like <a href="https://debezium.io/">Debezium</a> read directly from the PostgreSQL write-ahead log and publish changes to <a href="https://kafka.apache.org/">Kafka</a> or SQS without any polling delay. The outbox table and the consumer stay exactly the same, only the relay is replaced.</p>
<p>This is a bigger operational commitment than a polling worker, so polling is the right starting point for most systems.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The dual-write problem is easy to overlook because the naïve implementation works correctly most of the time. It only fails in the gaps between two separate system writes, and those gaps only become visible when something goes wrong at exactly the wrong moment. By the time you notice it in production, data is already inconsistent and there is no clean way to recover.</p>
<p>The transactional outbox pattern closes that gap at the database level. The outbox row is part of the same atomic commit as the business record, so the two are always in sync. The relay handles the network call to SQS independently, with its own retry logic, without touching the request lifecycle. The consumer handles at-least-once delivery with a single condition check on the write.</p>
<p>Each piece is simple on its own, and together they give you reliable, decoupled event delivery without distributed transactions.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build Kubernetes Operators: A Handbook for Devs ]]>
                </title>
                <description>
                    <![CDATA[ Kubernetes ships with controllers that manage a fixed set of built-in resources: Deployments, Services, Nodes, and so on. An operator extends the same pattern to resources Kubernetes doesn't know abou ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-kubernetes-operators-a-handbook-for-devs/</link>
                <guid isPermaLink="false">6a6a09f608b0619a2131a1cb</guid>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Go Language ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloud native ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Karan Pratap Singh ]]>
                </dc:creator>
                <pubDate>Wed, 29 Jul 2026 14:11:02 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/21c81312-eb74-40f3-823a-3831945a3f58.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Kubernetes ships with controllers that manage a fixed set of built-in resources: Deployments, Services, Nodes, and so on.</p>
<p>An operator extends the same pattern to resources Kubernetes doesn't know about natively, letting you manage custom, often external, systems the same declarative way you manage everything else in the cluster.</p>
<p>This guide is divided into four parts: what an operator actually is, the anatomy of one, building one from scratch, and preparing it for production.</p>
<h3 id="heading-table-of-contents">Table of Contents</h3>
<ul>
<li><p><a href="#heading-part-1-introduction">Part 1: Introduction</a></p>
<ul>
<li><p><a href="#heading-what-is-an-operator">What is an Operator?</a></p>
</li>
<li><p><a href="#heading-operator-vs-controller-vs-crd">Operator vs Controller vs CRD</a></p>
</li>
<li><p><a href="#heading-why-not-just-a-helm-chart-a-cronjob-or-a-script">Why Not Just a Helm Chart, a CronJob, or a Script?</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-2-anatomy-of-an-operator">Part 2: Anatomy of an Operator</a></p>
<ul>
<li><p><a href="#heading-custom-resource">Custom Resource</a></p>
</li>
<li><p><a href="#heading-watching-for-change">Watching for Change</a></p>
</li>
<li><p><a href="#heading-manager">Manager</a></p>
</li>
<li><p><a href="#heading-reconciliation-loop">Reconciliation Loop</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-3-building-the-operator">Part 3: Building the Operator</a></p>
<ul>
<li><p><a href="#heading-setup">Setup</a></p>
</li>
<li><p><a href="#heading-mock-provider">Mock Provider</a></p>
</li>
<li><p><a href="#heading-defining-the-virtualmachine-crd">Defining the VirtualMachine CRD</a></p>
</li>
<li><p><a href="#heading-reconciler">Reconciler</a></p>
</li>
<li><p><a href="#heading-failure-handling-amp-retries">Failure Handling &amp; Retries</a></p>
</li>
<li><p><a href="#heading-finalizer">Finalizer</a></p>
</li>
<li><p><a href="#heading-predicate">Predicate</a></p>
</li>
<li><p><a href="#heading-owned-resources">Owned Resources</a></p>
</li>
<li><p><a href="#heading-cross-resource-reconciliation">Cross-Resource Reconciliation</a></p>
</li>
<li><p><a href="#heading-rbac">RBAC</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-4-production-amp-deployment">Part 4: Production &amp; Deployment</a></p>
<ul>
<li><p><a href="#heading-packaging-amp-deployment">Packaging &amp; Deployment</a></p>
</li>
<li><p><a href="#heading-performance-amp-resilience">Performance &amp; Resilience</a></p>
</li>
<li><p><a href="#heading-security">Security</a></p>
</li>
<li><p><a href="#heading-observability">Observability</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-next-steps">Next steps</a></p>
</li>
</ul>
<h2 id="heading-part-1-introduction">Part 1: Introduction</h2>
<h3 id="heading-what-is-an-operator">What is an Operator?</h3>
<p>Kubernetes works by comparing the state we describe against actual state. A controller acts to close the gap, whether that's the Deployment controller replacing a pod we killed or scaling down the ones we no longer want.</p>
<p>This loop of observe, compare, and act is called <strong>reconciliation</strong>. It means looking up a resource's desired and actual state, deciding what to do next, and recomputing that decision fresh on every run regardless of what changed.</p>
<p>That's what makes the loop resilient: it never has to trust that it saw every event, only that it gets called again.</p>
<img src="https://raw.githubusercontent.com/karanpratapsingh/portfolio/refs/heads/master/public/static/blogs/kubernetes-operators/reconcile-loop.png" alt="reconciliation loop illustration" style="display:block;margin:0 auto" width="680" height="400" loading="lazy">

<p>An operator applies that exact same loop to a resource Kubernetes doesn't understand natively. We define a Custom Resource, describe our domain's desired state in it, and write a controller that knows how to reconcile that domain.</p>
<p>That's the whole concept. Everything else in this guide (informers, workqueues, finalizers, status conditions) exists to make that one loop reliable for a resource type Kubernetes only knows about because we defined it.</p>
<h3 id="heading-operator-vs-controller-vs-crd">Operator vs Controller vs CRD</h3>
<p>These three terms are often used interchangeably, but they describe three different layers of the same system.</p>
<ul>
<li><p><strong>CRD (CustomResourceDefinition)</strong>: a schema we register with the Kubernetes API server to teach it about a new resource type. On its own, a CRD does nothing. It only gives the API server a shape to store, validate, and serve.</p>
</li>
<li><p><strong>Controller</strong>: any piece of software running a reconciliation loop against a resource type, from the built-in Deployment controller to a custom controller reconciling a <code>PostgresCluster</code>.</p>
</li>
<li><p><strong>Operator</strong>: a controller, or a small set of controllers, that targets a custom resource and encodes enough domain-specific knowledge to manage its full lifecycle without a human: provisioning, upgrades, failure recovery, and so on.</p>
</li>
</ul>
<p>Every operator is a controller, but not every controller is an operator. A CRD without a controller behind it is just a schema that nothing acts on.</p>
<h3 id="heading-why-not-just-a-helm-chart-a-cronjob-or-a-script">Why Not Just a Helm Chart, a CronJob, or a Script?</h3>
<p>A <a href="https://helm.sh/docs/topics/charts/"><strong>Helm chart</strong></a> renders a set of values into YAML and applies it once. It has no way to keep watching afterward. if a resource it created is deleted or drifts, Helm has no idea until we run <code>helm upgrade</code> again by hand.</p>
<p>A <strong>CronJob</strong> gives us a loop back, at the cost of granularity, staleness up to one interval, no state carried between runs, and no way for one CronJob to react to a status change another one made.</p>
<p>And a <strong>one-off script</strong> only acts when triggered, manually or by a CI pipeline, and does nothing about drift in between runs. It's also rarely written with retries and idempotency as first-class concerns.</p>
<h4 id="heading-why-an-operator-wins-here">Why an operator wins here:</h4>
<p>An operator is event-driven and continuous. The API server notifies it the instant a custom resource is created, updated, or deleted, and it keeps reconciling for that resource's entire lifetime, not just at apply time. That matters most for state that takes time to converge, can fail partway through, and can drift after it's first created.</p>
<p>This comes at a cost, though. An operator is a long-running process with its own RBAC (Role-Based Access Control), failure modes, and observability surface. This is more to build and operate than a chart or a script.</p>
<p>If the problem really is rendering some YAML once, a Helm chart is the right tool. An operator earns its keep when the problem is keeping something continuously correct, which is what the rest of this guide builds toward.</p>
<h2 id="heading-part-2-anatomy-of-an-operator">Part 2: Anatomy of an Operator</h2>
<p>Next, we'll look at the pieces that make that loop actually work: the Custom Resource itself, the machinery that notices when something changed, and the manager that runs it all, before going into the reconciliation loop in detail.</p>
<h3 id="heading-custom-resource">Custom Resource</h3>
<p>Before a controller can reconcile anything, the API server needs to know the shape of what it's storing. Registering a CRD teaches it that shape.</p>
<p>Every Custom Resource carries the same identity fields every Kubernetes object already has (<code>kind</code>, <code>name</code>, <code>namespace</code>, <code>labels</code>, and so on), plus two fields that are entirely ours to define: a spec and a status. That split isn't a style choice. It maps directly to the reconciliation loop.</p>
<ul>
<li><p><strong>Spec</strong> is desired state. Whoever creates or edits the resource writes it, and the controller only ever reads it.</p>
</li>
<li><p><strong>Status</strong> is observed state. It's written only by the controller, to record what it found and what it did.</p>
</li>
</ul>
<p>A client that writes to status directly is working around the controller instead of through it, which is why status is usually served as its own subresource with separate permissions.</p>
<p>The last piece is registration. The API server and any client talking to it need a shared, agreed-upon way to encode and decode our type, so we register it once against a scheme before anything can use it. Without that, our type is just a definition nobody can serve. With it, the API server can store and serve it exactly the way it serves Pods or Deployments.</p>
<p>We'll see exactly what that registration looks like when we build one for real in Part 3.</p>
<h3 id="heading-watching-for-change">Watching for Change</h3>
<p>A reconciler doesn't poll the API server in a loop asking "did anything change yet?" Three pieces work together to avoid that.</p>
<p>An <strong>informer</strong> opens a long-lived watch against the API server and keeps a local, in-memory cache of every object of a given type, updating it as add, update, and delete events arrive.</p>
<p>A <strong>lister</strong> reads from that cache instead of the API server, so a reconciler checking "does this Resource already exist?" costs a local map lookup, not a network call.</p>
<p>A <strong>workqueue</strong> sits between the informer and the reconciler. When the informer sees a change, it doesn't call the reconciler directly. Instead, it enqueues a key, namespace, and name, not the object itself. Workers pull keys off the queue and reconcile them, and the queue deduplicates and rate-limits on our behalf, so ten rapid updates to the same object collapse into one pending item instead of ten redundant reconciles.</p>
<img src="https://raw.githubusercontent.com/karanpratapsingh/portfolio/refs/heads/master/public/static/blogs/kubernetes-operators/informer-pipeline.png" alt="informer, workqueue, and reconciler pipeline" style="display:block;margin:0 auto" width="880" height="420" loading="lazy">

<p>This is also why a reconciler receives a key and not an object. By the time a worker picks the key off the queue, the object may have changed again, so the reconciler always looks up the current state itself rather than trusting whatever triggered it.</p>
<h3 id="heading-manager">Manager</h3>
<p>The manager is the process that owns all of this: the shared cache the informers populate, the client the reconciler uses to read and write objects, and the health, readiness, and metrics endpoints the rest of the cluster uses to know the controller is alive. Every reconciler we register runs inside one manager.</p>
<p>If we run more than one replica of the same controller for availability, we don't want both replicas reconciling the same object at once and racing each other.</p>
<p>The manager coordinates this through <strong>leader election</strong>. Replicas compete for a lease, exactly one holds it and actively reconciles, and the rest sit idle until the leader stops renewing it.</p>
<p>We'll come back to this in practice in Part 4. For now it's enough to know the manager is what makes it possible.</p>
<h3 id="heading-reconciliation-loop">Reconciliation Loop</h3>
<p>This is the part that matters most. Once we understand this loop well, most of what an operator does is a variation on it.</p>
<p>A reconciler's entry point is called with just a namespace and a name, nothing else. No spec, status, or diff. The reconciler has to fetch the object itself, compare its spec against what it can observe of the actual state, and decide what to do. That constraint is deliberate, and it's the reason for everything below.</p>
<h4 id="heading-idempotency">Idempotency</h4>
<p>Because the reconciler only ever gets a key, and because it can be called any number of times for the same object (in a row, out of order, or after a long gap), it has to produce the same end result no matter how many times it runs. A reconciler that blindly calls create every time it runs breaks the moment it runs twice, since the second call fails against an object that already exists.</p>
<p>The fix is to always check current state before acting: create only if missing, update only if different, and delete only if it shouldn't exist.</p>
<h4 id="heading-event-driven-reconciliation">Event-driven reconciliation</h4>
<p>A reconcile is triggered by a watch event on the resource being reconciled, and by convention also on anything it owns or otherwise depends on. On top of that, most controllers set a periodic resync so the loop also runs on a schedule even with no watch event at all, which matters once state can drift for reasons a watch would never catch.</p>
<h4 id="heading-requeues">Requeues</h4>
<p>Sometimes a single pass through reconcile can't finish the job, becuase the work it's waiting on is still in progress elsewhere. A reconciler can ask to be called again after a delay without treating this as a failure. This is how it polls something that takes time to converge, rather than blocking inside a single call.</p>
<h4 id="heading-error-handling">Error handling</h4>
<p>Returning an error does something similar: it requeues, but with exponential backoff instead of a fixed delay. So a persistently failing reconcile doesn't hammer whatever it's failing against.</p>
<p>It's worth distinguishing errors that are worth retrying (like a timeout or lock conflict) from ones that aren't (like a spec that will never be valid, which should be surfaced as a status condition instead of retried forever).</p>
<h4 id="heading-drift-correction">Drift correction</h4>
<p>Put all of the above together and the loop is self-healing by construction. Because reconcile recomputes the full diff every time rather than reacting to what specifically changed, it doesn't matter whether the drift came from someone running <code>kubectl edit</code>, another controller, or the underlying system the resource represents changing state on its own. The next reconcile, whether triggered by a watch event or a resync, sees the same gap either way and closes it the same way.</p>
<h2 id="heading-part-3-building-the-operator">Part 3: Building the Operator</h2>
<p>Everything so far has been building toward this. We now know what an operator is, how the terms around it relate, and what pieces a reconciliation loop is made of.</p>
<p>Now we'll put all of it to use and build <strong>VMOperator</strong>. It's an operator that manages a <code>VirtualMachine</code> Custom Resource backed by a mock cloud provider, a small HTTP service we'll also write that stands in for a real one.</p>
<pre><code class="language-yaml">apiVersion: compute.example.com/v1
kind: VirtualMachine
spec:
  image: ubuntu-22.04
  cpu: 2
  memory: 4Gi
status:
  phase: Running
  id: vm-123
</code></pre>
<p>Say we want to represent a virtual machine in a Kubernetes-native way, <code>kubectl apply</code> a YAML file, and get a VM – all without touching a cloud console or a separate CLI.</p>
<p>That's the motivation behind VMOperator: something that lives entirely outside Kubernetes becomes just another object the cluster's own tooling (<code>kubectl</code>, RBAC, GitOps pipelines) already knows how to work with.</p>
<img src="https://raw.githubusercontent.com/karanpratapsingh/portfolio/refs/heads/master/public/static/blogs/kubernetes-operators/vmoperator-architecture.png" alt="VMOperator architecture" style="display:block;margin:0 auto" width="1000" height="520" loading="lazy">

<h3 id="heading-setup">Setup</h3>
<p>We'll need a local cluster, and <a href="https://kind.sigs.k8s.io/">kind</a> is the easiest way to get one:</p>
<pre><code class="language-bash">kind create cluster --name vmoperator
</code></pre>
<p>Beyond that, we'll be using <a href="https://go.dev/doc/install">Go</a> in this part for the operator, <code>kubectl</code> pointed at the new cluster, and Python with Flask for the mock provider below.</p>
<pre><code class="language-bash">pip install flask
</code></pre>
<h3 id="heading-mock-provider">Mock Provider</h3>
<p>Before we write controller code, we need something for it to control. The mock provider is a small HTTP service with three endpoints:</p>
<ul>
<li><p><code>POST /vms</code> to create one</p>
</li>
<li><p><code>GET /vms/{id}</code> to check on it</p>
</li>
<li><p><code>DELETE /vms/{id}</code> to remove it</p>
</li>
</ul>
<p>backed by nothing more than a dict in memory.</p>
<p>Every VM it creates starts in <code>Provisioning</code> and flips to <code>Running</code> a few seconds later on its own, which is enough to force our reconciler to actually poll instead of assuming success.</p>
<p>We're writing this one in Python rather than Go. It has nothing to do with the operator's code, as this is only for mock purposes.</p>
<pre><code class="language-python">import random
import string
import threading
import time

from flask import Flask, jsonify, request

app = Flask(__name__)
vms = {}  # in-memory store, keyed by VM id

def provision(vm):
    time.sleep(5)  # simulate provisioning taking time
    vm["phase"] = "Running"

@app.post("/vms")
def create_vm():
    body = request.get_json()
    vm_id = "vm-" + "".join(random.choices(string.digits, k=6))
    vm = {"id": vm_id, "image": body["image"], "phase": "Provisioning"}
    vms[vm_id] = vm

    threading.Thread(target=provision, args=(vm,), daemon=True).start()  # flips to Running in the background

    return jsonify(vm)

@app.get("/vms/&lt;vm_id&gt;")
def get_vm(vm_id):
    vm = vms.get(vm_id)
    if vm is None:
        return "", 404
    return jsonify(vm)

@app.delete("/vms/&lt;vm_id&gt;")
def delete_vm(vm_id):
    vms.pop(vm_id, None)
    return "", 204

if __name__ == "__main__":
    app.run(port=8080, threaded=True)
</code></pre>
<p>We'll run this as its own process, alongside the cluster, listening on the port the operator will be configured to call. Nothing about it knows Kubernetes exists, which is the point: it's standing in for a real cloud API.</p>
<h3 id="heading-defining-the-virtualmachine-crd">Defining the VirtualMachine CRD</h3>
<p>With something to control, we can define what we're controlling. The <code>VirtualMachine</code> type follows exactly the spec and status split from Part 2:</p>
<pre><code class="language-go">type VirtualMachineSpec struct {
	Image  string `json:"image"`
	CPU    int    `json:"cpu"`
	Memory string `json:"memory"`
}

type VirtualMachineStatus struct {
	ID    string `json:"id,omitempty"`    // provider-assigned id, empty until first provisioned
	Phase string `json:"phase,omitempty"` // mirrors the provider's lifecycle phase
}

type VirtualMachine struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`

	Spec   VirtualMachineSpec   `json:"spec,omitempty"`
	Status VirtualMachineStatus `json:"status,omitempty"`
}

type VirtualMachineList struct {
	metav1.TypeMeta `json:",inline"`
	metav1.ListMeta `json:"metadata,omitempty"`
	Items           []VirtualMachine `json:"items"`
}
</code></pre>
<p><code>TypeMeta</code> carries <code>kind</code> and <code>apiVersion</code>, the same two fields on every Kubernetes object, built-in or custom, that say what this thing is.</p>
<p><code>ListMeta</code> is its counterpart for list types, <code>resourceVersion</code> and <code>continue</code> for pagination (instead of <code>name</code>/<code>namespace</code>). This is why <code>VirtualMachineList</code> embeds <code>ListMeta</code> next to its <code>TypeMeta</code> while <code>VirtualMachine</code> itself embeds <code>ObjectMeta</code>.</p>
<p>Every type we register needs to satisfy <code>runtime.Object</code>, which means implementing <code>DeepCopyObject</code>. This is normally generated for us, but since we're doing this by hand, here's what that generated code actually looks like for <code>VirtualMachine</code>. The rest follow the same mechanical pattern:</p>
<pre><code class="language-go">func (in *VirtualMachine) DeepCopyObject() runtime.Object {
	out := VirtualMachine{
		TypeMeta:   in.TypeMeta,
		ObjectMeta: *in.ObjectMeta.DeepCopy(), // ObjectMeta already knows how to copy itself
		Spec:       in.Spec,                   // no pointers or slices in Spec, a plain copy is safe
		Status:     in.Status,
	}
	return &amp;out
}
</code></pre>
<p>And the CRD manifest that teaches the API server about it:</p>
<pre><code class="language-yaml">apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: virtualmachines.compute.example.com
spec:
  group: compute.example.com
  scope: Namespaced
  names:
    kind: VirtualMachine
    listKind: VirtualMachineList
    plural: virtualmachines
    singular: virtualmachine
    shortNames: [vm] # lets us type `kubectl get vm` instead of the full plural
  versions:
    - name: v1
      served: true
      storage: true
      subresources:
        status: {} # splits status into its own subresource, see Part 2
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              required: [image, cpu, memory]
              properties:
                image: { type: string }
                cpu: { type: integer }
                memory: { type: string }
            status:
              type: object
              properties:
                phase: { type: string }
                id: { type: string }
</code></pre>
<p>The <code>subresources.status</code> line matters. It's what makes status a separate subresource with its own update path. This is exactly the boundary we talked about in Part 2 between what a client can write and what only the controller can.</p>
<p>The <code>names</code> block is also what <code>kubectl</code> resolves against, <code>kubectl get virtualmachines</code> works because <code>plural</code> says so. <code>shortNames</code> is why <code>kubectl get vm</code> works too, the same way <code>kubectl get po</code> works for Pods.</p>
<h3 id="heading-reconciler">Reconciler</h3>
<p>The reconciler's job is small on paper, look at a <code>VirtualMachine</code>, and make sure a matching VM exists in the provider and its status reflects reality. We wrap the provider's HTTP API behind a small client so the reconciler itself stays readable:</p>
<pre><code class="language-go">func (r *VirtualMachineReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	var vm computev1.VirtualMachine
	if err := r.Get(ctx, req.NamespacedName, &amp;vm); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err) // object was deleted, nothing left to do
	}

	if vm.Status.ID == "" {
		// no VM yet, this is the first time we've seen this object
		created, err := r.Provider.Create(ctx, vm.Spec.Image)
		if err != nil {
			return ctrl.Result{}, err
		}

		vm.Status.ID = created.ID
		vm.Status.Phase = created.Phase
		if err := r.Status().Update(ctx, &amp;vm); err != nil {
			return ctrl.Result{}, err
		}

		return ctrl.Result{RequeueAfter: 2 * time.Second}, nil // check back shortly instead of blocking here
	}

	// VM already exists, poll the provider for whatever it knows right now
	current, err := r.Provider.Get(ctx, vm.Status.ID)
	if err != nil {
		return ctrl.Result{}, err
	}

	vm.Status.Phase = current.Phase
	if err := r.Status().Update(ctx, &amp;vm); err != nil {
		return ctrl.Result{}, err
	}

	if current.Phase != "Running" {
		return ctrl.Result{RequeueAfter: 2 * time.Second}, nil // still provisioning, keep polling
	}

	return ctrl.Result{}, nil
}
</code></pre>
<p>Two things are worth calling out. First, this is only reachable at all because we've registered a watch on <code>VirtualMachine</code>. The API server tells us the moment one is created or edited, which is what triggers the first call.</p>
<p>Second, every branch ends by writing to <code>vm.Status</code>, mapping whatever the provider told us onto the resource. Kubernetes never talks to the provider directly. The only way anyone finds out a VM is running is because our reconciler wrote it into status.</p>
<h3 id="heading-failure-handling-amp-retries">Failure Handling &amp; Retries</h3>
<p>Notice the reconciler above never retries anything itself. When <code>r.Provider.Create</code> or <code>r.Provider.Get</code> fails (like because of a network blip or the mock provider not being up yet), it just returns the error. That's deliberate. Returning an error is how we ask controller-runtime to requeue with exponential backoff on our behalf. This means we don't need to hand-roll a retry loop, and a persistently unreachable provider doesn't get flooded with retries.</p>
<p>The one thing worth being careful about is treating every failure the same way. A timeout talking to the provider is worth retrying. A <code>VirtualMachine</code> whose <code>spec.image</code> the provider will never accept is not. Retrying that forever just produces a busy loop that never succeeds.</p>
<p>We'll leave surfacing that distinction through status conditions to the exercises. The reconciler above only has one failure mode to worry about, since the mock provider never rejects a request outright.</p>
<h3 id="heading-finalizer">Finalizer</h3>
<p>If we delete a <code>VirtualMachine</code> right now, Kubernetes removes the object and we're left with an orphaned VM the provider still thinks is running. A finalizer closes that gap: it's a string on the object that tells Kubernetes "don't actually delete this until I say so."</p>
<pre><code class="language-go">const vmFinalizer = "compute.example.com/vm-cleanup"

func (r *VirtualMachineReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	var vm computev1.VirtualMachine
	if err := r.Get(ctx, req.NamespacedName, &amp;vm); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	if !vm.DeletionTimestamp.IsZero() {
		// being deleted, deprovision through the provider before letting it go
		if controllerutil.ContainsFinalizer(&amp;vm, vmFinalizer) {
			if vm.Status.ID != "" {
				if err := r.Provider.Delete(ctx, vm.Status.ID); err != nil {
					return ctrl.Result{}, err
				}
			}
			controllerutil.RemoveFinalizer(&amp;vm, vmFinalizer) // safe to let the delete proceed now
			return ctrl.Result{}, r.Update(ctx, &amp;vm)
		}
		return ctrl.Result{}, nil
	}

	if !controllerutil.ContainsFinalizer(&amp;vm, vmFinalizer) {
		controllerutil.AddFinalizer(&amp;vm, vmFinalizer) // register before we ever provision anything
		if err := r.Update(ctx, &amp;vm); err != nil {
			return ctrl.Result{}, err
		}
	}

	// ... provisioning logic from before
	return ctrl.Result{}, nil
}
</code></pre>
<p>A <code>kubectl delete</code> on a <code>VirtualMachine</code> with our finalizer present doesn't remove it. Rather, it sets <code>deletionTimestamp</code> and waits.</p>
<p>Our reconciler sees that on the next call, deprovisions the VM through the provider, and only then removes the finalizer. At this point Kubernetes finally deletes the object. If there's no finalizer, there's no guarantee that cleanup ever runs.</p>
<h3 id="heading-predicate">Predicate</h3>
<p>There's a subtle bug already sitting in the reconciler above. Every time it calls <code>r.Status().Update</code>, that write is itself a change to the object. This triggers our own watch, which calls reconcile again.</p>
<p>Left alone, this doesn't spin forever, since we're recomputing the same status until it settles. But it's still wasted work reconciling in response to writes we made ourselves.</p>
<p>A predicate filters those events that actually enqueue a reconcile, before our code ever runs:</p>
<pre><code class="language-go">func (r *VirtualMachineReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&amp;computev1.VirtualMachine{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). // drop status only events
		Complete(r)
}
</code></pre>
<p><code>generation</code> only increments when <code>spec</code> changes. Status updates don't touch it. <code>GenerationChangedPredicate</code> uses that to drop events where nothing but status moved, so our own writes stop retriggering us. Then we're back to reconciling only when something meaningful changed, or when we explicitly ask to be requeued.</p>
<h3 id="heading-owned-resources">Owned Resources</h3>
<p>A <code>VirtualMachine</code> being <code>Running</code> somewhere isn't very useful on its own, so let's make the operator also create a <code>Secret</code> holding the VM's connection details in-cluster:</p>
<pre><code class="language-go">func (r *VirtualMachineReconciler) reconcileConnectionSecret(ctx context.Context, vm *computev1.VirtualMachine) error {
	secret := &amp;corev1.Secret{
		ObjectMeta: metav1.ObjectMeta{
			Name:      vm.Name + "-connection",
			Namespace: vm.Namespace,
		},
		StringData: map[string]string{"id": vm.Status.ID},
	}

	if err := controllerutil.SetControllerReference(vm, secret, r.Scheme); err != nil {
		return err // ties the Secret's lifecycle to this VirtualMachine
	}

	return r.Patch(ctx, secret, client.Apply, client.ForceOwnership, client.FieldOwner("vmoperator")) // create or update, either way
}
</code></pre>
<p><code>SetControllerReference</code> is what makes this an <strong>owned resource</strong>, it stamps an owner reference onto the <code>Secret</code> pointing back at the <code>VirtualMachine</code>.</p>
<p>Two things fall out of that for free. Deleting the <code>VirtualMachine</code> now cascades, Kubernetes garbage collects the <code>Secret</code> automatically, and there's no finalizer needed since it's an in-cluster object, not an external one.</p>
<p>And if we add <code>Owns(&amp;corev1.Secret{})</code> alongside <code>For(&amp;computev1.VirtualMachine{})</code> in <code>SetupWithManager</code>, an edit or deletion of the <code>Secret</code> itself re-triggers reconciliation of its owning <code>VirtualMachine</code>. So if someone deletes it by hand, we notice and recreate it.</p>
<p>The same pattern, <code>SetControllerReference</code> call, and <code>Owns()</code> registration creates a second owned resource: a <code>Service</code> fronting the VM in-cluster. That's two different resource kinds owned by one <code>VirtualMachine</code>, which is all <strong>multiple owned resources</strong> means in practice. There's nothing more to it than calling the same pattern twice for different types.</p>
<h3 id="heading-cross-resource-reconciliation">Cross-Resource Reconciliation</h3>
<p>Every <code>VirtualMachine</code> so far talks to one hardcoded provider endpoint. Real deployments need that to be configurable, and it's rarely a one-off: fifty <code>VirtualMachine</code>s in the same AWS account share the same endpoint and credentials, and a hundred more might live in Azure instead.</p>
<p>We could put an <code>endpoint</code> field directly on <code>VirtualMachineSpec</code>, but rotating a credential or fixing a typo would then mean editing every <code>VirtualMachine</code> that uses it, one at a time. Pulling that into its own object lets many <code>VirtualMachine</code>s reference it by name instead, so a single edit propagates to all of them.</p>
<p>Now let's add a second, small CRD:</p>
<pre><code class="language-go">type ProviderConfigSpec struct {
	Endpoint string `json:"endpoint"`
}
</code></pre>
<p>And a <code>providerRef</code> field on <code>VirtualMachineSpec</code> pointing at one by name. The interesting part isn't the new type. It's what happens when a <code>ProviderConfig</code> changes.</p>
<p>A <code>VirtualMachine</code> doesn't watch <code>ProviderConfig</code> directly, and there's no owner reference between them, so a plain <code>Owns()</code> won't do it. Instead, we watch the type and map each event onto every <code>VirtualMachine</code> that references it:</p>
<img src="https://raw.githubusercontent.com/karanpratapsingh/portfolio/refs/heads/master/public/static/blogs/kubernetes-operators/cross-resource-fanout.png" alt="cross-resource reconciliation fan-out" style="display:block;margin:0 auto" width="820" height="380" loading="lazy">

<pre><code class="language-go">func (r *VirtualMachineReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&amp;computev1.VirtualMachine{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
		Owns(&amp;corev1.Secret{}).
		Owns(&amp;corev1.Service{}).
		Watches(
			&amp;computev1.ProviderConfig{}, // not owned, so Owns() won't catch its changes
			handler.EnqueueRequestsFromMapFunc(r.findVirtualMachinesForProviderConfig),
		).
		Complete(r)
}

func (r *VirtualMachineReconciler) findVirtualMachinesForProviderConfig(ctx context.Context, obj client.Object) []reconcile.Request {
	var vms computev1.VirtualMachineList
	if err := r.List(ctx, &amp;vms, client.InNamespace(obj.GetNamespace())); err != nil {
		return nil
	}

	var requests []reconcile.Request
	for _, vm := range vms.Items {
		if vm.Spec.ProviderRef == obj.GetName() { // only re-enqueue VMs that actually reference this config
			requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&amp;vm)})
		}
	}
	return requests
}
</code></pre>
<p>This is <strong>cross-resource reconciliation</strong>: one resource's change causing a different resource type entirely to reconcile, connected only by a field value rather than ownership.</p>
<p>It's also where the theme of this whole project comes back around. <code>ProviderConfig</code> is what would hold real credentials and a real endpoint for AWS, Azure, or GCP in a production version of this operator. The mock provider is standing in for exactly that boundary.</p>
<h3 id="heading-rbac">RBAC</h3>
<p>None of the above works without permission to act on it. The manifest just has to list what we actually touch: <code>VirtualMachine</code> and <code>ProviderConfig</code> objects, the <code>VirtualMachine</code> status subresource separately, and the <code>Secret</code>/<code>Service</code> objects we create:</p>
<pre><code class="language-yaml">apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: vmoperator-manager-role
rules:
  - apiGroups: ['compute.example.com']
    resources: ['virtualmachines', 'providerconfigs']
    verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete']
  - apiGroups: ['compute.example.com']
    resources: ['virtualmachines/status'] # separate rule, it's a separate subresource
    verbs: ['get', 'update', 'patch']
  - apiGroups: ['']
    resources: ['secrets', 'services']
    verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete']
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: vmoperator-manager-rolebinding
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: vmoperator-manager-role
subjects:
  - kind: ServiceAccount
    name: vmoperator-controller-manager
    namespace: vmoperator-system
</code></pre>
<p><strong>Note:</strong> VMOperator manages a resource entirely outside the cluster through a hand-rolled HTTP client, but it's not a novel pattern. <a href="https://www.crossplane.io/">Crossplane</a>, <a href="https://aws-controllers-k8s.github.io/community/">AWS Controllers for Kubernetes</a>, <a href="https://cluster-api.sigs.k8s.io/">Cluster API</a>, and cert-manager all reconcile external or non-Kubernetes state through CRDs the same way. These resources are worth reading once this pattern feels familiar.</p>
<p><strong>Another note:</strong> we're keeping VMOperator's scope narrow on purpose. Resizing a running VM, stopping and restarting one, taking snapshots, and supporting more than one real provider behind <code>ProviderConfig</code> are all natural extensions of what's here, and a reasonable next step once the core loop feels solid.</p>
<h2 id="heading-part-4-production-amp-deployment">Part 4: Production &amp; Deployment</h2>
<p>Now that VMOperator works, let's see how to package and deploy it and improve it for production.</p>
<h3 id="heading-packaging-amp-deployment">Packaging &amp; Deployment</h3>
<p>Everything so far has run as a binary on our own machine. <code>go run</code> against whatever cluster <code>kubectl</code> happens to be pointed at.</p>
<p>A <code>Deployment</code> needs an image instead, so the operator gets a multi-stage <code>Dockerfile</code>: one stage to compile it, and a second, much smaller image to actually run it:</p>
<pre><code class="language-dockerfile">FROM golang:1.26 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /vmoperator ./cmd/manager

FROM gcr.io/distroless/static-debian12
COPY --from=build /vmoperator /vmoperator
USER 65532:65532 # nonroot, matches the security context on the Deployment below
ENTRYPOINT ["/vmoperator"]
</code></pre>
<p>The build stage has the full Go toolchain and every source file, none of which need to ship. The final image only has the compiled binary, which covers most of what a container security context later in this part would otherwise have to ask for. There's no shell to get a foothold in even before <code>runAsNonRoot</code> is set.</p>
<pre><code class="language-bash">docker build -t registry.example.com/vmoperator:v0.1.0 .
docker push registry.example.com/vmoperator:v0.1.0
</code></pre>
<p>That image is what the <code>Deployment</code> manifest under <code>config/manager/</code> actually references. With it pushed somewhere the cluster can pull from, the rest of the manifests can go on: the CRDs, RBAC, the operator's <code>Deployment</code>, and a <code>Deployment</code> and <code>Service</code> for the mock provider. So it's no longer something we run as a side process on our own machine either:</p>
<pre><code class="language-bash">kubectl apply -f config/crd/
kubectl apply -f config/rbac/
kubectl apply -f config/manager/
</code></pre>
<p>Installing the CRDs before anything else matters. Otherwise the operator's <code>Deployment</code> will crash-loop if it starts and immediately (it tries to watch a resource type the API server has never heard of).</p>
<p>Schema changes are the part that hand-written manifests make us feel directly. Adding a field to <code>VirtualMachineSpec</code> is harmless, but existing objects just don't have it set. Renaming or restructuring one isn't: every stored <code>VirtualMachine</code> was serialized against the old shape.</p>
<p>The CRD's <code>versions</code> list is built for exactly this, as more than one version can be <code>served</code> at once. One is marked <code>storage</code> to say which shape objects are actually persisted as, and a conversion webhook translates between them when a client asks for a version that isn't the stored one.</p>
<p>We don't need this for VMOperator today, since <code>v1</code> is the only version that's ever existed. But it's why the <code>versions</code> field was a list and not a single value from the very first manifest we wrote.</p>
<p>None of the above replaces a person running <code>kubectl apply</code> by hand forever. A CI pipeline that builds the operator's image, pushes it, and applies the manifests on merge to main is the natural next step. This is ordinary CI/CD, nothing operator-specific about it once the manifests themselves are in Git.</p>
<h3 id="heading-performance-amp-resilience">Performance &amp; Resilience</h3>
<p>By default, a controller only processes one reconcile at a time. That's fine while we're the only ones testing it, but with hundreds of <code>VirtualMachine</code> objects it means that most of them sit in the workqueue waiting their turn even though nothing about reconciling one blocks reconciling another.</p>
<p><code>MaxConcurrentReconciles</code> raises that:</p>
<pre><code class="language-go">func (r *VirtualMachineReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&amp;computev1.VirtualMachine{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
		Owns(&amp;corev1.Secret{}).
		Owns(&amp;corev1.Service{}).
		Watches(&amp;computev1.ProviderConfig{}, handler.EnqueueRequestsFromMapFunc(r.findVirtualMachinesForProviderConfig)).
		WithOptions(controller.Options{MaxConcurrentReconciles: 5}). // five VMs in flight instead of one
		Complete(r)
}
</code></pre>
<p>Caching only helps one side of this reconciler. Reading <code>vm</code> back from <code>r.Get</code> is already fast and local, as informers keep that in memory. But <code>r.Provider.Get</code> is a real HTTP round trip every single time, and there's no cache in front of it.</p>
<p>That asymmetry is worth sitting with, because it's the same one from Part 2: in-cluster reads are cheap because Kubernetes built the caching layer for us, and external reads are exactly as expensive as whatever's on the other end of the wire. We could add a short-lived cache in front of the provider client, but it comes with a real cost: a cached <code>Running</code> for a VM that just failed is a lie our status will repeat until the cache expires.</p>
<p>The provider not having a cache in front of it also means nothing is stopping us from hammering it. A burst of reconciles, say every <code>VirtualMachine</code> getting touched at once after a cluster restart, turns into a burst of HTTP calls with no coordination between them. Wrapping the client in a rate limiter caps that independently of whatever backoff the workqueue is already doing on failures:</p>
<pre><code class="language-go">type Client struct {
	baseURL string
	http    *http.Client
	limiter *rate.Limiter // shared across every reconcile using this client
}

func (c *Client) Create(ctx context.Context, image string) (*VM, error) {
	if err := c.limiter.Wait(ctx); err != nil {
		return nil, err
	}
	// ... existing HTTP call
}
</code></pre>
<p>Leader election is the other half of running more than one replica safely. We turned this down to a concept in Part 2, but in practice it's two fields on the manager:</p>
<pre><code class="language-go">mgr, err := ctrl.NewManager(cfg, ctrl.Options{
	LeaderElection:   true,
	LeaderElectionID: "vmoperator-leader",
})
</code></pre>
<p>With this set, every replica starts up, but only the one holding the lease actually reconciles. The rest sit ready to take over the moment it doesn't renew in time.</p>
<p>Concurrency also surfaces a race we glossed over in Part 3. Say the reconciler calls <code>r.Provider.Create</code>, the provider creates the VM and returns its id, and then the process crashes before <code>r.Status().Update</code> ever runs. <code>vm.Status.ID</code> is still empty, so the next reconcile sees an object with no VM yet and calls <code>Create</code> again. Now the provider has two VMs for one <code>VirtualMachine</code>.</p>
<p>Nothing about <code>MaxConcurrentReconciles</code> or leader election prevents this. It's a gap in the create step itself, and it only shows up once something can fail between the external call and the write that records it.</p>
<p>Closing it for real means the provider needs to accept an idempotency key, generated once and stored on the object before the first <code>Create</code> call, so a retried create recognizes that it already happened instead of making a second VM.</p>
<h3 id="heading-security">Security</h3>
<p>The <code>ClusterRole</code> from Part 3 works, but it's broader than it needs to be. It grants every verb on <code>secrets</code> and <code>services</code> cluster-wide, when the operator only ever touches the ones it owns.</p>
<p>A tighter version scopes to a single namespace with <code>Role</code>/<code>RoleBinding</code> instead of <code>ClusterRole</code>/<code>ClusterRoleBinding</code> wherever VMOperator is only expected to run in one, and it drops verbs we never call. We never <code>list</code> or <code>watch</code> arbitrary <code>Secret</code>s outside our own, only the ones we create. This is also the RBAC the manifests applied in the previous section were referring to.</p>
<p>Credentials are the other gap. <code>ProviderConfig</code> currently holds a plaintext endpoint, and a real provider needs an API key alongside it, which has no business sitting in a CRD spec anyone with read access to the object can see. It belongs in a <code>Secret</code>, referenced by name instead of embedded:</p>
<pre><code class="language-go">type ProviderConfigSpec struct {
	Endpoint  string                      `json:"endpoint"`
	SecretRef corev1.LocalObjectReference `json:"secretRef"` // Secret holding the provider's API key
}
</code></pre>
<p>The reconciler resolves <code>SecretRef</code> at the point it builds the provider client, reads the key out of the <code>Secret</code>'s data, and never logs it or writes it back to anything with wider read access, including the <code>VirtualMachine</code>'s own status.</p>
<p>The last piece is the operator's own pod. A container security context that runs as a non-root user sets a read-only root filesystem, drops Linux capabilities it doesn't need, and shrinks what's possible if the binary itself is ever compromised. This is standard practice for any workload, not something specific to operators.</p>
<p>If we'd added an admission webhook anywhere in this guide, its certificates would belong here too. We didn't need one for VMOperator, so we'll leave that as a pointer rather than something to configure.</p>
<h3 id="heading-observability">Observability</h3>
<p>The manager exposes a Prometheus endpoint without us writing anything for it. Workqueue depth, reconcile duration, and reconcile error counts are already there per controller.</p>
<p>What isn't there automatically is anything about the provider, so we add a metric the same way any Go service would:</p>
<pre><code class="language-go">var providerCallDuration = prometheus.NewHistogramVec(
	prometheus.HistogramOpts{
		Name: "vmoperator_provider_call_duration_seconds",
		Help: "Duration of calls to the VM provider, by operation",
	},
	[]string{"operation"},
)

func init() {
	metrics.Registry.MustRegister(providerCallDuration) // shares the manager's existing /metrics endpoint
}
</code></pre>
<p>Wrapping each provider call with a timer around this turns "is the provider slow" from a question we'd have to guess at into one we can graph.</p>
<p>Logging benefits from the same instinct. <code>log.FromContext(ctx)</code> inside <code>Reconcile</code> already carries the <code>VirtualMachine</code>'s name and namespace on every line if we set that up once in <code>SetupWithManager</code>. Adding <code>vm.Status.ID</code> to that logger right after it's set means every subsequent log line for that reconcile also carries the provider's own identifier for the VM. That one field is what makes it possible to grep a mock provider log and an operator log for the same request and find both sides of the same failure.</p>
<h2 id="heading-next-steps">Next Steps</h2>
<p>In this guide, you learned what a Kubernetes operator is, how to build one from scratch, and how to prepare it for production. You also learned about finalizers, predicates, owned resources, and cross-resource reconciliation along the way.</p>
<p>None of this is specific to managing VMs. The next operator, whatever it manages, is the same shape.</p>
<p>You can also review the resources below to keep learning:</p>
<ul>
<li><p><a href="https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/">K8s custom resources</a></p>
</li>
<li><p><a href="https://github.com/kubernetes/client-go">client-go</a></p>
</li>
<li><p><a href="https://github.com/kubernetes-sigs/controller-runtime">controller-runtime</a></p>
</li>
<li><p><a href="https://docs.docker.com/build/">Docker docs</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Implement PayPal in a Microservice Architecture Using NestJS, gRPC, and Docker ]]>
                </title>
                <description>
                    <![CDATA[ In this tutorial, you'll build a production-ready PayPal payment service using NestJS microservices. Along the way, you'll learn how to isolate payment logic into its own service, communicate between  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-implement-paypal-in-a-microservice-architecture-using-nestjs-grpc-and-docker/</link>
                <guid isPermaLink="false">6a59619ee14c719ac88d7a33</guid>
                
                    <category>
                        <![CDATA[ Microservices ]]>
                    </category>
                
                    <category>
                        <![CDATA[ PayPal ]]>
                    </category>
                
                    <category>
                        <![CDATA[ payments ]]>
                    </category>
                
                    <category>
                        <![CDATA[ nestjs ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ containers ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Md Tarikul Islam ]]>
                </dc:creator>
                <pubDate>Thu, 16 Jul 2026 22:56:30 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/665e54b7-b47e-4abe-a417-49b51569868f.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this tutorial, you'll build a production-ready PayPal payment service using NestJS microservices. Along the way, you'll learn how to isolate payment logic into its own service, communicate between services using gRPC, publish payment events with RabbitMQ, and deploy everything with Docker.</p>
<p>By the end, you'll have a scalable payment architecture that can be reused across multiple business domains.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-introduction">Introduction</a></p>
</li>
<li><p><a href="#heading-why-use-a-dedicated-payment-service">Why Use a Dedicated Payment Service?</a></p>
</li>
<li><p><a href="#heading-architecture-overview">Architecture Overview</a></p>
<ul>
<li><a href="#heading-payment-state-machine">Payment State Machine</a></li>
</ul>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-paypal-concepts-you-need-to-know">PayPal Concepts You Need to Know</a></p>
<ul>
<li><p><a href="#heading-sandbox-vs-live">Sandbox vs Live</a></p>
</li>
<li><p><a href="#heading-orders-api-flow-what-we-use">Orders API Flow (What We Use)</a></p>
</li>
<li><p><a href="#heading-environment-variables">Environment Variables</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-project-structure">Project Structure</a></p>
</li>
<li><p><a href="#heading-step-1-create-the-payment-service">Step 1 — Create the Payment Service</a></p>
</li>
<li><p><a href="#heading-step-2-define-the-grpc-contract">Step 2 — Define the gRPC Contract</a></p>
</li>
<li><p><a href="#heading-step-3-implement-the-paypal-service">Step 3 — Implement the PayPal Service</a></p>
</li>
<li><p><a href="#heading-step-4-build-the-payment-flow-create-approve-capture">Step 4 — Build the Payment Flow (Create → Approve → Capture)</a></p>
<ul>
<li><p><a href="#heading-4a-create-payment">4a. Create Payment</a></p>
</li>
<li><p><a href="#heading-4b-user-approves-on-paypal">4b. User Approves on PayPal</a></p>
</li>
<li><p><a href="#heading-4c-capture-payment">4c. Capture Payment</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-step-5-connect-domain-services-via-grpc">Step 5 — Connect Domain Services via gRPC</a></p>
<ul>
<li><a href="#heading-domain-service-business-logic-example">Domain Service Business Logic Example</a></li>
</ul>
</li>
<li><p><a href="#heading-step-6-add-the-api-gateway-layer">Step 6 — Add the API Gateway Layer</a></p>
</li>
<li><p><a href="#heading-step-7-publish-payment-events-with-rabbitmq">Step 7 — Publish Payment Events with RabbitMQ</a></p>
<ul>
<li><a href="#heading-two-paths-to-mark-an-order-as-paid">Two Paths to Mark an Order as Paid</a></li>
</ul>
</li>
<li><p><a href="#heading-step-8-database-schema-and-migrations">Step 8 — Database Schema and Migrations</a></p>
<ul>
<li><a href="#heading-production-migration-gotcha">Production Migration Gotcha</a></li>
</ul>
</li>
<li><p><a href="#heading-step-9-local-development-setup-docker">Step 9 — Local Development Setup (Docker)</a></p>
<ul>
<li><p><a href="#heading-environment-variables-env">Environment Variables (.env)</a></p>
</li>
<li><p><a href="#heading-docker-compose-local">Docker Compose (Local)</a></p>
</li>
<li><p><a href="#heading-start-services">Start Services</a></p>
</li>
<li><p><a href="#heading-verify-health">Verify Health</a></p>
</li>
<li><p><a href="#heading-test-payment-flow">Test Payment Flow</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-step-10-production-deployment">Step 10 — Production Deployment</a></p>
<ul>
<li><p><a href="#heading-paypal-live-credentials">PayPal Live Credentials</a></p>
</li>
<li><p><a href="#heading-production-env-on-server-never-commit">Production .env</a></p>
</li>
<li><p><a href="#heading-docker-compose-production">Docker Compose (Production)</a></p>
</li>
<li><p><a href="#heading-deploy-commands">Deploy Commands</a></p>
</li>
<li><p><a href="#heading-verify-production">Verify Production</a></p>
</li>
<li><p><a href="#heading-frontend-domain-in-production">Frontend Domain in Production</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-step-11-health-checks-and-monitoring">Step 11 — Health Checks and Monitoring</a></p>
</li>
<li><p><a href="#heading-complete-request-flow-real-example">Complete Request Flow (Real Example)</a></p>
</li>
<li><p><a href="#heading-coupon-support-optional">Coupon Support (Optional)</a></p>
</li>
<li><p><a href="#heading-paypal-webhooks-optional-but-recommended">PayPal Webhooks (Optional but Recommended)</a></p>
</li>
<li><p><a href="#heading-testing-checklist">Testing Checklist</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
<li><p><a href="#heading-further-reading">Further Reading</a></p>
</li>
</ul>
<h2 id="heading-introduction">Introduction</h2>
<p>Payment logic doesn't belong inside every microservice. When you scatter PayPal API calls across <code>user-service</code>, <code>order-service</code>, and <code>billing-service</code>, you end up with:</p>
<ul>
<li><p>Duplicated PayPal credentials and SDK code</p>
</li>
<li><p>Inconsistent error handling and idempotency</p>
</li>
<li><p>Hard-to-audit payment records</p>
</li>
<li><p>Painful environment switching (sandbox to live)</p>
</li>
</ul>
<p>The solution is a dedicated payment microservice that owns all PayPal interactions. Other services call it over gRPC, and payment outcomes are broadcast over RabbitMQ so domain services can update their own data.</p>
<p>This guide walks you through that pattern using a real-world stack:</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Technology</th>
</tr>
</thead>
<tbody><tr>
<td>Payment service</td>
<td>NestJS</td>
</tr>
<tr>
<td>Inter-service communication</td>
<td>gRPC</td>
</tr>
<tr>
<td>Event bus</td>
<td>RabbitMQ</td>
</tr>
<tr>
<td>Database</td>
<td>PostgreSQL</td>
</tr>
<tr>
<td>API exposure</td>
<td>API Gateway (HTTP)</td>
</tr>
<tr>
<td>Containerization</td>
<td>Docker Compose</td>
</tr>
<tr>
<td>PayPal API</td>
<td>Orders v2 (Create, Approve, Capture)</td>
</tr>
</tbody></table>
<h2 id="heading-why-use-a-dedicated-payment-service">Why Use a Dedicated Payment Service?</h2>
<p>A dedicated payment service centralizes all payment-related responsibilities in one place. Instead of every microservice communicating directly with PayPal, they simply request payment operations from the payment service.</p>
<p>This service manages PayPal authentication, order creation, payment captures, wallet updates, ledger records, and webhook processing. Meanwhile, domain services remain focused on business logic such as student applications or subscriptions.</p>
<p>Domain services only need to know:</p>
<ol>
<li><p>How much to charge</p>
</li>
<li><p>Who is paying</p>
</li>
<li><p>What business entity the payment is for (<code>referenceId</code>)</p>
</li>
<li><p>Where to redirect the user after payment (<code>returnUrl</code> / <code>cancelUrl</code>)</p>
</li>
</ol>
<p>They do <strong>not</strong> need PayPal credentials.</p>
<h2 id="heading-architecture-overview">Architecture Overview</h2>
<p>Users initiate payments from the Frontend, and requests are routed through the API Gateway to the Students Service. The service uses gRPC to communicate with the Payment Service, which handles all interactions with PayPal.</p>
<p>Once the payment is completed, the Payment Service publishes an event to RabbitMQ, enabling the Students Service to update the payment status asynchronously.</p>
<pre><code class="language-plaintext">┌────────────────────────────────────────────────────────────┐
│                     PRESENTATION LAYER                     │
├────────────────────────────────────────────────────────────┤
│ Frontend (React)                                           │
└───────────────────────┬────────────────────────────────────┘
                        │ HTTP
                        ▼

┌────────────────────────────────────────────────────────────┐
│                       GATEWAY LAYER                        │
├────────────────────────────────────────────────────────────┤
│ student-apigw                                               │
└───────────────────────┬────────────────────────────────────┘
                        │ gRPC
                        ▼

┌────────────────────────────────────────────────────────────┐
│                       DOMAIN LAYER                         │
├────────────────────────────────────────────────────────────┤
│ students-service                                            │
└───────────────────────┬────────────────────────────────────┘
                        │ gRPC
                        ▼

┌────────────────────────────────────────────────────────────┐
│                      PAYMENT LAYER                         │
├────────────────────────────────────────────────────────────┤
│ payment-service                                             │
│                                                            │
│ • Create Payment                                           │
│ • Capture Payment                                          │
│ • Wallet Management                                        │
│ • Ledger                                                   │
│ • Webhooks                                                 │
│ • Event Publishing                                         │
└──────────────┬───────────────────────┬─────────────────────┘
               │                       │
               │ REST                  │ RabbitMQ
               ▼                       ▼

      ┌───────────────┐      ┌────────────────────┐
      │    PayPal     │      │   payment_events   │
      │   Checkout    │      │       Queue        │
      └───────────────┘      └─────────┬──────────┘
                                       │
                                       ▼

                           ┌────────────────────┐
                           │ students-service   │
                           │ Event Consumer     │
                           └────────────────────┘
</code></pre>
<h3 id="heading-payment-state-machine">Payment State Machine</h3>
<p>A payment state machine represents the lifecycle of a payment, tracking its progress from creation to completion (or failure). Each state reflects the current status of the payment, making it easier to monitor, retry, and prevent invalid operations.</p>
<pre><code class="language-plaintext">NOT_STARTED → EXECUTING → SUCCESS
                      └→ FAILED
</code></pre>
<ul>
<li><p><strong>NOT_STARTED</strong> — order record created in DB</p>
</li>
<li><p><strong>EXECUTING</strong> — PayPal order created, waiting for user approval</p>
</li>
<li><p><strong>SUCCESS</strong> — funds captured, ledger updated, event published</p>
</li>
<li><p><strong>FAILED</strong> — capture failed or user cancelled</p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you start, make sure you have:</p>
<ul>
<li><p><a href="https://nodejs.org/">Node.js 18+</a></p>
</li>
<li><p><a href="https://docs.docker.com/get-docker/">Docker and Docker Compose</a></p>
</li>
<li><p><a href="https://nestjs.com/">NestJS</a> basics</p>
</li>
<li><p>A <a href="https://developer.paypal.com/">PayPal Developer</a> account</p>
</li>
<li><p>Basic understanding of gRPC and message queues</p>
</li>
</ul>
<h2 id="heading-paypal-concepts-you-need-to-know">PayPal Concepts You Need to Know</h2>
<p>Before integrating PayPal, it's helpful to understand a few core concepts. PayPal provides separate environments for development and production, along with an order-based payment workflow that your application follows.</p>
<h3 id="heading-sandbox-vs-live">Sandbox vs Live</h3>
<table>
<thead>
<tr>
<th>Environment</th>
<th>API Base URL</th>
<th>Checkout URL</th>
</tr>
</thead>
<tbody><tr>
<td>Sandbox (dev)</td>
<td><code>https://api-m.sandbox.paypal.com</code></td>
<td><code>https://www.sandbox.paypal.com/checkoutnow?token=...</code></td>
</tr>
<tr>
<td>Live (prod)</td>
<td><code>https://api-m.paypal.com</code></td>
<td><code>https://www.paypal.com/checkoutnow?token=...</code></td>
</tr>
</tbody></table>
<p>Always develop in <strong>sandbox</strong>. Switch to live only in production.</p>
<h3 id="heading-orders-api-flow-what-we-use">Orders API Flow (What We Use)</h3>
<p>PayPal's Orders v2 API follows three steps:</p>
<ol>
<li><p><strong>Create Order</strong>: your backend creates an order with amount and return URLs</p>
</li>
<li><p><strong>Approve</strong>: user is redirected to PayPal and approves payment</p>
</li>
<li><p><strong>Capture</strong>: your backend captures the approved funds</p>
</li>
</ol>
<p>This is different from the older Payments REST API. Orders v2 is the recommended approach for new integrations.</p>
<h3 id="heading-environment-variables">Environment Variables</h3>
<p>The PayPal service reads its configuration from environment variables. This keeps sensitive credentials out of your source code and makes it easy to switch between sandbox and production environments.</p>
<pre><code class="language-bash">PAYPAL_CLIENT_ID=your_client_id
PAYPAL_CLIENT_SECRET=your_client_secret
PAYPAL_API_BASE=https://api-m.sandbox.paypal.com   # or https://api-m.paypal.com for live
</code></pre>
<p>Never commit real credentials to Git. Use <code>.env</code> files and Docker environment injection.</p>
<h2 id="heading-project-structure">Project Structure</h2>
<pre><code class="language-plaintext">apps/
├── core/
│   └── payment-service/          # Owns all PayPal logic
│       ├── src/
│       │   ├── app/payment/
│       │   │   ├── paypal/paypal.service.ts
│       │   │   ├── payment.service.ts
│       │   │   ├── payment.grpc.controller.ts
│       │   │   ├── payment.http.controller.ts
│       │   │   └── events/payment-events.publisher.ts
│       │   ├── migrations/       # DB schema
│       │   └── routes/health.routes.ts
│       └── Dockerfile
├── services/
│   └── students-service/         # Domain service example
│       └── src/app/payment/
│           ├── payment-client.service.ts      # gRPC client
│           ├── application-payment.service.ts # business logic
│           └── payment-events.consumer.ts     # RabbitMQ listener
└── gateways/
    └── student-apigw/            # HTTP API for frontend
libs/
└── shared/dto/src/lib/payment/
    └── payment.proto             # Shared gRPC contract
</code></pre>
<h2 id="heading-step-1-create-the-payment-service">Step 1 — Create the Payment Service</h2>
<p>The payment service runs two servers in one process</p>
<table>
<thead>
<tr>
<th>Protocol</th>
<th>Port</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td>HTTP</td>
<td>3003</td>
<td>Health checks, webhooks, admin APIs</td>
</tr>
<tr>
<td>gRPC</td>
<td>50061</td>
<td>Internal service-to-service calls</td>
</tr>
</tbody></table>
<p>The payment service exposes both an HTTP server and a gRPC server in the same NestJS application. The HTTP server handles health checks, webhooks, and external requests, while the gRPC server accepts internal requests from other microservices.</p>
<pre><code class="language-typescript">
// apps/core/payment-service/src/main.ts

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Health route (outside /api prefix)
  app.use('/health', healthRouter);

  // gRPC microservice
  app.connectMicroservice&lt;MicroserviceOptions&gt;({
    transport: Transport.GRPC,
    options: {
      package: 'payment',
      protoPath: join(process.cwd(), 'libs/shared/dto/src/lib/payment/payment.proto'),
      url: `0.0.0.0:${process.env.GRPC_PORT || '50061'}`,
    },
  });

  app.setGlobalPrefix('api');
  await app.startAllMicroservices();
  await app.listen(process.env.PORT || 3003);
}
</code></pre>
<p>During startup, NestJS initializes both servers, allowing external clients and internal services to communicate through the appropriate protocol.</p>
<p><strong>Key design choice:</strong> HTTP is for external/webhook traffic. gRPC is for fast, typed internal calls between services.</p>
<h2 id="heading-step-2-define-the-grpc-contract">Step 2 — Define the gRPC Contract</h2>
<p>Next, you'll create a shared <code>.proto</code> file so all services speak the same language:</p>
<p>A gRPC contract defines the API shared between microservices. Using a <code>.proto</code> file ensures that every service communicates with the payment service using the same request and response structure, regardless of the programming language.</p>
<pre><code class="language-protobuf">// libs/shared/dto/src/lib/payment/payment.proto

syntax = "proto3";
package payment;

service PaymentService {
  rpc CreatePayment(CreatePaymentRequest) returns (CreatePaymentResponse) {}
  rpc CapturePayment(CapturePaymentRequest) returns (CapturePaymentResponse) {}
  rpc GetPaymentStatus(GetPaymentStatusRequest) returns (GetPaymentStatusResponse) {}
  rpc ListPayments(ListPaymentsRequest) returns (ListPaymentsResponse) {}
}

message CreatePaymentRequest {
  string checkout_id = 1;
  string payment_order_id = 2;
  string domain = 3;           // e.g. "application", "subscription"
  string reference_id = 4;     // business entity ID
  string payer_id = 5;
  string amount = 6;
  string currency = 7;
  string buyer_email = 8;
  string seller_account = 9;
  string payment_category = 10;
  string return_url = 11;      // PayPal redirect on success
  string cancel_url = 12;      // PayPal redirect on cancel
  string idempotency_key = 13;
  string metadata = 14;
  string description = 15;
}

message CreatePaymentResponse {
  int32 status = 1;
  string message = 2;
  string payment_order_id = 3;
  string paypal_order_id = 4;
  string approve_url = 5;      // Redirect user here
  string payment_order_status = 6;
}
</code></pre>
<p>The <code>domain</code> + <code>reference_id</code> pair lets one payment service handle payments for applications, subscriptions, university fees, and more without coupling to any single business model.</p>
<h2 id="heading-step-3-implement-the-paypal-service">Step 3 — Implement the PayPal Service</h2>
<p>Now, you'll create a dedicated <code>PayPalService</code> that wraps the PayPal REST API.</p>
<p>Instead of calling the PayPal API throughout the application, we encapsulate all PayPal communication inside a dedicated service. This keeps authentication, order creation, and payment capture logic centralized and easier to maintain.</p>
<pre><code class="language-typescript">// apps/core/payment-service/src/app/payment/paypal/paypal.service.ts

@Injectable()
export class PayPalService {
  private accessToken: string | null = null;
  private tokenExpiresAt = 0;

  private get apiBase(): string {
    return this.configService.get('PAYPAL_API_BASE')
      || 'https://api-m.sandbox.paypal.com';
  }

  // Step 1: Get OAuth access token (cached until expiry)
  private async getAccessToken(): Promise&lt;string&gt; {
    const now = Date.now();
    if (this.accessToken &amp;&amp; now &lt; this.tokenExpiresAt) {
      return this.accessToken;
    }

    const response = await axios.post(
      `${this.apiBase}/v1/oauth2/token`,
      'grant_type=client_credentials',
      {
        auth: {
          username: this.configService.get('PAYPAL_CLIENT_ID'),
          password: this.configService.get('PAYPAL_CLIENT_SECRET'),
        },
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      }
    );

    this.accessToken = response.data.access_token;
    this.tokenExpiresAt = now + (response.data.expires_in - 60) * 1000;
    return this.accessToken;
  }

  // Step 2: Create PayPal checkout order
  async createOrder(input: PayPalCreateOrderInput) {
    const token = await this.getAccessToken();

    const response = await axios.post(
      `${this.apiBase}/v2/checkout/orders`,
      {
        intent: 'CAPTURE',
        purchase_units: [{
          custom_id: input.paymentOrderId,
          description: input.description,
          amount: {
            currency_code: input.currency,
            value: input.amount,
          },
        }],
        application_context: {
          return_url: input.returnUrl,
          cancel_url: input.cancelUrl,
          brand_name: 'YourApp',
          user_action: 'PAY_NOW',
        },
      },
      {
        headers: {
          Authorization: `Bearer ${token}`,
          'PayPal-Request-Id': input.idempotencyKey,
        },
      }
    );

    const paypalOrderId = response.data.id;
    const approveUrl = response.data.links
      ?.find((l) =&gt; l.rel === 'approve')?.href;

    return { paypalOrderId, approveUrl };
  }

  // Step 3: Capture approved order
  async captureOrder(paypalOrderId: string) {
    const token = await this.getAccessToken();

    const response = await axios.post(
      `${this.apiBase}/v2/checkout/orders/${paypalOrderId}/capture`,
      {},
      { headers: { Authorization: `Bearer ${token}` } }
    );

    const capture = response.data.purchase_units?.[0]?.payments?.captures?.[0];
    return { status: response.data.status, captureId: capture?.id || '' };
  }
}
</code></pre>
<p>On startup, log configuration (with masked secrets) so you can verify sandbox vs live at a glance:</p>
<pre><code class="language-plaintext">PayPal configuration check:
  PAYPAL_API_BASE: https://api-m.paypal.com
  PAYPAL_CLIENT_ID: AQb2...aq1M (80 chars)
  credentialsPresent: true
  environment: live
</code></pre>
<p>Notice that the access token is cached until it expires. This avoids requesting a new OAuth token for every payment, improving performance and reducing unnecessary API calls.</p>
<h2 id="heading-step-4-build-the-payment-flow-create-approve-capture">Step 4 — Build the Payment Flow (Create, Approve, Capture)</h2>
<h3 id="heading-create-payment">Create Payment</h3>
<p><code>PaymentService.createPayment()</code> does the following:</p>
<ol>
<li><p>Checks <strong>idempotency key</strong> and returns an existing order if one is already created</p>
</li>
<li><p>Creates a <code>payment_events</code> checkout record</p>
</li>
<li><p>Creates a <code>payment_orders</code> row with status <code>NOT_STARTED</code></p>
</li>
<li><p>Calls <code>PayPalService.createOrder()</code></p>
</li>
<li><p>Updates order status to <code>EXECUTING</code></p>
</li>
<li><p>Returns <code>approveUrl</code> to the caller</p>
</li>
</ol>
<pre><code class="language-typescript">async createPayment(input: CreatePaymentPayload) {
  // Idempotency: prevent duplicate charges
  const existing = await this.paymentOrderModel.findOne({
    where: { idempotencyKey: input.idempotencyKey },
  });
  if (existing) return this.buildCreateResponse(existing);

  const order = await this.paymentOrderModel.create({
    paymentOrderId: input.paymentOrderId,
    amount: input.amount,
    currency: input.currency,
    paymentOrderStatus: PaymentOrderStatus.NOT_STARTED,
    domain: input.domain,
    referenceId: input.referenceId,
    // ...
  });

  const paypalOrder = await this.paypalService.createOrder({
    paymentOrderId: order.paymentOrderId,
    amount: input.amount,
    currency: input.currency,
    returnUrl: input.returnUrl,
    cancelUrl: input.cancelUrl,
    idempotencyKey: input.idempotencyKey,
  });

  await order.update({
    paymentOrderStatus: PaymentOrderStatus.EXECUTING,
    paypalOrderId: paypalOrder.paypalOrderId,
  });

  return {
    approveUrl: paypalOrder.approveUrl,
    paypalOrderId: paypalOrder.paypalOrderId,
    paymentOrderStatus: PaymentOrderStatus.EXECUTING,
  };
}
</code></pre>
<h3 id="heading-user-approves-on-paypal">User Approves on PayPal</h3>
<p>The frontend redirects the user to <code>approveUrl</code>. PayPal handles authentication and approval, then redirects back to your <code>returnUrl</code>.</p>
<h3 id="heading-capture-payment">Capture Payment</h3>
<p>After approval, call <code>capturePayment()</code> with either <code>paymentOrderId</code> or <code>paypalOrderId</code>:</p>
<pre><code class="language-typescript">async capturePayment(paymentOrderId?: string, paypalOrderId?: string) {
  const order = await this.findOrder(paymentOrderId, paypalOrderId);

  if (order.paymentOrderStatus === PaymentOrderStatus.SUCCESS) {
    return this.buildCaptureResponse(order); // already captured
  }

  const capture = await this.paypalService.captureOrder(order.paypalOrderId);

  if (capture.status !== 'COMPLETED') {
    throw new Error(`PayPal capture status: ${capture.status}`);
  }

  await this.finalizeSuccessfulPayment(order, capture.captureId);
  return this.buildCaptureResponse(order);
}
</code></pre>
<p><code>finalizeSuccessfulPayment()</code> runs in a database transaction:</p>
<ol>
<li><p>Updates order status to <code>SUCCESS</code></p>
</li>
<li><p>Updates seller wallet balance</p>
</li>
<li><p>Creates ledger entries (audit trail)</p>
</li>
<li><p>Mark scheckout event as done</p>
</li>
<li><p>Publishes a <code>payment.{domain}.completed</code> event to RabbitMQ</p>
</li>
</ol>
<h2 id="heading-step-5-connect-domain-services-via-grpc">Step 5 — Connect Domain Services via gRPC</h2>
<p>Domain services (like <code>students-service</code>) never talk to PayPal directly. They use a gRPC client:</p>
<p>The Students Service communicates with the Payment Service through a gRPC client. Rather than calling the PayPal API directly, it invokes strongly typed remote procedures exposed by the payment service.</p>
<pre><code class="language-typescript">// apps/services/students-service/src/app/payment/payment.module.ts

ClientsModule.registerAsync([{
  name: 'PAYMENT_SERVICE',
  useFactory: () =&gt; ({
    transport: Transport.GRPC,
    options: {
      package: 'payment',
      protoPath: 'libs/shared/dto/src/lib/payment/payment.proto',
      url: process.env.PAYMENT_SERVICE_URL || 'payment-service:50061',
    },
  }),
}])
</code></pre>
<pre><code class="language-typescript">// payment-client.service.ts

@Injectable()
export class PaymentClientService implements OnModuleInit {
  private paymentService: PaymentGrpcService;

  constructor(@Inject('PAYMENT_SERVICE') private client: ClientGrpc) {}

  onModuleInit() {
    this.paymentService = this.client.getService('PaymentService');
  }

  async createPayment(data: CreatePaymentRequest) {
    return firstValueFrom(this.paymentService.CreatePayment(data));
  }

  async capturePayment(data: { payment_order_id?: string; paypal_order_id?: string }) {
    return firstValueFrom(this.paymentService.CapturePayment(data));
  }
}
</code></pre>
<h3 id="heading-domain-service-business-logic-example">Domain Service Business Logic Example:</h3>
<p>This example shows how a domain service prepares business-specific data before delegating payment processing to the Payment Service.</p>
<pre><code class="language-typescript">// application-payment.service.ts

async initiateTuitionPayment(applicationId: number, options: { domain: string }) {
  const application = await this.applicationModel.findByPk(applicationId);

  // Build PayPal return URLs from frontend domain
  const frontendBase = options.domain; // e.g. https://crm.yourapp.com
  const returnUrl = `${frontendBase}/payment/successful?applicationId=${application.applicationId}`;
  const cancelUrl = `${frontendBase}/payment/failure?applicationId=${application.applicationId}`;

  const result = await this.paymentClient.createPayment({
    checkout_id: `checkout-app-${application.id}`,
    payment_order_id: uuidv4(),
    domain: 'application',
    reference_id: String(application.id),
    payer_id: application.studentId,
    amount: finalAmount.toFixed(2),
    currency: 'USD',
    buyer_email: buyerEmail,
    seller_account: `university-${application.universityId}`,
    payment_category: 'tuition_deposit',
    return_url: returnUrl,
    cancel_url: cancelUrl,
    idempotency_key: `app-${application.id}-tuition-${uuidv4()}`,
  });

  return {
    approveUrl: result.approve_url,
    paypalOrderId: result.paypal_order_id,
    paymentOrderId: result.payment_order_id,
  };
}
</code></pre>
<p>The domain service remains responsible for business rules, while the payment service handles the payment workflow itself.</p>
<p><strong>Important:</strong> The frontend must send its own origin as <code>domain</code> so return URLs point to the correct environment (localhost in dev, production URL in prod).</p>
<h2 id="heading-step-6-add-the-api-gateway-layer">Step 6 — Add the API Gateway Layer</h2>
<p>The API gateway exposes HTTP endpoints to the frontend and forwards to domain services:</p>
<pre><code class="language-plaintext">POST /applications/:id/pay/applicationfee
Body: { "domain": "https://crm.yourapp.com", "couponCode": "SAVE10" }
</code></pre>
<pre><code class="language-typescript">// student-apigw → students-service (gRPC) → payment-service (gRPC) → PayPal
</code></pre>
<p>Gateway responsibilities:</p>
<ul>
<li><p>Authentication (JWT)</p>
</li>
<li><p>Request validation</p>
</li>
<li><p>No PayPal credentials</p>
</li>
</ul>
<p>Capture the endpoint after the PayPal redirect:</p>
<pre><code class="language-plaintext">POST /applications/:id/pay/applicationfee/capture
Body: { "paypalOrderId": "PAYPAL_ORDER_ID_FROM_URL" }
</code></pre>
<h2 id="heading-step-7-publish-payment-events-with-rabbitmq">Step 7 — Publish Payment Events with RabbitMQ</h2>
<p>RabbitMQ enables asynchronous communication between services. Instead of waiting for every service to finish processing after a payment succeeds, the payment service simply publishes an event and lets interested services handle it independently.</p>
<p>After a successful capture, the payment service publishes an event:</p>
<pre><code class="language-typescript">// payment-events.publisher.ts

async publishCompleted(event: PaymentCompletedEvent) {
  const pattern = `payment.${event.domain}.completed`; // e.g. payment.application.completed
  this.eventsClient.emit(pattern, { ...event, eventId: uuidv4() });
}
</code></pre>
<p>Each domain service subscribes to payment events that are relevant to its business domain. For example, the Students Service listens for <code>payment.application.completed</code> events so it can mark student applications as paid.</p>
<pre><code class="language-typescript">// payment-events.consumer.ts (students-service)

@EventPattern('payment.application.completed')
async handlePaymentCompleted(@Payload() data: PaymentCompletedPayload) {
  await this.applicationPaymentService.handlePaymentCompletedEvent(data);
  // Marks application as PAID, records payment history
}
</code></pre>
<p>This decouples payment completion from domain updates. Even if <code>students-service</code> is temporarily down, you can replay events from the queue.</p>
<h3 id="heading-two-paths-to-mark-an-order-as-paid">Two Paths to Mark an Order as Paid</h3>
<table>
<thead>
<tr>
<th>Path</th>
<th>When used</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Synchronous capture</strong></td>
<td>Frontend calls capture API after PayPal redirect</td>
</tr>
<tr>
<td><strong>Async event</strong></td>
<td>RabbitMQ consumer updates domain state after payment service publishes event</td>
</tr>
</tbody></table>
<p>Using both (with idempotency) gives you reliability: the sync path gives immediate UX feedback. The async path is a safety net.</p>
<h2 id="heading-step-8-database-schema-and-migrations">Step 8 — Database Schema and Migrations</h2>
<p>The payment service maintains its own database schema. Each table has a specific responsibility, allowing payment records, financial transactions, and webhook processing to remain isolated from other business services.</p>
<table>
<thead>
<tr>
<th>Table</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td><code>payment_events</code></td>
<td>Checkout session (buyer/seller info)</td>
</tr>
<tr>
<td><code>payment_orders</code></td>
<td>Individual payment attempts with PayPal IDs</td>
</tr>
<tr>
<td><code>ledger_entries</code></td>
<td>Financial audit trail</td>
</tr>
<tr>
<td><code>wallets</code></td>
<td>Seller balance tracking</td>
</tr>
<tr>
<td><code>processed_webhooks</code></td>
<td>Webhook deduplication</td>
</tr>
<tr>
<td><code>coupons</code> / <code>coupon_redemptions</code></td>
<td>Discount codes (optional)</td>
</tr>
<tr>
<td><code>sequelize_meta</code></td>
<td>Migration tracking</td>
</tr>
</tbody></table>
<h3 id="heading-production-migration-gotcha">Production Migration Gotcha</h3>
<p>In production Docker images, migration <code>.ts</code> files are <strong>not</strong> available unless you compile them to JavaScript and copy them into the image:</p>
<pre><code class="language-dockerfile"># Dockerfile — compile migrations for production
RUN pnpm exec tsc --project apps/core/payment-service/tsconfig.migrations.json
COPY --from=builder /app/dist/apps/core/payment-service/migrations ./migrations
</code></pre>
<p>Without this, you'll see <code>Executed 0 migrations</code> in logs and <strong>no tables will be created</strong>.</p>
<p>Create the database user before first deploy:</p>
<pre><code class="language-sql">CREATE USER payment_user WITH PASSWORD 'payment_pass';
CREATE DATABASE payment_db;
GRANT ALL PRIVILEGES ON DATABASE payment_db TO payment_user;
</code></pre>
<h2 id="heading-step-9-local-development-setup-docker">Step 9 — Local Development Setup (Docker)</h2>
<h3 id="heading-environment-variables-env">Environment Variables (<code>.env</code>)</h3>
<pre><code class="language-bash">PAYPAL_CLIENT_ID=your_sandbox_client_id
PAYPAL_CLIENT_SECRET=your_sandbox_client_secret
PAYPAL_API_BASE=https://api-m.sandbox.paypal.com
</code></pre>
<p>In this section, we'll configure the payment service for local development using Docker Compose. This setup provides a complete environment for testing payments without deploying to production.</p>
<h3 id="heading-docker-compose-local">Docker Compose (local)</h3>
<p>The following configuration starts the payment service together with its required dependencies, including PostgreSQL and RabbitMQ.</p>
<pre><code class="language-yaml">payment-service:
  build:
    dockerfile: apps/core/payment-service/Dockerfile.dev
  ports:
    - '3003:3003'    # HTTP
    - '50061:50061'  # gRPC
  environment:
    - PAYPAL_API_BASE=https://api-m.sandbox.paypal.com
    - PAYPAL_CLIENT_ID=${PAYPAL_CLIENT_ID}
    - PAYPAL_CLIENT_SECRET=${PAYPAL_CLIENT_SECRET}
    - DB_HOST=postgres
    - DB_NAME=payment_db
    - DB_USER=payment_user
    - DB_PASSWORD=payment_pass
    - RABBITMQ_URL=amqp://rabbitmq:5672

students-service:
  environment:
    - PAYMENT_SERVICE_URL=payment-service:50061
  depends_on:
    payment-service:
      condition: service_healthy
</code></pre>
<h3 id="heading-start-services">Start Services</h3>
<p>Once the configuration is complete, start the containers and verify that every service is running correctly before testing the payment flow.</p>
<pre><code class="language-bash">docker compose up -d payment-service students-service student-apigw
</code></pre>
<h3 id="heading-verify-health">Verify Health</h3>
<pre><code class="language-bash">curl http://localhost:3003/health
# {"status":"healthy","service":"payment-service",...}
</code></pre>
<h3 id="heading-test-payment-flow">Test Payment Flow</h3>
<ol>
<li><p>Call <code>POST /applications/:id/pay/applicationfee</code> with <code>{ "domain": "http://localhost:3000" }</code></p>
</li>
<li><p>Open the returned <code>approveUrl</code> in a browser</p>
</li>
<li><p>Log in with a <a href="https://developer.paypal.com/dashboard/accounts">PayPal sandbox buyer account</a></p>
</li>
<li><p>After approval, call <code>POST /applications/:id/pay/applicationfee/capture</code> with the <code>paypalOrderId</code></p>
</li>
<li><p>Confirm application status is <code>PAID</code></p>
</li>
</ol>
<h2 id="heading-step-10-production-deployment">Step 10 — Production Deployment</h2>
<p>After verifying everything locally, the next step is deploying the payment service to production. The main differences are using PayPal Live credentials, production environment variables, and production-ready Docker images.</p>
<h3 id="heading-paypal-live-credentials">PayPal Live Credentials</h3>
<ol>
<li><p>Go to <a href="https://developer.paypal.com/dashboard/applications/live">PayPal Developer Dashboard → Live apps</a></p>
</li>
<li><p>Create a Live REST API app</p>
</li>
<li><p>Copy Client ID and Secret</p>
</li>
</ol>
<h3 id="heading-production-env-on-server-never-commit">Production <code>.env</code> (on Server — Never Commit)</h3>
<pre><code class="language-bash">PAYPAL_CLIENT_ID=your_live_client_id
PAYPAL_CLIENT_SECRET=your_live_secret
PAYPAL_API_BASE=https://api-m.paypal.com
</code></pre>
<h3 id="heading-docker-compose-production">Docker Compose (Production)</h3>
<pre><code class="language-yaml">payment-service:
  build:
    dockerfile: apps/core/payment-service/Dockerfile
  environment:
    - NODE_ENV=production
    - PAYPAL_API_BASE=${PAYPAL_API_BASE:-https://api-m.paypal.com}
    - PAYPAL_CLIENT_ID=${PAYPAL_CLIENT_ID}
    - PAYPAL_CLIENT_SECRET=${PAYPAL_CLIENT_SECRET}
    - DB_HOST=${DB_HOST}
    - DB_NAME=payment_db
    - DB_USER=payment_user
    - DB_PASSWORD=payment_pass
    - RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASS}@rabbitmq:5672
  labels:
    - 'traefik.http.routers.payment.rule=Host(`payment-service.yourapp.com`)'

students-service:
  environment:
    - PAYMENT_SERVICE_URL=payment-service:50061
  depends_on:
    payment-service:
      condition: service_healthy
</code></pre>
<h3 id="heading-deploy-commands">Deploy Commands</h3>
<pre><code class="language-bash">docker compose -f docker-compose.prod.yml build --no-cache payment-service
docker compose -f docker-compose.prod.yml up -d payment-service students-service
</code></pre>
<h3 id="heading-verify-production">Verify Production</h3>
<pre><code class="language-bash">curl https://payment-service.yourapp.com/health

docker logs -f apply-goal-payment-service
# Look for:
#   environment: live
#   Found 8 pending migrations
#   Executed 8 migrations
</code></pre>
<h3 id="heading-frontend-domain-in-production">Frontend Domain in Production</h3>
<p>The frontend must send the production CRM URL when initiating payment:</p>
<pre><code class="language-json">{ "domain": "https://crm.yourapp.com" }
</code></pre>
<p>Not <code>localhost</code>. This controls where PayPal redirects after payment.</p>
<h2 id="heading-step-11-health-checks-and-monitoring">Step 11 — Health Checks and Monitoring</h2>
<p>Health checks allow orchestration tools such as Docker and Traefik to verify that the payment service is running correctly. Monitoring these endpoints helps detect failures early and improves application reliability.</p>
<pre><code class="language-typescript">// GET /health
{ "status": "healthy", "service": "payment-service", "timestamp": "...", "version": "1.0.0" }
</code></pre>
<p>Used by:</p>
<ul>
<li><p>Docker <code>HEALTHCHECK</code></p>
</li>
<li><p>Traefik load balancer</p>
</li>
<li><p>Uptime monitoring</p>
</li>
</ul>
<p>PayPal credential check runs on startup via <code>PayPalService.logConfiguration()</code>.</p>
<h2 id="heading-complete-request-flow-real-example">Complete Request Flow (Real Example)</h2>
<p><strong>Scenario:</strong> Student pays tuition fee for university application.</p>
<pre><code class="language-plaintext">1. Frontend
   POST /applications/42/pay/applicationfee
   Body: { "domain": "https://crm.yourapp.com" }
        │
        ▼
2. student-apigw (HTTP → gRPC)
   InitiateApplicationTuitionPayment(applicationId: 42)
        │
        ▼
3. students-service
   - Validates application not already paid
   - Resolves tuition amount
   - Optionally validates coupon via payment-service gRPC
   - Builds returnUrl / cancelUrl from domain
   - Calls payment-service CreatePayment (gRPC)
        │
        ▼
4. payment-service
   - Creates payment_orders record (EXECUTING)
   - Calls PayPal POST /v2/checkout/orders
   - Returns approveUrl
        │
        ▼
5. Frontend redirects user to approveUrl (PayPal checkout)
        │
        ▼
6. User approves → PayPal redirects to returnUrl
        │
        ▼
7. Frontend
   POST /applications/42/pay/applicationfee/capture
   Body: { "paypalOrderId": "PAYPAL_ORDER_ID" }
        │
        ▼
8. payment-service
   - POST /v2/checkout/orders/{id}/capture
   - Updates order → SUCCESS
   - Updates wallet + ledger
   - Publishes payment.application.completed → RabbitMQ
        │
        ▼
9. students-service (event consumer)
   - Marks application paymentStatus = PAID
   - Records payment in application_payments table
</code></pre>
<h2 id="heading-coupon-support-optional">Coupon Support (Optional)</h2>
<p>Before creating a PayPal order, validate a coupon via gRPC:</p>
<pre><code class="language-typescript">const validation = await this.paymentClient.validateCoupon({
  code: 'SAVE20',
  universityId: application.universityId,
  originalAmount: 500,
  paymentType: 'application_fee',
});

const finalAmount = validation.data.finalAmount;

// If coupon covers 100% — skip PayPal entirely
if (finalAmount &lt;= 0) {
  await this.markApplicationPaid(applicationId, { amount: 0, source: 'coupon' });
  return { paymentOrderStatus: 'COMPLETED' };
}
</code></pre>
<p>Coupon logic lives in <code>payment-service</code> so discount rules are centralized.</p>
<h2 id="heading-paypal-webhooks-optional-but-recommended">PayPal Webhooks (Optional but Recommended)</h2>
<p>Register a webhook URL in the PayPal dashboard:</p>
<pre><code class="language-plaintext">https://payment-service.yourapp.com/api/v1/payments/webhooks/paypal
</code></pre>
<p>The payment service handles:</p>
<table>
<thead>
<tr>
<th>Event</th>
<th>Action</th>
</tr>
</thead>
<tbody><tr>
<td><code>CHECKOUT.ORDER.APPROVED</code></td>
<td>Auto-capture the order</td>
</tr>
<tr>
<td><code>PAYMENT.CAPTURE.COMPLETED</code></td>
<td>Finalize payment if not already done</td>
</tr>
</tbody></table>
<p>Webhook events are deduplicated via <code>processed_webhooks</code> table to prevent double-processing.</p>
<h2 id="heading-testing-checklist">Testing Checklist</h2>
<ul>
<li><p>[ ] <code>GET /health</code> returns 200</p>
</li>
<li><p>[ ] PayPal logs show <code>credentialsPresent: true</code></p>
</li>
<li><p>[ ] Database tables exist after startup (<code>payment_orders</code>, <code>payment_events</code>, etc.)</p>
</li>
<li><p>[ ] Create payment returns valid <code>approveUrl</code></p>
</li>
<li><p>[ ] Sandbox buyer can complete checkout</p>
</li>
<li><p>[ ] Capture returns <code>payment_order_status: SUCCESS</code></p>
</li>
<li><p>[ ] Application marked as <code>PAID</code> in domain service</p>
</li>
<li><p>[ ] RabbitMQ event <code>payment.application.completed</code> is consumed</p>
</li>
<li><p>[ ] Duplicate capture is handled gracefully (idempotent)</p>
</li>
<li><p>[ ] Coupon 100% discount skips PayPal</p>
</li>
<li><p>[ ] Production uses <code>https://api-m.paypal.com</code> (live)</p>
</li>
</ul>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>Integrating PayPal in a microservice architecture comes down to a few principles:</p>
<ol>
<li><p>One payment service owns all PayPal API calls</p>
</li>
<li><p>gRPC connects domain services to the payment service internally</p>
</li>
<li><p>RabbitMQ broadcasts payment outcomes so domain services stay decoupled</p>
</li>
<li><p>Idempotency keys prevent duplicate charges</p>
</li>
<li><p>Environment variables switch between sandbox and live — no code changes</p>
</li>
<li><p>Migrations must be compiled for production Docker images</p>
</li>
<li><p>Frontend sends <code>domain</code> so return URLs work in every environment</p>
</li>
</ol>
<p>This pattern scales: add a new payment type (subscription, agency fee, university service fee) by sending a different <code>domain</code> and <code>payment_category</code> — no changes to PayPal integration code.</p>
<h2 id="heading-further-reading">Further Reading</h2>
<ul>
<li><p><a href="https://developer.paypal.com/docs/api/orders/v2/">PayPal Orders API v2 Documentation</a></p>
</li>
<li><p><a href="https://developer.paypal.com/tools/sandbox/">PayPal Sandbox Testing Guide</a></p>
</li>
<li><p><a href="https://docs.nestjs.com/microservices/grpc">NestJS Microservices (gRPC)</a></p>
</li>
<li><p><a href="https://docs.nestjs.com/microservices/rabbitmq">NestJS RabbitMQ Transport</a></p>
</li>
<li><p><a href="https://github.com/sequelize/umzug">Umzug Database Migrations</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Containerize a Node.js Application with Docker and Deploy with GitHub Actions ]]>
                </title>
                <description>
                    <![CDATA[ If you've been building Node.js projects, you've probably had an experience like this. The project runs fine on your machine, but when you push it to a server, something breaks. Maybe it's a different ]]>
                </description>
                <link>https://www.freecodecamp.org/news/containerize-a-node-js-app-with-docker-and-deploy-with-github-actions/</link>
                <guid isPermaLink="false">6a569b9cbd138d774dee2042</guid>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GitHub Actions ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker compose ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker-compose.yml ]]>
                    </category>
                
                    <category>
                        <![CDATA[ containerization ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Backend Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Tutorial ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zia Ullah ]]>
                </dc:creator>
                <pubDate>Tue, 14 Jul 2026 20:27:08 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/343864e6-5319-4378-a2b1-4955e38ad6d8.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you've been building <a href="https://www.freecodecamp.org/news/role-based-access-control-nodejs-rest-api-jwt/">Node.js projects</a>, you've probably had an experience like this. The project runs fine on your machine, but when you push it to a server, something breaks.</p>
<p>Maybe it's a different Node version, maybe an environment variable is missing, or maybe a system dependency doesn't match. You spend an hour debugging something that was never actually a code problem.</p>
<p>Docker fixes this at the root. With Docker, you stop shipping just code. The Node version, dependencies, and config all travel inside the container. Your laptop, a CI server, a production VM — it behaves the same on all of them. No more environment surprises.</p>
<p>In this tutorial, we'll go through all this step by step: a multi-stage Dockerfile, using Docker Compose with PostgreSQL for local development, and a GitHub Actions workflow that pushes a fresh image to Docker Hub on every merge to <code>main</code>.</p>
<p>The complete code for this tutorial is available on <a href="https://github.com/ziaongit/nodejs-docker-cicd">GitHub</a>.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-sample-application">The Sample Application</a></p>
</li>
<li><p><a href="#heading-writing-the-dockerfile">Writing the Dockerfile</a></p>
</li>
<li><p><a href="#heading-the-dockerignore-file">The .dockerignore File</a></p>
</li>
<li><p><a href="#heading-the-gitignore-file">The .gitignore File</a></p>
</li>
<li><p><a href="#heading-build-and-test-the-image-locally">Build and Test the Image Locally</a></p>
</li>
<li><p><a href="#heading-docker-compose-for-local-development">Docker Compose for Local Development</a></p>
</li>
<li><p><a href="#heading-automate-the-build-with-github-actions">Automate the Build with GitHub Actions</a></p>
</li>
<li><p><a href="#heading-deploying-the-image">Deploying the Image</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Node.js 18+</p>
</li>
<li><p>Docker Desktop, which you can download at <a href="https://docs.docker.com/get-docker/">docs.docker.com/get-docker</a>. Windows users need WSL 2 before Docker starts. Open PowerShell as Administrator and run <code>wsl --install</code>. After the restart, Docker Desktop will install without issues.</p>
</li>
<li><p>A GitHub account</p>
</li>
<li><p>A Docker Hub account (free at <a href="https://hub.docker.com">hub.docker.com</a>)</p>
</li>
<li><p>Some Express.js experience helps, but isn't required</p>
</li>
</ul>
<h2 id="heading-the-sample-application">The Sample Application</h2>
<p>We're building a task management API with Express and PostgreSQL. Keep in mind the app is just a vehicle to teach you how this works. The Dockerfile and pipeline we set up here work the same way for any Node.js project.</p>
<p>Create the project:</p>
<pre><code class="language-bash">mkdir nodejs-docker-cicd &amp;&amp; cd nodejs-docker-cicd
npm init -y
npm install express pg dotenv
npm install --save-dev nodemon
</code></pre>
<p>Create <code>src/index.js</code>:</p>
<pre><code class="language-javascript">const express = require('express');
const { Pool } = require('pg');
require('dotenv').config();

const app = express();
app.use(express.json());

const pool = new Pool({
  host: process.env.DB_HOST,
  port: process.env.DB_PORT,
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
});

// Create table on startup
pool.query(`
  CREATE TABLE IF NOT EXISTS tasks (
    id SERIAL PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    completed BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT NOW()
  )
`).catch(console.error);

// Health check — required for Docker HEALTHCHECK and load balancers
app.get('/health', (req, res) =&gt; {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

app.get('/tasks', async (req, res) =&gt; {
  try {
    const result = await pool.query('SELECT * FROM tasks ORDER BY created_at DESC');
    res.json(result.rows);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.post('/tasks', async (req, res) =&gt; {
  const { title } = req.body;
  if (!title) return res.status(400).json({ error: 'Title is required' });
  try {
    const result = await pool.query(
      'INSERT INTO tasks (title) VALUES ($1) RETURNING *',
      [title]
    );
    res.status(201).json(result.rows[0]);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.patch('/tasks/:id', async (req, res) =&gt; {
  const { id } = req.params;
  const { completed } = req.body;
  try {
    const result = await pool.query(
      'UPDATE tasks SET completed = $1 WHERE id = $2 RETURNING *',
      [completed, id]
    );
    if (result.rows.length === 0) return res.status(404).json({ error: 'Task not found' });
    res.json(result.rows[0]);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () =&gt; console.log(`Server running on port ${PORT}`));
</code></pre>
<p>Open <code>package.json</code> and update the <code>"scripts"</code> section:</p>
<pre><code class="language-json">"scripts": {
  "start": "node src/index.js",
  "dev": "nodemon src/index.js"
}
</code></pre>
<p><code>npm start</code> runs the app directly with Node. <code>npm run dev</code> uses nodemon so the server restarts automatically when you edit a file.</p>
<p>For running without Docker, create a <code>.env</code> file:</p>
<pre><code class="language-plaintext">DB_HOST=localhost
DB_PORT=5432
DB_NAME=tasksdb
DB_USER=postgres
DB_PASSWORD=yourpassword
PORT=3000
</code></pre>
<p>Notice that all database credentials come from environment variables rather than being hardcoded. Swap the variables, and the same image runs against your local database or a production one — no code changes needed. The <code>/health</code> endpoint is what Docker pings to know the app is actually handling requests.</p>
<h2 id="heading-writing-the-dockerfile">Writing the Dockerfile</h2>
<p>Before touching the Dockerfile, there are two terms you'll keep seeing. An <strong>image</strong> is a packaged, immutable version of your app — Node runtime, code, dependencies, everything together in one artifact. A <strong>container</strong> is a running instance of that image. One image, many containers, any machine.</p>
<p>Here's the Dockerfile we'll use:</p>
<pre><code class="language-dockerfile"># ── Stage 1: Install dependencies ──────────────────────────────────────────
FROM node:18-alpine AS builder

WORKDIR /app

# Copy package files first — Docker caches this layer separately.
# If you only change src code (not package.json), Docker skips npm ci on rebuild.
COPY package*.json ./
RUN npm ci

COPY . .


# ── Stage 2: Production image ───────────────────────────────────────────────
FROM node:18-alpine AS production

# Create a non-root user — running as root inside a container is a security risk
RUN addgroup -g 1001 -S nodejs &amp;&amp; \
    adduser -S nodeuser -u 1001

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

# Copy only the source code from the builder stage (not node_modules or dev files)
COPY --from=builder /app/src ./src

RUN chown -R nodeuser:nodejs /app
USER nodeuser

EXPOSE 3000

# Docker will ping /health every 30s. If it fails 3 times, the container is marked unhealthy.
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

CMD ["node", "src/index.js"]
</code></pre>
<p>This is a multi-stage build. The first stage (<code>builder</code>) installs everything, including dev dependencies. The second stage (<code>production</code>) starts fresh and only copies what the app needs to run. Nodemon, test frameworks, and anything else dev-only never make it into the final image.</p>
<p>The size difference is real. A <code>node:18</code> Debian image is over 950MB. Switch to <code>node:18-alpine</code> and cut out the dev dependencies, and the final image lands around 150–200MB instead. A smaller image means faster pushes and faster deploys.</p>
<p><code>npm ci</code> instead of <code>npm install</code> is a deliberate choice for CI/CD. It reads exact versions from <code>package-lock.json</code> and fails hard if the lockfile doesn't match <code>package.json</code>. Every build on every machine installs the exact same versions — no surprises from a dependency that quietly updated overnight.</p>
<p>The <code>nodeuser</code> account exists because containers run as root by default. That's fine until something goes wrong. A non-root user means that an attacker who gets inside the container can't just do whatever they want.</p>
<h2 id="heading-the-dockerignore-file">The <code>.dockerignore</code> File</h2>
<p>Create <code>.dockerignore</code> before building:</p>
<pre><code class="language-plaintext">node_modules
npm-debug.log
.env
.git
.gitignore
README.md
Dockerfile
.dockerignore
</code></pre>
<p>The <code>node_modules</code> exclusion is the critical one. Your local modules were compiled for your operating system — macOS or Windows binaries won't work inside a Linux container. Excluding them means Docker installs fresh modules during the build, compiled for the correct platform. Without this exclusion, you'd either copy broken binaries into the image or waste time uploading hundreds of megabytes to the build context.</p>
<p>Never put <code>.env</code> in an image. Passwords, API keys, anything sensitive — those go in at runtime as environment variables, never inside the image itself.</p>
<h2 id="heading-the-gitignore-file">The <code>.gitignore</code> File</h2>
<p>One more thing before the first commit: a <code>.gitignore</code>. You don't want <code>node_modules</code> or <code>.env</code> tracked:</p>
<pre><code class="language-plaintext">node_modules/
.env
.env.local
npm-debug.log*
logs/
.DS_Store
Thumbs.db
.vscode/
.idea/
dist/
build/
</code></pre>
<h2 id="heading-build-and-test-the-image-locally">Build and Test the Image Locally</h2>
<p>Open Docker Desktop first and give it a moment. On Windows, you'll see a whale icon in the taskbar that animates while the engine is starting up. Once it goes still, you're good to run Docker commands. If you try to run Docker before the engine is up, you'll hit this:</p>
<pre><code class="language-plaintext">ERROR: Error response from daemon: Docker Desktop is unable to start
</code></pre>
<p>If that happens, quit Docker Desktop. Open PowerShell as Administrator, run <code>wsl --update</code>, and restart. Then go to Control Panel → Programs → Turn Windows features on or off. Both Hyper-V and Virtual Machine Platform need to be checked. After the restart, Docker Desktop should come up fine.</p>
<p>It's worth knowing about this error too:</p>
<pre><code class="language-plaintext">docker : The term 'docker' is not recognized as the name of a cmdlet, function,
script file, or operable program.
</code></pre>
<p>This means that Docker Desktop isn't running or isn't installed. Open it from the Start menu and wait.</p>
<p>Run the build:</p>
<pre><code class="language-bash">docker build -t nodejs-docker-cicd:latest .
</code></pre>
<p>The first time takes roughly 30 seconds since Docker has to pull <code>node:18-alpine</code> from the internet. Once that's cached, subsequent builds are much quicker. Both stages will scroll by:</p>
<pre><code class="language-plaintext">[+] Building 33.1s (17/17) FINISHED
 =&gt; [builder 1/5] FROM docker.io/library/node:18-alpine       20.9s
 =&gt; [builder 4/5] RUN npm ci                                   3.5s
 =&gt; [production 5/7] RUN npm ci --only=production              3.2s
 =&gt; [production 7/7] RUN chown -R nodeuser:nodejs /app         3.2s
 =&gt; exporting to image                                         1.5s
 =&gt; =&gt; naming to docker.io/library/nodejs-docker-cicd:latest     0.0s
</code></pre>
<p>When you see <code>(17/17) FINISHED</code> the image is built. Check the size:</p>
<pre><code class="language-bash">docker images nodejs-docker-cicd
</code></pre>
<pre><code class="language-plaintext">IMAGE                     ID             DISK USAGE   CONTENT SIZE
nodejs-docker-cicd:latest   c9eed311d999        198MB         47.5MB
</code></pre>
<p><strong>CONTENT SIZE</strong> (47.5MB) is the compressed size that gets pushed to Docker Hub. <strong>DISK USAGE</strong> (198MB) is what it takes up on disk locally. Compare that to a <code>node:18</code> Debian image at 950MB+, and you can see why the Alpine base and multi-stage approach matter.</p>
<p>On subsequent builds, Docker reuses cached layers. Edit only your source files without touching <code>package.json</code> and the <code>npm ci</code> step gets skipped completely. That 33-second first build becomes 3 seconds.</p>
<h2 id="heading-docker-compose-for-local-development">Docker Compose for Local Development</h2>
<p>The app needs a database. Setting up PostgreSQL locally means every developer who clones the repo has to do it, too. Docker Compose handles this: one file defines both services, and one command starts them.</p>
<p>Create <code>docker-compose.yml</code>:</p>
<pre><code class="language-yaml">services:
  app:
    build:
      context: .
      target: production
    ports:
      - '3000:3000'
    environment:
      DB_HOST: postgres
      DB_PORT: 5432
      DB_NAME: tasksdb
      DB_USER: postgres
      DB_PASSWORD: postgres
      PORT: 3000
    depends_on:
      postgres:
        condition: service_healthy
    restart: unless-stopped

  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: tasksdb
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - '5432:5432'
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U postgres']
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:
</code></pre>
<p>A few things worth pointing out. <code>DB_HOST</code> is set to <code>postgres</code>. That's the service name, not <code>localhost</code>. Containers on the same Docker network reach each other by service name. Put <code>localhost</code> there and the app tries to connect to itself.</p>
<p><code>depends_on</code> with <code>condition: service_healthy</code> holds the app back until Postgres actually passes its health check. Skip this and the app starts, tries to connect to a database that isn't ready yet, and crashes. The health check pings <code>pg_isready</code> every 5 seconds. Once it gets a green response, the app container starts.</p>
<p>The named volume <code>postgres_data</code> keeps your data alive between restarts. Run <code>docker compose down</code> and the data is still there next time. Add <code>--volumes</code> to wipe it clean.</p>
<p>Start both services:</p>
<pre><code class="language-bash">docker compose up --build
</code></pre>
<p>You'll see PostgreSQL initialize and then the app start. Once you see <code>Server running on port 3000</code> in the logs, the stack is up.</p>
<p>Open a second terminal to test — leave the compose logs running in the first one.</p>
<p><strong>Linux/macOS:</strong></p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn Docker"}'

curl http://localhost:3000/tasks

curl http://localhost:3000/health
</code></pre>
<p><strong>Windows PowerShell:</strong> Typing <code>curl</code> in PowerShell runs <code>Invoke-WebRequest</code>, not actual curl. Run <code>curl.exe</code> instead. For JSON bodies, write to a file first:</p>
<pre><code class="language-powershell">'{"title": "Learn Docker"}' | Set-Content body.json
curl.exe -X POST http://localhost:3000/tasks -H "Content-Type: application/json" --data `@body.json

curl.exe http://localhost:3000/tasks

curl.exe http://localhost:3000/health
</code></pre>
<p>The backtick before <code>@body.json</code> is necessary. PowerShell would otherwise try to interpret <code>@</code> as a splatting operator rather than passing it to curl as a filename prefix.</p>
<p>You should see responses like these:</p>
<pre><code class="language-json"># POST /tasks
{"id":1,"title":"Learn Docker","completed":false,"created_at":"2026-07-09T22:21:17.073Z"}

# GET /tasks
[{"id":1,"title":"Learn Docker","completed":false,"created_at":"2026-07-09T22:21:17.073Z"}]

# GET /health
{"status":"ok","timestamp":"2026-07-09T22:11:44.700Z"}
</code></pre>
<p>The task hit PostgreSQL in one container and came back through the app. <code>Ctrl+C</code> in the compose terminal stops both.</p>
<h2 id="heading-automate-the-build-with-github-actions">Automate the Build with GitHub Actions</h2>
<p>The image works locally, so it's time to stop doing this by hand.</p>
<h3 id="heading-step-1-create-a-docker-hub-access-token">Step 1: Create a Docker Hub Access Token</h3>
<p>Go to <a href="https://hub.docker.com">hub.docker.com</a> and then Account Settings → Security → New Access Token. Set permission to Read &amp; Write, as read-only breaks the push. The token appears once, so copy it before closing the page.</p>
<p><strong>Security warning:</strong> Don't paste this token into a chat, email, or commit. If you expose it by accident, delete it immediately, then make a new one.</p>
<h3 id="heading-step-2-add-secrets-to-your-github-repository">Step 2: Add Secrets to Your GitHub Repository</h3>
<p>Head to Settings → Secrets and variables → Actions in your repo and add:</p>
<ul>
<li><p><code>DOCKERHUB_USERNAME</code> — your Docker Hub username</p>
</li>
<li><p><code>DOCKERHUB_TOKEN</code> — paste the token here, nowhere else</p>
</li>
</ul>
<p>If you ran into <code>Error: Username and password required</code>, the secrets either aren't saved yet or the names are typed wrong. Both are case-sensitive.</p>
<p>A Node 20 deprecation warning in the logs is normal. It comes from the Docker actions internally, not your code.</p>
<h3 id="heading-step-3-create-the-workflow-file">Step 3: Create the Workflow File</h3>
<p>Create <code>.github/workflows/docker-publish.yml</code>:</p>
<pre><code class="language-yaml">name: Build and Push Docker Image

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/nodejs-docker-cicd

jobs:
  build-and-push:
    runs-on: ubuntu-latest

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

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to Docker Hub
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=sha-
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          target: production
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
</code></pre>
<p>The login step has <code>if: github.event_name != 'pull_request'</code>. This skips authentication on pull requests. PRs from forks don't have access to your secrets, so trying to log in would just fail. The build still runs on PRs to validate your Dockerfile, but the image isn't pushed.</p>
<p>The metadata action generates two tags on every merge to <code>main</code>: <code>latest</code> and a short commit SHA like <code>sha-a1b2c3d</code>. The SHA tag is what makes rollbacks practical. If <code>latest</code> breaks in production, you can pull any previous <code>sha-</code> tag and you're back to a known-good state in seconds.</p>
<p>The <code>cache-from/cache-to: type=gha</code> lines store Docker's layer cache in GitHub Actions' built-in cache. The first run builds everything from scratch. After that, unchanged layers are pulled from cache rather than rebuilt. On a typical Node.js app this brings build time from 2–3 minutes down to under 30 seconds.</p>
<h3 id="heading-push-and-watch-it-run">Push and Watch it Run</h3>
<pre><code class="language-bash">git add .
git commit -m "Add Docker configuration and GitHub Actions workflow"
git push origin main
</code></pre>
<p>Go to your repo's <strong>Actions</strong> tab. You'll see the workflow running in real time. Each step turns green as it completes:</p>
<pre><code class="language-plaintext">✅ Checkout code
✅ Set up Docker Buildx
✅ Log in to Docker Hub
✅ Extract metadata
✅ Build and push
</code></pre>
<p>Green across the board means your image is live on Docker Hub — two tags, <code>latest</code> and a commit SHA like <code>sha-a1b2c3d</code>. Every push to <code>main</code> from here builds and ships automatically.</p>
<h2 id="heading-deploying-the-image">Deploying the Image</h2>
<p>With your image on Docker Hub, you can deploy it to any infrastructure:</p>
<p><strong>Any VPS or server:</strong></p>
<pre><code class="language-bash">docker pull yourusername/nodejs-docker-cicd:latest
docker run -d -p 3000:3000 \
  -e DB_HOST=your-db-host \
  -e DB_NAME=tasksdb \
  -e DB_USER=postgres \
  -e DB_PASSWORD=yourpassword \
  yourusername/nodejs-docker-cicd:latest
</code></pre>
<p><strong>Railway</strong> — Connect your Docker Hub image in the Railway dashboard and it deploys on the next push.</p>
<p><strong>Fly.io</strong> — Run <code>fly launch</code> pointing at your Dockerfile and Fly handles the rest.</p>
<p><strong>Render</strong> — Paste your Docker Hub image URL into the Render service settings.</p>
<p>Each push to <code>main</code> runs the workflow. New image goes to Docker Hub, platform picks it up — that's your deployment handled.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>What started as a local Node.js app now runs in a container. You get the same behavior on any machine, real PostgreSQL in development, and a pipeline that builds and ships to Docker Hub without you doing anything after the push.</p>
<p>The multi-stage build keeps the image lean — dev tools stay out, non-root user, health check baked in. Compose gets the full stack up with one command for anyone who clones the repo. The SHA tag on every GitHub Actions build means rolling back is just a matter of pulling an older tag.</p>
<p>These same patterns (multi-stage builds, Compose for local development, automated image publishing) are used across the industry for production Node.js deployments. Pick up these patterns once and they follow you to every project.</p>
<p>From here, you can extend the pipeline: drop a test step in before the build, or add multi-platform support if you're targeting ARM. Once Docker Compose starts feeling limiting in production, that's usually when Kubernetes enters the picture.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Docker Full Course ]]>
                </title>
                <description>
                    <![CDATA[ We just posted a comprehensive Docker course now live on the freeCodeCamp.org YouTube channel! The ability to scale applications instantly and ship software reliably is an important skill. Containeriz ]]>
                </description>
                <link>https://www.freecodecamp.org/news/docker-full-course/</link>
                <guid isPermaLink="false">6a217a7e004b104f5f3c88b8</guid>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ youtube ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Beau Carnes ]]>
                </dc:creator>
                <pubDate>Thu, 04 Jun 2026 13:15:42 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5f68e7df6dfc523d0a894e7c/2d702aaa-2eef-48b1-aa2b-f36dcb744501.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>We just posted a comprehensive Docker course now live on the freeCodeCamp.org YouTube channel!</p>
<p>The ability to scale applications instantly and ship software reliably is an important skill. Containerization is at the heart of modern development.</p>
<p>This hands-on, structured course is designed to take you from absolute scratch to becoming job-ready. Taught by instructor Eissa from DolfinEd, who brings over 25 years of industry experience and 21 years of teaching expertise, this course breaks down complex concepts into simple, actionable skills.</p>
<p>This is a complete, step-by-step practical course that covers everything you need to master Docker:</p>
<ul>
<li><p>Foundations: Understand the shift from legacy physical servers to virtual machines and containers.</p>
</li>
<li><p>Core Skills: Master Docker files, image creation, and how to manage repositories using Docker Hub.</p>
</li>
<li><p>Networking &amp; Storage: Learn the gold standards for managing container networking, storage, and volumes.</p>
</li>
<li><p>Orchestration: Move beyond basic containers by learning how to deploy multi-container applications with Docker Compose and get an introduction to Docker Swarm.</p>
</li>
<li><p>Real-World Application: Put your skills to the test with structured quizzes, module assignments, and real-world projects that mirror professional environments.</p>
</li>
</ul>
<p>Watch the full course now and start your journey to becoming a Docker expert (7-hour watch):</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/rjjES5IsPdg" 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 Self‑Host an S3‑Compatible Object Store with MinIO on Your Staging Server (and Save Hundreds of Dollars a Month) ]]>
                </title>
                <description>
                    <![CDATA[ This article is a complete copy‑paste guide to running MinIO behind Traefik with HTTPS, custom domains, and pre-signed upload/download URLs — using only Docker Compose. Your production will keep using ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-self-host-an-s3-compatible-object-store-with-minio-on-your-staging-server/</link>
                <guid isPermaLink="false">6a1d99eb2f5663bb4c520a8f</guid>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloud-storage ]]>
                    </category>
                
                    <category>
                        <![CDATA[ S3 ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Md Tarikul Islam ]]>
                </dc:creator>
                <pubDate>Mon, 01 Jun 2026 14:40:43 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/a7e1dd1d-2e31-4d80-ae9b-10242588a5e1.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>This article is a complete copy‑paste guide to running MinIO behind Traefik with HTTPS, custom domains, and pre-signed upload/download URLs — using only Docker Compose.</p>
<p>Your production will keep using a managed S3 / Cloudflare R2 / Hetzner Object Storage, while every staging upload, download, and pre-signed URL goes to your <strong>own</strong> server for free.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-1-why-selfhost-object-storage-on-staging">1. Why Self‑Host Object Storage on Staging?</a></p>
</li>
<li><p><a href="#heading-2-the-architecture-production-vs-staging">2. The Architecture: Production vs. Staging</a></p>
</li>
<li><p><a href="#heading-3-prerequisites">3. Prerequisites</a></p>
</li>
<li><p><a href="#heading-4-step-1-dns-point-your-domains-to-the-staging-server">4. Step 1 — DNS: Point Your Domains to the Staging Server</a></p>
</li>
<li><p><a href="#heading-5-step-2-run-minio-with-docker-compose">5. Step 2 — Run MinIO with Docker Compose</a></p>
</li>
<li><p><a href="#heading-6-step-3-expose-minio-over-https-with-traefik">6. Step 3 — Expose MinIO over HTTPS with Traefik</a></p>
</li>
<li><p><a href="#heading-7-step-4-create-the-bucket-and-access-keys">7. Step 4 — Create the Bucket and Access Keys</a></p>
</li>
<li><p><a href="#heading-8-step-5-configure-your-app-to-use-minio-on-staging-only">8. Step 5 — Configure Your App to Use MinIO on Staging Only</a></p>
</li>
<li><p><a href="#heading-9-step-6-upload-files-3-ways">9. Step 6 — Upload Files (3 Ways)</a></p>
</li>
<li><p><a href="#heading-10-step-7-generate-presigned-urls-put-and-get">10. Step 7 — Generate Presigned URLs (PUT and GET)</a></p>
</li>
<li><p><a href="#heading-11-step-8-get-public-urls-for-documents">11. Step 8 — Get Public URLs for Documents</a></p>
</li>
<li><p><a href="#heading-12-step-9-lock-down-cors-lifecycle-and-security">12. Step 9 — Lock Down CORS, Lifecycle, and Security</a></p>
</li>
<li><p><a href="#heading-13-step-10-backups-and-monitoring">13. Step 10 — Backups and Monitoring</a></p>
</li>
<li><p><a href="#heading-14-troubleshooting-cheat-sheet">14. Troubleshooting Cheat Sheet</a></p>
</li>
<li><p><a href="#heading-15-wrapping-up">15. Wrapping Up</a></p>
</li>
</ul>
<h2 id="heading-1-why-selfhost-object-storage-on-staging">1. Why Self‑Host Object Storage on Staging?</h2>
<p>If your app handles documents — PDFs, profile pictures, application transcripts, recordings — every test upload your QA team makes costs real money on AWS S3, Cloudflare R2, or Hetzner Object Storage. The price isn't huge per file, but staging is where you:</p>
<ul>
<li><p>run automated end‑to‑end tests that upload thousands of dummy files,</p>
</li>
<li><p>reset databases nightly (which leaves orphan objects behind),</p>
</li>
<li><p>let developers experiment with broken code that re‑uploads the same files,</p>
</li>
<li><p>and hold months of test data nobody ever deletes.</p>
</li>
</ul>
<p>In production those costs are justified. Managed storage gives you replication, availability, and someone else's pager. In staging, those costs are pure waste.</p>
<p><a href="https://min.io/"><strong>MinIO</strong></a> is a free, open‑source, S3‑compatible object server. Same API, same SDKs, same presigned URLs, same <code>mc</code>/<code>aws s3</code> CLIs — but running on your own VPS, billed at $0 per gigabyte. Point your staging app at MinIO, point your production app at S3/R2, and the only thing that changes is an environment variable.</p>
<p><strong>The result:</strong> identical code paths in both environments, zero storage bill on staging, and a nice fallback if your cloud provider ever has an outage.</p>
<h2 id="heading-2-the-architecture-production-vs-staging">2. The Architecture: Production vs. Staging</h2>
<p>In real-world applications, you usually don’t want your development or staging environment writing directly to production storage.</p>
<p>A common and cost-effective setup is:</p>
<ul>
<li><p><strong>Production</strong>: managed cloud object storage</p>
</li>
<li><p><strong>Staging / Development</strong>: self-hosted S3-compatible storage</p>
</li>
</ul>
<p>The good part is that your application code doesn't need to change.</p>
<p>As long as both services are S3-compatible, the same SDK and upload logic work everywhere. Only the environment variables differ.</p>
<h3 id="heading-high-level-architecture">High-Level Architecture</h3>
<img src="https://cdn.hashnode.com/uploads/covers/66cb39fcaa2a09f9a8d691c1/01ddeefd-8a67-42e3-a3af-9b1d3664bdb2.png" alt="High-level architecture showing a Next.js application uploading files to Cloudflare R2 in production and MinIO in staging through the same S3-compatible API." style="display:block;margin:0 auto" width="426" height="421" loading="lazy">

<p>The above diagram illustrates how the same application can communicate with different storage providers depending on the deployment environment.</p>
<p>In the <strong>production environment</strong>, uploads are stored in a managed object storage service such as AWS S3, Cloudflare R2, or Hetzner Object Storage. These services handle durability, scalability, backups, and infrastructure management.</p>
<p>In the <strong>staging environment</strong>, uploads are directed to a self-hosted MinIO instance running inside Docker on a VPS. MinIO implements the S3 API, making it behave similarly to production storage while keeping costs low.</p>
<p>Because both storage systems are S3-compatible, the application uses the same upload logic in every environment. The only difference is the configuration provided through environment variables.</p>
<h3 id="heading-why-this-architecture-is-useful">Why This Architecture Is Useful</h3>
<p>This setup gives you:</p>
<ul>
<li><p>A cheap staging environment</p>
</li>
<li><p>Production-like testing</p>
</li>
<li><p>Zero storage vendor lock-in</p>
</li>
<li><p>The ability to switch providers without rewriting application code</p>
</li>
</ul>
<p>Because both environments speak the S3 protocol, your upload logic remains identical.</p>
<h3 id="heading-example-environment-variables">Example Environment Variables</h3>
<p>Your application only reads environment variables like these:</p>
<pre><code class="language-xml">S3_ENDPOINT=
S3_REGION=
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_BUCKET=
</code></pre>
<p>Switch the values, and the exact same application now uploads files to a different backend.</p>
<h3 id="heading-production-storage-example">Production Storage Example</h3>
<p>In production, you typically use managed object storage providers such as:</p>
<ul>
<li><p>AWS S3</p>
</li>
<li><p>Cloudflare R2</p>
</li>
<li><p>Hetzner Object Storage</p>
</li>
</ul>
<p>Example:</p>
<pre><code class="language-plaintext">S3_ENDPOINT=https://&lt;region&gt;.r2.cloudflarestorage.com
</code></pre>
<p>The benefits are that it's highly scalable, globally available, durable, has managed backups, and doesn't have infrastructure maintenance.</p>
<h3 id="heading-staging-environment-example">Staging Environment Example</h3>
<p>For staging, a lightweight self-hosted MinIO container is often enough.</p>
<pre><code class="language-plaintext">Next.js App
     ↓
MinIO Container (inside Docker on VPS)
</code></pre>
<p>Example domains:</p>
<table>
<thead>
<tr>
<th>Service</th>
<th>Domain</th>
<th>Internal Port</th>
</tr>
</thead>
<tbody><tr>
<td>MinIO S3 API</td>
<td><a href="http://minio-staging.domain.com"><code>minio-staging.domain.com</code></a></td>
<td><code>9000</code></td>
</tr>
<tr>
<td>MinIO Web Console</td>
<td><a href="http://minio-console-staging.domain.com"><code>minio-console-staging.domain.com</code></a></td>
<td><code>9001</code></td>
</tr>
</tbody></table>
<p>This allows you to:</p>
<ul>
<li><p>Test uploads safely</p>
</li>
<li><p>Avoid production storage costs</p>
</li>
<li><p>Reproduce production-like behavior locally</p>
</li>
</ul>
<h2 id="heading-3-prerequisites">3. Prerequisites</h2>
<p>You'll need:</p>
<ul>
<li><p>A Linux VPS (Hetzner, DigitalOcean, Contabo, OVH — anything with a public IP).</p>
</li>
<li><p>Two A records pointing at that IP (we'll register them next).</p>
</li>
<li><p>Docker + Docker Compose v2.</p>
</li>
<li><p><a href="https://traefik.io/">Traefik</a> v2 in front, with Let's Encrypt configured (any reverse proxy works&nbsp;– the labels below are Traefik's flavor).</p>
</li>
<li><p>Open ports <code>80</code> and <code>443</code> on the firewall for Let's Encrypt + HTTPS.</p>
</li>
<li><p>~10 GB free disk for the MinIO data volume to start.</p>
</li>
</ul>
<p>If Docker isn't installed:</p>
<pre><code class="language-bash">curl -fsSL https://get.docker.com | sh
sudo apt-get install -y docker-compose-plugin
docker --version &amp;&amp; docker compose version
</code></pre>
<h2 id="heading-4-step-1-dns-point-your-domains-to-the-staging-server">4. Step 1 — DNS: Point Your Domains to the Staging Server</h2>
<p>In your DNS provider (Cloudflare, Route 53, Namecheap, and so on), create two <strong>A records</strong> pointing at your staging server's public IP:</p>
<pre><code class="language-plaintext">minio-staging.domain.com           A    203.0.113.45
minio-console-staging.domain.com   A    203.0.113.45
</code></pre>
<p>If you use Cloudflare, set the proxy status to <strong>DNS only</strong> (gray cloud) for <code>minio-staging.*</code>. Cloudflare's free plan caps uploads at 100 MB, and you don't want it stripping S3 signing headers. The console subdomain can stay proxied if you want a WAF in front of it.</p>
<p>Wait a minute and verify:</p>
<pre><code class="language-bash">dig +short minio-staging.domain.com
# 203.0.113.45
</code></pre>
<h2 id="heading-5-step-2-run-minio-with-docker-compose">5. Step 2 — Run MinIO with Docker Compose</h2>
<p>Add this service to your staging compose file (<code>docker-compose.staging.yml</code>). MinIO is just one container — the disk is mounted as a Docker volume so data survives upgrades.</p>
<pre><code class="language-yaml"># docker-compose.staging.yml
networks:
  proxy:
    external: true
    name: proxy
  internal:
    name: internal

volumes:
  minio-data:

services:
  minio:
    image: minio/minio:latest
    container_name: minio-staging
    restart: unless-stopped
    environment:
      - MINIO_ROOT_USER=${MINIO_ROOT_USER:-admin}
      - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-change-me-please}
      # Tell MinIO which public domain to sign URLs with
      - MINIO_SERVER_URL=https://minio-staging.domain.com
      - MINIO_BROWSER_REDIRECT_URL=https://minio-console-staging.domain.com
    command: server /data --console-address ":9001"
    volumes:
      - minio-data:/data
    networks:
      - proxy
      - internal
    ports:
      - "9000:9000"  # S3 API
      - "9001:9001"  # Web console
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s
</code></pre>
<p>Two things deserve attention:</p>
<ul>
<li><p><code>MINIO_SERVER_URL</code> is the secret sauce. Without it, MinIO signs presigned URLs using its internal hostname (<code>http://minio:9000</code>), which then fails verification when the browser hits the public domain. Set it to the exact HTTPS URL clients will use.</p>
</li>
<li><p><code>MINIO_BROWSER_REDIRECT_URL</code> does the same for the web console (login redirects, OIDC callbacks, and so on).</p>
</li>
</ul>
<p>Bring it up:</p>
<pre><code class="language-bash">docker compose -f docker-compose.staging.yml up -d minio
docker compose -f docker-compose.staging.yml logs -f minio
</code></pre>
<p>You should see <code>API: http://...</code> and <code>Console: http://...</code> lines.</p>
<h2 id="heading-6-step-3-expose-minio-over-https-with-traefik">6. Step 3 — Expose MinIO over HTTPS with Traefik</h2>
<p>We don't expose ports <code>9000</code>/<code>9001</code> to the world directly — Traefik does that for us, terminating TLS with a free Let's Encrypt certificate.</p>
<p>Add these labels to the <code>minio</code> service:</p>
<pre><code class="language-yaml">    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"

      # ---- S3 API (port 9000) ----
      - "traefik.http.routers.minio-staging.rule=Host(`minio-staging.domain.com`)"
      - "traefik.http.routers.minio-staging.entrypoints=websecure"
      - "traefik.http.routers.minio-staging.tls.certresolver=letsencrypt"
      - "traefik.http.routers.minio-staging.service=minio-staging"
      - "traefik.http.services.minio-staging.loadbalancer.server.port=9000"

      # ---- Web Console (port 9001) ----
      - "traefik.http.routers.minio-console-staging.rule=Host(`minio-console-staging.domain.com`)"
      - "traefik.http.routers.minio-console-staging.entrypoints=websecure"
      - "traefik.http.routers.minio-console-staging.tls.certresolver=letsencrypt"
      - "traefik.http.routers.minio-console-staging.service=minio-console-staging"
      - "traefik.http.services.minio-console-staging.loadbalancer.server.port=9001"
</code></pre>
<p>You also need an <code>entrypoint</code> for <code>:443</code> and a <code>certificatesresolver</code> named <code>letsencrypt</code>. Here's the minimum Traefik config (<code>traefik.staging.yml</code>):</p>
<pre><code class="language-yaml">api:
  dashboard: true

entryPoints:
  web:
    address: ":80"
  websecure:
    address: ":443"

certificatesResolvers:
  letsencrypt:
    acme:
      httpChallenge:
        entryPoint: web
      email: admin@domain.com
      storage: /etc/traefik/acme.json

providers:
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false
    network: proxy
</code></pre>
<p>Restart and watch the cert get issued:</p>
<pre><code class="language-bash">docker compose -f docker-compose.staging.yml up -d
docker compose -f docker-compose.staging.yml logs -f traefik | grep -i acme
</code></pre>
<p>Sanity check from your laptop:</p>
<pre><code class="language-bash">curl -I https://minio-staging.domain.com/minio/health/live
# HTTP/2 200
</code></pre>
<p>You can now log in to the <strong>web console</strong> at <code>https://minio-console-staging.domain.com</code> with <code>admin</code> / <code>change-me-please</code>.</p>
<p><strong>Important upload size tweak:</strong> if you're behind Cloudflare or NGINX in front of Traefik, raise the request body limit. Traefik itself has no default limit, but Cloudflare's free plan refuses anything over 100 MB. For self‑hosted edge proxies, set <code>client_max_body_size 0;</code> (NGINX) or the equivalent.</p>
<h2 id="heading-7-step-4-create-the-bucket-and-access-keys">7. Step 4 — Create the Bucket and Access Keys</h2>
<p>Anything that speaks S3 can talk to MinIO. The easiest tool is <code>mc</code> (the official MinIO client), shipped inside the same image.</p>
<h3 id="heading-71-connect-mc-to-your-server">7.1 Connect mc to your server</h3>
<pre><code class="language-bash">docker exec -it minio-staging \
  mc alias set local http://localhost:9000 admin change-me-please
</code></pre>
<h3 id="heading-72-create-a-bucket">7.2 Create a bucket</h3>
<pre><code class="language-bash">docker exec -it minio-staging mc mb local/domain-files-staging
</code></pre>
<h3 id="heading-73-choose-a-bucket-policy">7.3 Choose a bucket policy</h3>
<p>You have three choices, so just pick based on what you store:</p>
<table>
<thead>
<tr>
<th>Policy</th>
<th>When to use</th>
</tr>
</thead>
<tbody><tr>
<td><code>private</code> (default)</td>
<td>Anything sensitive — student transcripts, contracts, internal docs. Reads only via presigned URL.</td>
</tr>
<tr>
<td><code>download</code></td>
<td>Public read, no listing. Good for CDN‑style assets like avatars.</td>
</tr>
<tr>
<td><code>public</code></td>
<td>Anyone can read AND list. Use only for truly public content.</td>
</tr>
</tbody></table>
<p>Set one:</p>
<pre><code class="language-bash"># Private (recommended for documents)
docker exec -it minio-staging \
  mc anonymous set none local/domain-files-staging

# OR public read for static assets only:
docker exec -it minio-staging \
  mc anonymous set download local/domain-files-staging
</code></pre>
<h3 id="heading-74-create-a-dedicated-app-user-dont-use-root-keys">7.4 Create a dedicated app user (don't use root keys!)</h3>
<p>The <code>admin</code> account can wipe everything. Make a least‑privilege user for your app:</p>
<pre><code class="language-bash">docker exec -it minio-staging mc admin user add local \
  domain-app a-long-random-secret-key

# Attach the built-in read/write policy, scoped to one bucket via JSON:
cat &gt; /tmp/policy.json &lt;&lt;'EOF'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:*"],
      "Resource": [
        "arn:aws:s3:::domain-files-staging",
        "arn:aws:s3:::domain-files-staging/*"
      ]
    }
  ]
}
EOF

docker cp /tmp/policy.json minio-staging:/tmp/policy.json
docker exec -it minio-staging \
  mc admin policy create local domain-rw /tmp/policy.json
docker exec -it minio-staging \
  mc admin policy attach local domain-rw --user domain-app
</code></pre>
<p>Save those two values — they are your <code>S3_ACCESS_KEY</code> and <code>S3_SECRET_KEY</code>.</p>
<h2 id="heading-8-step-5-configure-your-app-to-use-minio-on-staging-only">8. Step 5 — Configure Your App to Use MinIO on Staging Only</h2>
<p>The trick to "MinIO in staging, real S3 in prod" is to use the <strong>same S3 client</strong> in your code and only swap the env vars.</p>
<p>Your <code>staging.env</code> (loaded by your staging compose stack):</p>
<pre><code class="language-env"># ---- Staging: self-hosted MinIO ----
STORAGE_ENABLED=true
S3_ENDPOINT=https://minio-staging.domain.com
S3_PUBLIC_ENDPOINT=https://minio-staging.domain.com
S3_BUCKET=domain-files-staging
S3_ACCESS_KEY=domain-app
S3_SECRET_KEY=a-long-random-secret-key
S3_REGION=us-east-1
S3_FORCE_PATH_STYLE=true
</code></pre>
<p>Your <code>production.env</code>:</p>
<pre><code class="language-env"># ---- Production: Cloudflare R2 ----
STORAGE_ENABLED=true
S3_ENDPOINT=https://&lt;account-id&gt;.r2.cloudflarestorage.com
S3_PUBLIC_ENDPOINT=https://files.domain.com
S3_BUCKET=domain-files
S3_ACCESS_KEY=&lt;r2-access-key&gt;
S3_SECRET_KEY=&lt;r2-secret-key&gt;
S3_REGION=auto
S3_FORCE_PATH_STYLE=true
</code></pre>
<p><code>S3_FORCE_PATH_STYLE=true</code> is critical for both MinIO <strong>and</strong> R2/Hetzner. Without it, the SDK tries <code>https://bucket.minio-staging.domain.com</code> (virtual‑host style), which won't resolve.</p>
<p>Now in your application code (Node.js example using AWS SDK v3):</p>
<pre><code class="language-javascript">// src/lib/s3.js
import { S3Client } from "@aws-sdk/client-s3";

export const s3 = new S3Client({
  endpoint: process.env.S3_ENDPOINT,
  region: process.env.S3_REGION,
  credentials: {
    accessKeyId: process.env.S3_ACCESS_KEY,
    secretAccessKey: process.env.S3_SECRET_KEY,
  },
  forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true",
});

export const BUCKET = process.env.S3_BUCKET;
export const PUBLIC_ENDPOINT = process.env.S3_PUBLIC_ENDPOINT;
</code></pre>
<p>The same <code>s3</code> instance now talks to MinIO on staging and to R2 in production with no code change.</p>
<h2 id="heading-9-step-6-upload-files-3-ways">9. Step 6 — Upload Files (3 Ways)</h2>
<h3 id="heading-91-from-a-server-best-for-trusted-backends">9.1 From a server (best for trusted backends)</h3>
<pre><code class="language-javascript">import { PutObjectCommand } from "@aws-sdk/client-s3";
import { s3, BUCKET } from "./lib/s3.js";
import { readFile } from "node:fs/promises";

export async function uploadDocument(localPath, key, contentType) {
  const Body = await readFile(localPath);
  await s3.send(new PutObjectCommand({
    Bucket: BUCKET,
    Key: key,
    Body,
    ContentType: contentType,
    // Optional: per-object metadata, useful for audits
    Metadata: { uploadedBy: "system", env: process.env.NODE_ENV },
  }));
  return key;
}
</code></pre>
<h3 id="heading-92-with-the-mc-cli-good-for-oneoff-uploads-migrations">9.2 With the mc CLI (good for one‑off uploads / migrations)</h3>
<pre><code class="language-bash">mc alias set staging https://minio-staging.domain.com domain-app a-long-random-secret-key
mc cp ./report.pdf staging/domain-files-staging/reports/2026/report.pdf
mc ls staging/domain-files-staging --recursive
</code></pre>
<h3 id="heading-93-directly-from-the-browser-via-a-presigned-put-url">9.3 Directly from the browser via a presigned PUT URL</h3>
<p>The recommended pattern for user uploads is: the file goes from the browser to MinIO with <strong>zero</strong> bytes touching your API server.</p>
<p>We'll cover this in detail next.</p>
<h2 id="heading-10-step-7-generate-presigned-urls-put-and-get">10. Step 7 — Generate Presigned URLs (PUT and GET)</h2>
<p>A <strong>presigned URL</strong> is a regular HTTPS URL with a time‑limited signature in the query string. Anyone with the URL can do exactly the action it was signed for (PUT this object, or GET that object) for the next N minutes — and nothing else.</p>
<p>This is what makes "users upload directly to storage" safe.</p>
<h3 id="heading-101-presigned-put-for-uploads">10.1 Presigned PUT (for uploads)</h3>
<pre><code class="language-javascript">// src/lib/presign.js
import { PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { s3, BUCKET } from "./s3.js";
import { randomUUID } from "node:crypto";

export async function presignUpload({ filename, contentType, userId }) {
  const key = `users/\({userId}/\){randomUUID()}-${filename}`;
  const cmd = new PutObjectCommand({
    Bucket: BUCKET,
    Key: key,
    ContentType: contentType,
  });
  const uploadUrl = await getSignedUrl(s3, cmd, { expiresIn: 60 * 5 }); // 5 min
  return { uploadUrl, key };
}
</code></pre>
<p>Wire it to your API:</p>
<pre><code class="language-javascript">// POST /api/uploads/presign
app.post("/api/uploads/presign", requireAuth, async (req, res) =&gt; {
  const { filename, contentType } = req.body;
  const result = await presignUpload({
    filename,
    contentType,
    userId: req.user.id,
  });
  res.json(result); // { uploadUrl, key }
});
</code></pre>
<p>The browser uploads straight to MinIO:</p>
<pre><code class="language-javascript">// In your frontend
async function uploadFile(file) {
  const { uploadUrl, key } = await fetch("/api/uploads/presign", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ filename: file.name, contentType: file.type }),
  }).then(r =&gt; r.json());

  await fetch(uploadUrl, {
    method: "PUT",
    headers: { "Content-Type": file.type },
    body: file,
  });

  // Persist `key` in your DB so you can retrieve it later
  await fetch("/api/documents", {
    method: "POST",
    body: JSON.stringify({ key, originalName: file.name }),
  });
}
</code></pre>
<p>The <code>Content-Type</code> you send during PUT <strong>must match</strong> the one you signed with, or MinIO will reject the request with <code>SignatureDoesNotMatch</code>. This catches everyone the first time.</p>
<h3 id="heading-102-presigned-get-for-downloads">10.2 Presigned GET (for downloads)</h3>
<p>Same idea, but with <code>GetObjectCommand</code>:</p>
<pre><code class="language-javascript">export async function presignDownload(key, expiresIn = 60 * 10) {
  const cmd = new GetObjectCommand({ Bucket: BUCKET, Key: key });
  return getSignedUrl(s3, cmd, { expiresIn });
}
</code></pre>
<p>A typical "view document" endpoint:</p>
<pre><code class="language-javascript">app.get("/api/documents/:id/url", requireAuth, async (req, res) =&gt; {
  const doc = await db.documents.findById(req.params.id);
  if (!doc || !canUserSee(req.user, doc)) return res.sendStatus(403);
  const url = await presignDownload(doc.key, 600);
  res.json({ url });
});
</code></pre>
<p>The frontend just opens that URL — the file streams from MinIO directly to the user.</p>
<h3 id="heading-103-why-presigned-urls-beat-proxy-through-the-api">10.3 Why presigned URLs beat "proxy through the API"</h3>
<table>
<thead>
<tr>
<th></th>
<th>Proxy through API</th>
<th>Presigned URL</th>
</tr>
</thead>
<tbody><tr>
<td>Bytes through your app</td>
<td>All of them</td>
<td>Zero</td>
</tr>
<tr>
<td>API CPU/RAM cost</td>
<td>High</td>
<td>None</td>
</tr>
<tr>
<td>Throughput limit</td>
<td>Your API</td>
<td>MinIO's NIC</td>
</tr>
<tr>
<td>Auth check</td>
<td>Your code</td>
<td>Your code (still — check before signing)</td>
</tr>
</tbody></table>
<h2 id="heading-11-step-8-get-public-urls-for-documents">11. Step 8 — Get Public URLs for Documents</h2>
<p>Sometimes you want a permanent, unauthenticated URL — for example public profile pictures.</p>
<p>If the bucket policy allows anonymous reads (<code>mc anonymous set download …</code>), the public URL pattern is:</p>
<pre><code class="language-plaintext">https://minio-staging.domain.com/&lt;bucket&gt;/&lt;key&gt;
</code></pre>
<p>So <code>users/42/avatar.png</code> becomes:</p>
<pre><code class="language-plaintext">https://minio-staging.domain.com/domain-files-staging/users/42/avatar.png
</code></pre>
<p>In code:</p>
<pre><code class="language-javascript">export function publicUrl(key) {
  return `\({process.env.S3_PUBLIC_ENDPOINT}/\){BUCKET}/${key}`;
}
</code></pre>
<p>For <strong>private</strong> buckets (most documents), don't use public URLs at all — always go through <code>presignDownload(key)</code> so you can re‑check authorization on every request and expire links.</p>
<h2 id="heading-12-step-9-lock-down-cors-lifecycle-and-security">12. Step 9 — Lock Down CORS, Lifecycle, and Security</h2>
<h3 id="heading-121-allow-your-frontend-origins-cors">12.1 Allow your frontend origins (CORS)</h3>
<p>Browser uploads need CORS rules on the bucket. Drop this JSON via <code>mc</code>:</p>
<pre><code class="language-bash">cat &gt; /tmp/cors.json &lt;&lt;'EOF'
{
  "CORSRules": [
    {
      "AllowedOrigins": [
        "https://crm-staging.domain.com",
        "http://localhost:3000"
      ],
      "AllowedMethods": ["GET", "PUT", "POST", "HEAD"],
      "AllowedHeaders": ["*"],
      "ExposeHeaders": ["ETag"],
      "MaxAgeSeconds": 3000
    }
  ]
}
EOF

docker cp /tmp/cors.json minio-staging:/tmp/cors.json
docker exec -it minio-staging \
  mc cors set local/domain-files-staging /tmp/cors.json
</code></pre>
<h3 id="heading-122-autodelete-old-test-files-lifecycle">12.2 Auto‑delete old test files (lifecycle)</h3>
<p>Staging accumulates junk. Tell MinIO to expire anything older than 30 days:</p>
<pre><code class="language-bash">docker exec -it minio-staging \
  mc ilm rule add --expire-days 30 local/domain-files-staging
</code></pre>
<h3 id="heading-123-encrypt-at-rest">12.3 Encrypt at rest</h3>
<pre><code class="language-bash">docker exec -it minio-staging \
  mc encrypt set sse-s3 local/domain-files-staging
</code></pre>
<h3 id="heading-124-hard-rules">12.4 Hard rules</h3>
<ul>
<li><p><strong>Never</strong> ship <code>MINIO_ROOT_USER=admin</code> / <code>MINIO_ROOT_PASSWORD=admin123</code> to a server reachable from the internet. Generate strong values and store them in your secret manager.</p>
</li>
<li><p>The root account should be used only by <code>mc admin</code>, never by your app. The app uses a scoped IAM user (Step 7.4).</p>
</li>
<li><p>Keep the <strong>console</strong> subdomain behind an IP allow‑list or basic auth via Traefik middleware if it's truly public.</p>
</li>
<li><p>Rotate the app access keys at least every 90 days.</p>
</li>
</ul>
<h2 id="heading-13-step-10-backups-and-monitoring">13. Step 10 — Backups and Monitoring</h2>
<h3 id="heading-131-backups-mirror-to-a-cheap-cold-bucket-weekly">13.1 Backups: mirror to a cheap cold bucket weekly</h3>
<p>Set up a tiny cron job that uses <code>mc mirror</code> to push to Backblaze B2, R2, or another cheap S3 endpoint:</p>
<pre><code class="language-bash">mc alias set b2 https://s3.us-east-005.backblazeb2.com \(B2_KEY \)B2_SECRET
mc mirror --overwrite --remove \
  staging/domain-files-staging \
  b2/domain-staging-backup
</code></pre>
<p>Even at $6/TB/month this is essentially free for staging volumes.</p>
<h3 id="heading-132-monitoring-with-prometheus">13.2 Monitoring with Prometheus</h3>
<p>MinIO exposes Prometheus metrics out of the box at <code>/minio/v2/metrics/cluster</code>. Scrape with:</p>
<pre><code class="language-yaml">scrape_configs:
  - job_name: minio
    metrics_path: /minio/v2/metrics/cluster
    scheme: https
    static_configs:
      - targets: ["minio-staging.domain.com"]
</code></pre>
<p>If you have Grafana, import dashboard ID <strong>13502</strong> for an instant overview (capacity, request rates, latency, error counts).</p>
<h2 id="heading-14-troubleshooting-cheat-sheet">14. Troubleshooting Cheat Sheet</h2>
<table>
<thead>
<tr>
<th>Symptom</th>
<th>Likely cause</th>
<th>Fix</th>
</tr>
</thead>
<tbody><tr>
<td><code>SignatureDoesNotMatch</code> on presigned PUT</td>
<td>Browser sent a different <code>Content-Type</code> than what was signed</td>
<td>Send the exact same <code>Content-Type</code> header during PUT</td>
</tr>
<tr>
<td>Presigned URL works locally but not in browser</td>
<td><code>MINIO_SERVER_URL</code> not set, so URLs are signed for <code>minio:9000</code></td>
<td>Set <code>MINIO_SERVER_URL=https://minio-staging.domain.com</code> and restart</td>
</tr>
<tr>
<td><code>403 SignatureDoesNotMatch</code> after going through Cloudflare</td>
<td>Cloudflare strips/modifies headers</td>
<td>Set the DNS record to <strong>DNS‑only</strong> (gray cloud)</td>
</tr>
<tr>
<td><code>NoSuchBucket</code></td>
<td>App pointing at the wrong endpoint or bucket</td>
<td>Re‑check <code>S3_ENDPOINT</code> and <code>S3_BUCKET</code> in env</td>
</tr>
<tr>
<td>Browser CORS preflight fails</td>
<td>No CORS rule on the bucket</td>
<td>Apply the CORS JSON from §12.1</td>
</tr>
<tr>
<td>Upload works for small files, fails at 100 MB</td>
<td>Cloudflare free plan body limit</td>
<td>Use Cloudflare paid plan, or skip CF proxy</td>
</tr>
<tr>
<td><code>x509: certificate signed by unknown authority</code> from your app</td>
<td>App container doesn't trust Let's Encrypt</td>
<td>Update CA bundle (<code>apt install ca-certificates</code>) or use HTTP inside the Docker network</td>
</tr>
<tr>
<td>Web console redirects to <code>http://minio:9001/login</code></td>
<td><code>MINIO_BROWSER_REDIRECT_URL</code> missing</td>
<td>Set it to <code>https://minio-console-staging.domain.com</code></td>
</tr>
</tbody></table>
<p>Useful diagnostics:</p>
<pre><code class="language-bash"># Check MinIO health
curl -I https://minio-staging.domain.com/minio/health/live

# List all objects in a bucket
docker exec -it minio-staging mc ls --recursive local/domain-files-staging

# Tail MinIO logs
docker compose -f docker-compose.staging.yml logs -f minio

# Decode a presigned URL to see what it was signed for
echo "&lt;paste url&gt;" | tr '&amp;' '\n'
</code></pre>
<h2 id="heading-15-wrapping-up">15. Wrapping Up</h2>
<p>Here's what you have now:</p>
<ul>
<li><p>A free, S3‑compatible object store running on your own staging server.</p>
</li>
<li><p>Real HTTPS on a real domain (<code>https://minio-staging.domain.com</code>), thanks to Traefik + Let's Encrypt.</p>
</li>
<li><p>A scoped, least‑privilege application user — root keys stay locked away.</p>
</li>
<li><p>The same exact code paths in staging and production. Switching between MinIO / R2 / Hetzner / AWS S3 is a four‑variable change in the env file.</p>
</li>
<li><p>Presigned PUT URLs so users upload straight to storage, bypassing your API.</p>
</li>
<li><p>Presigned GET URLs so private documents are short‑lived and authorization‑gated.</p>
</li>
<li><p>Lifecycle rules that nuke old test files automatically.</p>
</li>
<li><p>Optional weekly mirror to a cold backup bucket.</p>
</li>
</ul>
<p>Production keeps running on managed storage where the SLA matters. Staging now costs you exactly <strong>$0 per month per gigabyte uploaded</strong> — and you can finally stop telling QA to "delete the test files when you're done."</p>
<h3 id="heading-further-reading">Further Reading</h3>
<ul>
<li><p><a href="https://min.io/docs/minio/container/index.html">MinIO Documentation</a></p>
</li>
<li><p><a href="https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-s3-request-presigner/">AWS SDK v3 — <code>getSignedUrl</code></a></p>
</li>
<li><p><a href="https://doc.traefik.io/traefik/providers/docker/">Traefik v2 Docker provider</a></p>
</li>
<li><p><a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucket-policies.html">S3 bucket policy reference</a></p>
</li>
</ul>
<p>If this guide saved your team a few dollars, share it with another team that's still uploading test PDFs to a $90/month S3 bucket. Happy shipping.</p>
 ]]>
                </content:encoded>
            </item>
        
            <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[ How to Dockerize a Go Application – Full Step-by-Step Walkthrough ]]>
                </title>
                <description>
                    <![CDATA[ Imagine that you want to share your source code with someone who doesn’t have Go installed on their computer. Unfortunately, this person won’t be able to run your application. Even if they do have Go  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-dockerize-a-go-application-full-step-by-step-walkthrough/</link>
                <guid isPermaLink="false">69f248846e0124c05e445b7a</guid>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ golang ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker compose ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Njong Emy ]]>
                </dc:creator>
                <pubDate>Wed, 29 Apr 2026 18:05:56 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/e49dda12-fd5e-4474-aa18-b72624640bf3.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Imagine that you want to share your source code with someone who doesn’t have Go installed on their computer. Unfortunately, this person won’t be able to run your application. Even if they do have Go installed, application behaviour may differ because your local development environment is different from theirs.</p>
<p>So how do you bundle up your application so that it can run the same way in every local environment? That’s where Docker comes in.</p>
<p>For beginners, Docker isn't always a very easy concept to grasp. But once you get it, I promise that it’s very interesting. So interesting that you’ll want to dockerize every application you lay your hands on.</p>
<p>For this article, a Go application will be our case study. The fundamental concept of containerization as explained here is transferable, so don’t worry too much about how dockerizing applications in another language will look like.</p>
<p>We’ll go through the basics of dockerizing a Go app with just Docker, images and containers, setting up multiple containers in one application with Docker Compose, and the constituent of a Docker Compose file.</p>
<p>By the end of this article, you'll have a basic understanding of what Docker is, what an image or container is, and how to orchestrate multiple, dependent containers with Docker Compose.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ol>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-is-docker">What is Docker</a>?</p>
</li>
<li><p><a href="#heading-how-to-install-docker">How to Install Docker</a></p>
</li>
<li><p><a href="#heading-what-is-a-dockerfile">What is a Dockerfile</a>?</p>
</li>
<li><p><a href="#heading-what-is-docker-compose">What is Docker Compose</a>?</p>
</li>
<li><p><a href="#heading-the-app-container">The app Container</a></p>
</li>
<li><p><a href="#heading-the-database-container">The database Container</a></p>
</li>
<li><p><a href="#heading-the-phpmyadmin-container">The phpMyAdmin Container</a></p>
</li>
<li><p><a href="#heading-running-everything-together">Running Everything Together</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You don't need any prior knowledge of Docker to follow this tutorial. This article is written with a beginner POV in mind, so it's okay if the concept is new to you.</p>
<p>In order to be fully engaged and understand the Go coding examples used here, it'll be helpful if you have basic knowledge of Golang. If you already understand how to set up a Go application on your local computer, you're good to go. If not, you can check this article on <a href="https://www.freecodecamp.org/news/how-to-get-started-coding-in-golang/">how to get started coding in Go</a>.</p>
<h2 id="heading-what-is-docker">What is Docker?</h2>
<p>Imagine that you have a box. In that box, you put your code and everything that it needs to run. That is, the programming language it uses and any other external packages you need to install.</p>
<p>If someone needs your application, you can just hand them the box. You can also hand this box to as many people as you want. They don’t need to install the language or any other thing on their computer because everything they need is already inside the box. So, when they run the application, what they're actually doing is running an instance of that box.</p>
<p>The app is running within the box which is the standard environment. This means for everyone who got the box and “opened it”, the application is going to run the exact same way.</p>
<p>With the help of Docker, apps can run under the same conditions across different systems, and you avoid the problem of “it works on my machine”.</p>
<img src="https://cdn.hashnode.com/uploads/covers/61d7e29f8d56921d07b9014e/3b2b169d-d882-48a8-88bf-233e4acec611.png" alt="A box containing dependencies, runtime, and source code that has arrows pointing to multiple developers" style="display:block;margin:0 auto" width="800" height="332" loading="lazy">

<p>In technical Docker terms, this box is called an <strong>image</strong> and the running instance is called a <strong>container</strong>.</p>
<p>An image is a lightweight, standalone, executable package that includes everything needed to run a piece of software. That is, code, runtime, libraries, system tools, and even the operating system.</p>
<p>A container is simply a runnable instance of an image. This represents the execution environment for a specific application.</p>
<p>If all this seems to abstract, don’t worry. We’ll get our hands dirty in a little bit.</p>
<h2 id="heading-how-to-install-docker">How to Install Docker</h2>
<p>In order to install Docker, we're going to install Docker Desktop which comes bundled up with the Docker Engine. Docker Destop is a GUI for managing containers, and you'll see how useful it is in subsequent sections.</p>
<p>At the time of writing, I'm using WSL (Windows Sub-system for Linux). If you're doing the same, you'll need to take that into consideration before installing because Docker requires different installation prerequisites and steps for different operating systems.</p>
<p>To install Docker Desktop on WSL,</p>
<ol>
<li><p>Download and install the <a href="https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe?utm_source=docker&amp;utm_medium=webreferral&amp;utm_campaign=docs-driven-download-windows&amp;_gl=1*6mcgze*_gcl_au*MTg5NDEzMjg4NS4xNzc0ODU5MzQ3*_ga*MTkwMzQzNjIyLjE3NzQ4NTkzNDc.*_ga_XJWPQMJYHQ*czE3NzY2MzUyMzgkbzMkZzEkdDE3NzY2MzY3MDkkajYwJGwwJGgw">windows</a> <code>.exe</code> file</p>
</li>
<li><p>Start Docker Desktop from the Start Menu and navigate to settings</p>
</li>
<li><p>Select <strong>Use WSL 2 based engine</strong> from the <strong>General</strong> tab</p>
</li>
<li><p>Click on apply.</p>
</li>
</ol>
<p>That’s it for the WSL installation. If you are running another operating system, the <a href="https://docs.docker.com/get-started/introduction/get-docker-desktop/">official docs</a> have a list of installation options for you.</p>
<h2 id="heading-what-is-a-dockerfile">What is a Dockerfile?</h2>
<p>In order to build your box in the first place, Docker needs to follow a couple of outlined steps. It needs to know the dependencies, the run time, and it also needs to have the source code. All these steps we list in a Dockerfile.</p>
<p>Before we get down to cracking anything, let’s create a working directory and navigate into it.</p>
<pre><code class="language-bash">mkdir go_book_api &amp;&amp; cd go_book_api
</code></pre>
<p>To intialise the Go module in your application, run the following command:</p>
<pre><code class="language-bash">go mod init go_book_api
</code></pre>
<p>This creates a <code>go.mod</code> file to keep track of your project dependencies. In the root of the project, create a <code>cmd</code> directory, and a <code>main.go</code> file in it. This will serve as the entry point of your application. In the <code>main.go</code> file, you can have a simple print statement:</p>
<pre><code class="language-go">// cmd/main.go
package main

import "fmt"

func main() {
	fmt.Println("Look at me gooo!")
}
</code></pre>
<p>Now, go ahead and create a file in the root of your project and call it <code>Dockerfile</code>. This file has no extensions, but your system automatically knows that it's a file for Docker commands.</p>
<p>Go ahead and paste the following in that file, and then we'll go through each of them one by one:</p>
<pre><code class="language-bash"># base image
FROM golang:1.24

# define the working directory
WORKDIR /app

# copy the go.mod and go.sum so that the packages to be installed
# are known in the container. ./ here is the WORKDIR, /app
COPY go.mod ./

# command to install modules
RUN go mod download

# copy source code into working dir
COPY . .

# build
RUN CGO_ENABLED=0 GOOS=linux go build -o /docker-gs-ping ./cmd/main.go

# run the compiled binary when the container starts
CMD ["/docker-gs-ping"]
</code></pre>
<p>Most Dockerfiles begin with a base image, which is specified by the <code>FROM</code> keyword. A base image is a foundational template that provides minimal operating system environment, libraries, or dependencies required to build and run an application within a container.</p>
<p>In this case, your base image is <code>golang:1.24</code> . Your base image could have been an operating system like Linux. In that case. when you ship your code to someone who isn’t running a Linux operating system, they wouldn’t have to worry because they will be running the application in an environment that already has a minimal Linux OS. In the same light, someone who doesn’t have Go installed locally can run your application.</p>
<p>To figure out what base image to use when setting up your Dockerfile, you can always peruse the official Docker Hub repository for published images. For this case, you can check out base images that are officially published by Golang <a href="https://hub.docker.com/hardened-images/catalog/dhi/golang/images">here</a>.</p>
<p>The next step is to define a working directory. Inside your box, you have a filesystem that is almost identical to the ones you’d see on a Linux system. You have folders like <code>/app</code>, <code>/bin</code> , <code>/usr</code> , and <code>/var</code> , and so on. The working directory you've defined in this case is <code>/app</code>, and it's done with the <code>WORKDIR</code> command.</p>
<p>After setting a working directory, you want to copy the <code>go.mod</code> and <code>go.sum</code> file into it, so that Docker knows what dependencies to add into your box.</p>
<p>The <code>COPY</code> command in Docker takes at least two arguments: the source directory(ies), and then the destination directory. In this case, you want to copy <code>go.mod</code> and <code>go.sum</code> into the working directory of your box, <code>/app</code>.</p>
<p>In the box, you'll run a command that downloads and installs all the modules defined in the <code>go.mod</code> file. To run a command in Docker environment, use <code>RUN</code> and then the command, which is <code>go mod download</code> in this case.</p>
<p>The next step is to copy any source code you have into the working directory.</p>
<p>At this point, you have the dependencies and the source code. The last step is to build the Go application into a single executable file which can be run inside your environment (inside the container).</p>
<p>Within the container, you’ll have a compiled binary at <code>/docker-gs-ping</code>, which is as a result of the compilation of the code in your <code>main.go</code> file. The last step is a <code>RUN</code> command that just tells Docker to run the executable binary after building it. It’s a way of saying “once the container starts running, execute this binary file”.</p>
<p>With these steps, Docker will build an image (a box per our analogy) that you can run. To build the image, you can run this command in your terminal:</p>
<pre><code class="language-go">docker build -t go_book_api .
</code></pre>
<p>The <code>docker build</code> command tells Docker to build an image based on the steps in the Dockerfile. <code>-t</code> is the flag for a tag, and this helps you refer to the image later when running the container.</p>
<p>To accompany your tag, you'll provide a name to the image which is <code>go_book_api</code> in this case. The <code>.</code> at the end is important because it tells Docker where the Dockerfile in question is, and the files that you need to copy into your image.</p>
<p>This is what the building looks like in my IDE:</p>
<img src="https://cdn.hashnode.com/uploads/covers/61d7e29f8d56921d07b9014e/361a805e-153d-4034-9d9a-d34c9015738a.png" alt="screenshot of IDE terminal showing a Docker image being built" style="display:block;margin:0 auto" width="1910" height="992" loading="lazy">

<p>If you check the Images tab on Docker Compose, you'll see that an image is built:</p>
<img src="https://cdn.hashnode.com/uploads/covers/61d7e29f8d56921d07b9014e/b569277e-295b-4a3d-8e51-fb91dd7e3d91.png" alt="screenshot of a built container image on Docker Desktop" style="display:block;margin:0 auto" width="1881" height="363" loading="lazy">

<p>You can host this image on a public image repository platform like <a href="https://www.docker.com/products/docker-hub/">Docker Hub</a>, and share it with your friends. They can pull your image, set it up, and run your application even if they don’t have Go installed. All they need to do is get the container running.</p>
<p>If you click on the little play button to the far-right, you can spin up an instance of the image (a container).</p>
<img src="https://cdn.hashnode.com/uploads/covers/61d7e29f8d56921d07b9014e/09726294-be22-458d-b660-5f6d32102205.png" alt="screenshot of Docker Compose modal for running a new container" style="display:block;margin:0 auto" width="825" height="758" loading="lazy">

<p>You can give a descriptive name to the container (Docker will generate a random one if you don’t), and click on the Run button. Once the container starts running, you're redirected to its log page.</p>
<p>Your container is up and running! You can see that this is a running instance of your application.</p>
<img src="https://cdn.hashnode.com/uploads/covers/61d7e29f8d56921d07b9014e/3133c16c-0950-4f03-9502-ae6495535c13.png" alt="screenshot of a running docker container on Docker Compose" style="display:block;margin:0 auto" width="1918" height="663" loading="lazy">

<h2 id="heading-what-is-docker-compose">What is Docker Compose?</h2>
<p>If you were building a simple Go application that needed no external dependencies, the above set-up would be more than sufficient.</p>
<p>In our example here, the application is supposed to be for a book API, so you’d expect that we'd have some service like a database and a database administrator client like phpMyAdmin to visualize or tables.</p>
<p>To set all this up in one file would be a little complicated using just Docker. This is because Docker doesn't allow you to have one base image for Go, another base image for a database, and so on, in one file.</p>
<p>You could use the base image of a small operating system, and then run commands to manually install these other services as dependencies, but this method makes your application hard to maintain and scale. This method isn't advisable because if one dependency crashes, the whole application will collapse instantly.</p>
<p>To remedy this situation, Docker compose allows you to have multiple containers for your application that are connected together. Docker compose handles running the containers in the right order, allows one container to use a folder from another container, or even keep its data in another container – and so on.</p>
<p>Our previous analogy of boxes is the same, except with Docker Compose, we don’t necessarily have only one box anymore:</p>
<img src="https://cdn.hashnode.com/uploads/covers/61d7e29f8d56921d07b9014e/2c890de4-8d5d-4457-a27a-fc441f58d794.png" alt="image of a box containing multiple containers that have arrows pointing to different developers" style="display:block;margin:0 auto" width="823" height="609" loading="lazy">

<p>The point of Docker Compose is to help you orchestrate multiple images needed to run your application. You can think of it as connecting several boxes together.</p>
<p>Following the explanation from before, your application would be running in the <code>Go book api</code> container, the book data we'll create with your application would be stored in the <code>mysql</code> container which is the database, and you can visualize your database with phpMyadmin, which is in the <code>phpMyadmin</code> container.</p>
<p>To see this technically, create a <code>docker-compose.yml</code> file in the root of the project. The name of this file is important, and Docker Compose only accepts filenames such as <code>compose.yml</code> , <code>docker-compose.yml</code> , or <code>docker-compose.yaml</code>. The file extension hints that the commands are written in <code>yaml</code> which is a language mostly used for file configurations.</p>
<pre><code class="language-bash">services:
  app:
    depends_on:
      - database
    build: 
      context: .
    container_name: go_book_api
    hostname: go_book_api
    networks:
      - go_book_api_net
    ports:
      - 8080:8080
    env_file:
      - .env
    
  database:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MYSQL_DATABASE: ${DB_NAME}
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_USER: ${DB_USER}
    volumes:
      - mysql-go:/var/lib/mysql
    ports:
      - 3356:3306
    networks:
      - go_book_api_net

  phpmyadmin:
    image: phpmyadmin
    restart: always
    ports:
      - 9000:80
    environment:
      PMA_HOST: database
      PMA_ARBITRARY: 1
    depends_on:
      - database
    networks:
      - go_book_api_net

volumes:
  mysql-go:

networks:
  go_book_api_net:
    driver: bridge
</code></pre>
<p>At the root level of the docker-compose file, you have <code>services</code> . These are all the containers that are your application needs to run, and in the context of Docker Compose, they're each regarded as a service.</p>
<h3 id="heading-the-app-container">The <code>app</code> Container</h3>
<pre><code class="language-bash"> app:
    depends_on:
      - database
    build: 
      context: .
    container_name: go_book_api
    hostname: go_book_api
    networks:
      - go_book_api_net
    ports:
      - 8080:8080
    env_file:
      - .env
</code></pre>
<p>The very first container is the <code>app</code> container, which is your Go application. Under the <code>app</code> container, you'll need to define a few parameters that this container also needs to run.</p>
<p>The <code>depends_on</code> attribute controls the start-up and shut-down order of services within a container. This ensures that if container A depends on container B to start, the container B should be started first so that container A can use it. In this case, the <code>database</code> container must be started before the <code>app</code> container. Note that this doesn't mean <code>app</code> will always wait for the <code>database</code> to be ready.</p>
<p>The next attribute which is <code>build</code> tells Docker Compose to build the Docker image from the local project. Since the Dockerfile for your application is in the root of your app, you'll specify the root path with the <code>context</code> attribute as <code>.</code> .</p>
<p>To give a specific name to your container, you'll use <code>container_name</code>. <code>hostname</code> is what other containers will use for communication.</p>
<p>Recall that the point of Docker Compose is to have multiple containers communicating with each other. They do this with the help of networks. So you'll create another attribute, <code>networks</code>, and give it a name, <code>go_book_api_net</code> . To every other container that you want to associate with this <code>app</code>, you're going to specify the same network.</p>
<p>The next attribute is <code>ports</code> . Your application is an API, which means it's running on a backend Go server. To access the API, you'll need to map a local port to a port on the container. You're mapping port <code>8080</code> on your computer to port <code>8080</code> in the container.</p>
<p>The <code>env_file</code> attribute just tells Docker Compose where to read environment variables from. In this case, you can create a <code>.env</code> file in the root of your project to store important variables that your container will need.</p>
<h3 id="heading-the-database-container">The <code>database</code> Container</h3>
<pre><code class="language-bash">  database:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MYSQL_DATABASE: ${DB_NAME}
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_USER: ${DB_USER}
    volumes:
      - mysql-go:/var/lib/mysql
    ports:
      - 3356:3306
    networks:
      - go_book_api_net
</code></pre>
<p>The second container is the <code>database</code> container. Note, that you can give whatever name you choose to your listed services, but giving your containers descriptive names is always a good convention to follow.</p>
<p>For your Go application database, you'll be working with a MySQL database in this case. Your application needs MySQL to run, so you must set it up as one of the services.</p>
<p>Remember that to build a container, you need a base image. Your base image in this case is <code>mysql:8.0</code> , as you've specified with the <code>image</code> property above. When trying to set up this container, Docker Compose knows to build your database container from this already existing official image.</p>
<p>If you’ve set up a database locally before, you know that configuration is a step you can’t skip. Every database you create needs a user, a password, and the database name. You can set these variables up in the <code>environment</code> property. Instead of hardcoding these values, you can set them up in a <code>.env</code> file, and reference the environmental variables as you've done here.</p>
<p>Database servers usually listen on specific ports for incoming connections, whether the database is running locally or remotely. Just as you specified for your <code>app</code> container, you can set a port for your database and map it to a corresponding port in the container. If you want to access the database locally, you'd do that on port <code>3356</code>, and all requests are forwarded to port <code>3306</code> in the database container.</p>
<p>Once your containers go functional and your application starts running, creating, and storing data in the database, you’ll realise that every time you stop and then restart your containers, you lose the data stored in the database.</p>
<p>To avoid this, you'll need to store your data outside the container. That way, you won't lose the contents of your database every time you stop running your containers.</p>
<p>This is what volumes are for. You can allocate a specific location outside the database container to store all that content. For your <code>volume</code> in this case, the storage location you specified is <code>mysql-go:/var/lib/mysql</code> .</p>
<p>Just as you set the network in your <code>app</code> container above to <code>go_book_api_net</code>, you'll specify the same network for this database container. Since you want the containers to communicate with each other, it makes sense that they're within the same network.</p>
<h3 id="heading-the-phpmyadmin-container">The <code>phpMyAdmin</code> Container</h3>
<p>The last container or last service you need (but that is optional) to configure in this case is the phpMyAdmin container. I find it easier having a database client because it lets me easily see the structure and content of my database.</p>
<pre><code class="language-bash"> phpmyadmin:
    image: phpmyadmin
    restart: always
    ports:
      - 9000:80
    environment:
      PMA_HOST: database
      PMA_ARBITRARY: 1
    depends_on:
      - database
    networks:
      - go_book_api_net
</code></pre>
<p>The process is almost the same as the previous containers you've configured. You'll start by pulling the official <code>phpmyadmin</code> image from Docker so that your container is built on it.</p>
<p>The <code>restart</code> option here is just so that if you stop and restart the container, phpMyAdmin automatically reloads again.</p>
<p>On the host machine, which is your local environment, you can have access to this service via port <code>9000</code> and it maps to port <code>80</code> in the container.</p>
<p>As for the <code>environment</code> , <code>PMA_HOST</code> tells phpMyAdmin to connect to a host called <code>database</code> (which is your database container). This works because both containers are on the same network, as you can see in the <code>networks</code> attribute. <code>PMA_ARBITRARY</code> is used so that if you decide to connect to another host (say, you set up a another database in future and still wish to connect via phpMyAdmin), you can do that via the UI.</p>
<p>Your database client depends on the <code>database</code> container, and so you need to specify that in <code>depends_on</code>:</p>
<pre><code class="language-bash">volumes:
  mysql-go:

networks:
  go_book_api_net:
    driver: bridge
</code></pre>
<p>The final section of your Docker Compose file is where you declared named values for the volume and network you've used in setting up your containers.</p>
<p>For the <code>volumes</code>, you'll declare a value called <code>mysql-go</code>. To the container where you want to attach this volume, you'll assign a specific storage location. You can see this in use in the database container.</p>
<pre><code class="language-bash"> volumes:
      - mysql-go:/var/lib/mysql
</code></pre>
<p>The same concept follows for the network. You have a named network called <code>go_book_api_net</code> that every container within this same network can use. The <code>driver</code> option is used here to specify the network type, and <code>bridge</code> is used for private internal networks.</p>
<h3 id="heading-running-everything-together">Running Everything Together</h3>
<p>Before Docker Compose, you had one Dockerfile that built a single container for your Go application. With Docker Compose, You’re gonna be building three containers (your application container, the database, and phpMyAdmin), and orchestrating them to work together as one single application.</p>
<p>You can push all this to a platform like GitHub, and someone can clone, start, and run the application without having any of these services (MySQL or PhpMyAdmin) installed locally on their computer. But they do need to have Docker installed.</p>
<p>To build your containers all together, you can use the command <code>docker compose build</code>:</p>
<img src="https://cdn.hashnode.com/uploads/covers/61d7e29f8d56921d07b9014e/0040fbdc-c541-494f-af9b-664d6a00bc17.png" alt="screenshot of IDE terminal showing build for an image" style="display:block;margin:0 auto" width="1871" height="959" loading="lazy">

<p>If you check your Docker Compose UI again, we see that a new image has been built, and it corresponds to the app service</p>
<img src="https://cdn.hashnode.com/uploads/covers/61d7e29f8d56921d07b9014e/736be9be-feb1-4888-8d15-c818e4683f4b.png" alt="screenshot of a built image on Docker Desktop" style="display:block;margin:0 auto" width="1913" height="363" loading="lazy">

<p>To start running the containers, you can use the command <code>docker compose up</code>:</p>
<img src="https://cdn.hashnode.com/uploads/covers/61d7e29f8d56921d07b9014e/8ba14bb9-77d5-48a1-b574-54a848f54b1e.png" alt="a screenshot of running containers in terminal IDE" style="display:block;margin:0 auto" width="1912" height="993" loading="lazy">

<p>If you navigate to the container tab of Docker Compose, you can see that your containers are up and running:</p>
<img src="https://cdn.hashnode.com/uploads/covers/61d7e29f8d56921d07b9014e/82e3d54d-bfec-4cea-806a-c52846a3e077.png" alt="A screenshot of running containers on Docker Desktop" style="display:block;margin:0 auto" width="1910" height="202" loading="lazy">

<p>The main app service, <code>go_book_api</code>, isn’t running because when you run your image, your binary runs and exits almost immediately.</p>
<p>In your <code>main.go</code>, let’s rewrite the code to set up a minimal HTTP handler function that listens on port <code>8080</code>:</p>
<pre><code class="language-go">// cmd/main.go
package main

import (
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("ok"))
	})

	log.Println("listening on :8080")
	if err := http.ListenAndServe(":8080", nil); err != nil {
		log.Fatal(err)
	}
}
</code></pre>
<p>If you’re new to Go, don’t let the code above bother you too much. All it does it set up a <code>health</code> endpoint with an associated handler function that listens on a port (<code>8080</code> in this case) and prints “ok”.</p>
<p>In your <code>Dockerfile</code>, let’s add a command to execute the created binary when the container starts:</p>
<pre><code class="language-go"># run the compiled binary when the container starts
CMD ["/docker-gs-ping"]
</code></pre>
<p>After adding this, you'll need to rebuild the containers and start them again. You can see that all containers are running now:</p>
<img src="https://cdn.hashnode.com/uploads/covers/61d7e29f8d56921d07b9014e/3ddf3e15-87b8-4978-851f-d6179e323166.png" alt="A screenshot of running containers on Docker Desktop" style="display:block;margin:0 auto" width="1555" height="202" loading="lazy">

<p>If you click on the <code>go_book_api</code> container, you can see that your server is running on port <code>8080</code> as configured:</p>
<img src="https://cdn.hashnode.com/uploads/covers/61d7e29f8d56921d07b9014e/ddd07614-eb53-4bfc-b088-e824f651ef6c.png" alt="A screenshot of a running container on Docker Desktop" style="display:block;margin:0 auto" width="1919" height="522" loading="lazy">

<p>Since your app is running on port <code>8080</code> and you have a <code>/health</code> endpoint set up for it, you can actually visit that endpoint in a browser to see the output “ok”.</p>
<img src="https://cdn.hashnode.com/uploads/covers/61d7e29f8d56921d07b9014e/39a1ea3e-7cbf-4d46-9bbe-bf8053d48586.png" alt="an image of health endpoint showing ok response on the browser" style="display:block;margin:0 auto" width="1086" height="163" loading="lazy">

<p>Also, if you click on the exposed <code>phpmyadmin</code> port, you can access the database client locally on port <code>9000</code>. Based on the environment variables set up in the <code>.env</code> file, you can log in.</p>
<img src="https://cdn.hashnode.com/uploads/covers/61d7e29f8d56921d07b9014e/8d7de244-7268-4d17-a779-785feae389c4.png" alt="screenshot of browser with phpMyAdmin login form" style="display:block;margin:0 auto" width="1915" height="962" loading="lazy">

<p>Another interesting thing to look for on Docker desktop is volumes. There is a volumes tab where you can see your configured <code>mysql-go</code> volume.</p>
<img src="https://cdn.hashnode.com/uploads/covers/61d7e29f8d56921d07b9014e/66d1dde3-2fc1-48aa-b701-7504dba2007f.png" alt="a screenshot of the volumes tab on Docker Desktop" style="display:block;margin:0 auto" width="1910" height="254" loading="lazy">

<p>You can always open these volumes/containers on the docker GUI, go through the files and logs, experiment with putting one container down and seeing how the others respond, and so on.</p>
<p>After this entire setup, what do you notice? You didn’t have to install Go, MySQL, or phpMyAdmin locally. You only used officially published base images to orchestrate a full application. That's the magic of Docker.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>Docker can be very abstract at the beginning, but understanding the fundamental purpose behind it makes everything much clearer.</p>
<p>In this article, you've learned what Docker is, how to containerize a basic Go application, and how to manage multiple containers with Docker Compose.</p>
<p>If you have trouble wrapping your head around why or how the Dockerfile is set up in the order that it is, my advice is not to get too stuck figuring it out on your own. As a Docker beginner, I realised that it’s easier if you imagine it as creating a recipe. If you try to build an image and it fails, you know there’s a step that you’re skipping.</p>
<p>The <a href="https://www.docker.com/">official docker documentation</a> has amazing resources if you want to understand Docker further than this tutorial. I encourage you to do so because this article only scratches the surface of the amazing things you can achieve with containerization.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Trace Multi-Agent AI Swarms with Jaeger v2 ]]>
                </title>
                <description>
                    <![CDATA[ When you run a single AI agent, debugging is straightforward. You read the log, you see what happened. When you run five agents in a swarm, each spawning its own tool calls and producing its own outpu ]]>
                </description>
                <link>https://www.freecodecamp.org/news/multi-agent-ai-swarms-tracing/</link>
                <guid isPermaLink="false">69eaae45904b915438cefb47</guid>
                
                    <category>
                        <![CDATA[ jaeger ]]>
                    </category>
                
                    <category>
                        <![CDATA[ OpenTelemetry ]]>
                    </category>
                
                    <category>
                        <![CDATA[ distributed tracing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ multi-agent systems ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ai agents ]]>
                    </category>
                
                    <category>
                        <![CDATA[ observability ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Christopher Galliart ]]>
                </dc:creator>
                <pubDate>Thu, 23 Apr 2026 23:41:57 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/308710e6-cfe6-4007-887a-c49a5e2e6b9a.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>When you run a single AI agent, debugging is straightforward. You read the log, you see what happened.</p>
<p>When you run five agents in a swarm, each spawning its own tool calls and producing its own output, "read the log" stops being a strategy.</p>
<p>I built <a href="https://github.com/HatmanStack/claude-forge">Claude Forge</a> as an adversarial multi-agent coding framework on top of Claude Code. A typical run spawns a planner, an implementer, a reviewer, and a fixer. They evaluate each other's work and loop back when quality checks fail.</p>
<p>But when something went wrong, I had timestamps and text dumps but no way to see which agent was responsible, how long it actually took, or where the tokens went.</p>
<p>Jaeger fixed that. This article covers setting up Jaeger v2 with Docker, wiring it into a multi-agent system through OpenTelemetry, and what I learned along the way.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-distributed-tracing">What Is Distributed Tracing?</a></p>
</li>
<li><p><a href="#heading-why-jaeger-v2">Why Jaeger v2?</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-installing-docker-on-debian">Installing Docker on Debian</a></p>
</li>
<li><p><a href="#heading-setting-up-jaeger-v2">Setting Up Jaeger v2</a></p>
</li>
<li><p><a href="#heading-setting-up-claude-forge-tracing">Setting Up Claude Forge Tracing</a></p>
</li>
<li><p><a href="#heading-understanding-the-span-model">Understanding the Span Model</a></p>
</li>
<li><p><a href="#heading-instrumenting-a-multi-agent-swarm">Instrumenting a Multi-Agent Swarm</a></p>
</li>
<li><p><a href="#heading-viewing-traces-in-the-jaeger-ui">Viewing Traces in the Jaeger UI</a></p>
</li>
<li><p><a href="#heading-lessons-from-the-trenches">Lessons from the Trenches</a></p>
</li>
<li><p><a href="#heading-environment-variable-reference">Environment Variable Reference</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ul>
<h2 id="heading-what-is-distributed-tracing">What Is Distributed Tracing?</h2>
<p>Distributed tracing tracks a single operation as it moves through multiple services. A span is one unit of work with a start time, end time, and key-value attributes. Spans nest into parent-child trees. One tree per operation is one trace.</p>
<p>Microservices people already know this pattern: follow an HTTP request from the gateway through auth, the database, and the cache. Same idea works for multi-agent AI. Follow one swarm invocation from the orchestrator through each subagent and its tool calls.</p>
<p>OpenTelemetry (OTel) is the standard. It gives you SDKs for creating spans and shipping them over OTLP. Jaeger receives that data and renders it as a searchable timeline.</p>
<h2 id="heading-why-jaeger-v2">Why Jaeger v2?</h2>
<p>Jaeger started at Uber and graduated as a CNCF project in 2019. v1 hit end of life in December 2025. v2 is the current release, built on the OpenTelemetry Collector framework. Single binary: collector, query service, and UI. It speaks OTLP natively on port 4317 (gRPC) and 4318 (HTTP). There's no separate collector needed for local work.</p>
<p>One important difference from v1: configuration moved from CLI flags and environment variables to a YAML file. The old <code>-e SPAN_STORAGE_TYPE=badger</code> env vars are silently ignored in v2. The container starts fine but falls back to in-memory storage. I lost two days of traces before noticing. More on the correct setup below.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p><strong>Docker</strong> installed and running.</p>
</li>
<li><p><strong>Claude Code</strong> installed.</p>
</li>
<li><p><strong>Python 3.8+</strong> for the tracing hook.</p>
</li>
<li><p><strong>Claude Forge</strong> or another multi-agent system to instrument.</p>
</li>
</ul>
<h2 id="heading-installing-docker-on-debian">Installing Docker on Debian</h2>
<p>Skip this if you already have Docker. macOS and Windows users can use Docker Desktop. On Debian:</p>
<pre><code class="language-bash">sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
  https://download.docker.com/linux/debian \
  \((. /etc/os-release &amp;&amp; echo "\)VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list &gt; /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER
newgrp docker
</code></pre>
<p>Ubuntu users: replace both <code>linux/debian</code> URLs with <code>linux/ubuntu</code>.</p>
<h2 id="heading-setting-up-jaeger-v2">Setting Up Jaeger v2</h2>
<h3 id="heading-basic-run">Basic Run</h3>
<p>For quick testing with no persistence:</p>
<pre><code class="language-bash">docker run -d --name jaeger \
  -p 16686:16686 \
  -p 4317:4317 \
  -p 4318:4318 \
  jaegertracing/jaeger:2.17.0
</code></pre>
<p>Port 16686 is the UI. Port 4317 is OTLP/gRPC ingestion. Port 4318 is OTLP/HTTP. Remove the container and your traces are gone.</p>
<h3 id="heading-persistent-storage-with-badger">Persistent Storage with Badger</h3>
<p>v2 reads configuration from a YAML file, not environment variables. Save this as <code>~/.local/share/jaeger/config.yaml</code>:</p>
<pre><code class="language-yaml">service:
  extensions: [jaeger_storage, jaeger_query, healthcheckv2]
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [jaeger_storage_exporter]
extensions:
  healthcheckv2:
    use_v2: true
    http: { endpoint: 0.0.0.0:13133 }
  jaeger_query:
    storage: { traces: main_store }
  jaeger_storage:
    backends:
      main_store:
        badger:
          directories: { keys: /badger/key, values: /badger/data }
          ephemeral: false
          ttl: { spans: 720h }
receivers:
  otlp:
    protocols:
      grpc: { endpoint: 0.0.0.0:4317 }
      http: { endpoint: 0.0.0.0:4318 }
processors:
  batch:
exporters:
  jaeger_storage_exporter:
    trace_storage: main_store
</code></pre>
<p>The Jaeger container runs as UID 10001. Docker named volumes default to root ownership. Without fixing permissions first, the container crash-loops with <code>mkdir /badger/key: permission denied</code>.</p>
<p>Pre-create the volume and fix ownership:</p>
<pre><code class="language-bash">docker volume create jaeger-data

docker run --rm \
  -v jaeger-data:/badger \
  alpine sh -c "mkdir -p /badger/data /badger/key &amp;&amp; chown -R 10001:10001 /badger"
</code></pre>
<p>Then run Jaeger with the config mounted in:</p>
<pre><code class="language-bash">docker run -d --name jaeger \
  --restart unless-stopped \
  -v ~/.local/share/jaeger/config.yaml:/etc/jaeger/config.yaml:ro \
  -v jaeger-data:/badger \
  -p 16686:16686 \
  -p 4317:4317 \
  -p 4318:4318 \
  jaegertracing/jaeger:2.17.0 \
  --config /etc/jaeger/config.yaml
</code></pre>
<p>Verify persistence by running <code>docker restart jaeger</code> and confirming a previously recorded trace is still there. Hit <code>http://localhost:16686</code> and you should see the UI.</p>
<h2 id="heading-setting-up-claude-forge-tracing">Setting Up Claude Forge Tracing</h2>
<h3 id="heading-installing-claude-forge">Installing Claude Forge</h3>
<p>Install it through the Claude Code plugin marketplace:</p>
<pre><code class="language-bash">/plugin marketplace add hatmanstack/claude-forge
/plugin install forge@claude-forge
/reload-plugins
</code></pre>
<p>The install opens a TUI to confirm scope and settings. After reload, commands use the <code>forge:</code> prefix (for example, <code>/forge:pipeline</code>).</p>
<p>You can also clone the repo from <a href="https://github.com/HatmanStack/claude-forge">GitHub</a>.</p>
<h3 id="heading-installing-the-tracing-hook">Installing the Tracing Hook</h3>
<p>From your target project directory, run the install script. For plugin installs:</p>
<pre><code class="language-bash">cd your-project
forge-trace                # if you set up the alias from the README
# or, without the alias:
bash "$(find ~/.claude -path '*/forge*' -name install-tracing.sh 2&gt;/dev/null | head -1)"
</code></pre>
<p>For clone installs:</p>
<pre><code class="language-bash">cd your-project
bash /path/to/claude-forge/bin/install-tracing.sh
</code></pre>
<p>The script builds a dedicated venv at <code>~/.local/share/claude-forge/venv</code> (prefers <code>uv</code>, falls back to <code>python3 -m venv</code>), installs the OpenTelemetry packages, copies the hook into place, merges hook entries into <code>.claude/settings.local.json</code>, and self-tests against the OTLP endpoint.</p>
<p>Pass <code>--no-settings</code> to skip the settings merge, or <code>--uninstall</code> to tear everything down.</p>
<h3 id="heading-opting-in">Opting In</h3>
<p>Add to your shell init and restart your terminal:</p>
<pre><code class="language-bash">export CLAUDE_FORGE_TRACING=1
</code></pre>
<p>Restart Claude Code, run <code>/pipeline</code>, then check <code>http://localhost:16686</code> for the <code>claude-forge</code> service.</p>
<h2 id="heading-understanding-the-span-model">Understanding the Span Model</h2>
<p>Here's what the hierarchy looks like for a typical swarm run:</p>
<pre><code class="language-plaintext">session: "implement login form with OAuth"        &lt;- root span
├── subagent:planner
│   ├── tool:Write  (Phase-0.md)                  &lt;- mutation spans (on by default)
│   ├── tool:Write  (Phase-1.md)
│   └── subagent_result:planner                   &lt;- duration, token counts, output
├── subagent:implementer
│   ├── tool:Edit   (src/auth.ts)
│   ├── tool:Bash   (npm test)
│   ├── tool:Write  (src/oauth.ts)
│   └── subagent_result:implementer
├── subagent:reviewer
│   └── subagent_result:reviewer
└── session_complete                              &lt;- session totals
</code></pre>
<p>The root span's name comes from the first line of your prompt. Find traces by what you asked for, not by a UUID.</p>
<p>Subagents get an anchor span on start and a result span on completion. The result carries duration, token counts, prompt, and output.</p>
<h3 id="heading-three-tiers-of-detail">Three Tiers of Detail</h3>
<p>Not all inner tool calls are equally interesting. Write, Edit, MultiEdit, and Bash are mutational: small in number, high signal. They tell you what actually changed. Read, Glob, Grep, and WebFetch are navigation: lots of them, mostly noise.</p>
<p>Tracing captures mutations by default. That middle ground turned out to be the right one. Before this change, you either saw nothing inside subagents or you saw 200+ spans per run.</p>
<table>
<thead>
<tr>
<th>Mode</th>
<th>Subagents</th>
<th>Mutations (Write/Edit/Bash)</th>
<th>Other inner tools</th>
</tr>
</thead>
<tbody><tr>
<td>Default</td>
<td>yes</td>
<td>yes</td>
<td>no</td>
</tr>
<tr>
<td><code>CLAUDE_FORGE_TRACE_INNER=1</code></td>
<td>yes</td>
<td>yes</td>
<td>yes (minus blocklist)</td>
</tr>
<tr>
<td><code>CLAUDE_FORGE_TRACE_MUTATIONS=0</code></td>
<td>yes</td>
<td>no</td>
<td>no (or per INNER)</td>
</tr>
</tbody></table>
<h3 id="heading-span-attributes">Span Attributes</h3>
<p><strong>On</strong> <code>session_complete</code><strong>:</strong> <code>session.tokens.input</code>, <code>session.tokens.output</code>, <code>session.tokens.total</code>, <code>session.tokens.turns</code>, <code>session.duration_ms</code>, <code>user.prompt</code> (first 2KB).</p>
<p><strong>On</strong> <code>subagent_result</code><strong>:</strong> <code>agent.description</code>, <code>agent.prompt</code>, <code>agent.output</code>, <code>agent.duration_ms</code>, <code>agent.is_error</code>, <code>agent.tokens.input</code>, <code>agent.tokens.output</code>.</p>
<p><strong>On</strong> <code>tool:*</code><strong>:</strong> <code>tool.name</code>, <code>tool.input</code>, <code>tool.output</code>, <code>tool.duration_ms</code>, <code>tool.is_error</code>.</p>
<h2 id="heading-instrumenting-a-multi-agent-swarm">Instrumenting a Multi-Agent Swarm</h2>
<h3 id="heading-hook-architecture">Hook Architecture</h3>
<p>Claude Code has lifecycle hooks that fire scripts on specific events. Four matter here:</p>
<ol>
<li><p><strong>UserPromptSubmit</strong> (create the root span),</p>
</li>
<li><p><strong>PreToolUse</strong> (start a span),</p>
</li>
<li><p><strong>PostToolUse</strong> (end it with results), and</p>
</li>
<li><p><strong>Stop</strong> (finalize the trace). Each hook gets a JSON payload on stdin and runs as a subprocess.</p>
</li>
</ol>
<h3 id="heading-sending-spans-with-opentelemetry">Sending Spans with OpenTelemetry</h3>
<p>Here's some minimal Python to get a span into Jaeger:</p>
<pre><code class="language-python">from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource

resource = Resource.create({"service.name": "my-agent-system"})
exporter = OTLPSpanExporter(endpoint="http://localhost:4317", insecure=True)
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)

tracer = trace.get_tracer("agent-tracer")

with tracer.start_as_current_span("my-agent-task") as span:
    span.set_attribute("agent.name", "planner")
    span.set_attribute("agent.tokens.input", 1500)
    span.set_attribute("agent.tokens.output", 800)
</code></pre>
<p>Refresh <code>localhost:16686</code>, pick your service, click "Find Traces."</p>
<h3 id="heading-correlating-pre-and-post-events">Correlating Pre and Post Events</h3>
<p>You need to match each PreToolUse to its PostToolUse. Agent-type tool calls didn't include a <code>tool_use_id</code> in the payload, so I hashed the tool name and input instead. Pre and Post carry identical <code>tool_input</code>, so the hashes line up.</p>
<pre><code class="language-python">import hashlib, json

def correlation_key(tool_name: str, tool_input: dict) -&gt; str:
    content = json.dumps({"tool": tool_name, "input": tool_input}, sort_keys=True)
    return hashlib.sha1(content.encode()).hexdigest()[:16]
</code></pre>
<h3 id="heading-state-across-invocations">State Across Invocations</h3>
<p>Every hook call is a separate process. No shared memory. So I wrote span context to JSON files on Pre and read them back on Post:</p>
<pre><code class="language-plaintext">/tmp/claude-forge-tracing/&lt;session_id&gt;/
├── _root.json              # trace ID, root span context
├── _session_start_ns.json  # timestamp for duration calculation
├── subagent_&lt;hash&gt;.json    # per-subagent span context
└── tool_&lt;hash&gt;.json        # per-tool span context
</code></pre>
<p>File names get sanitized against path traversal. <code>_safe_name()</code> strips everything outside <code>[A-Za-z0-9._-]</code> and falls back to a SHA1 slug.</p>
<h3 id="heading-flushing-without-blocking">Flushing Without Blocking</h3>
<pre><code class="language-python">try:
    provider.force_flush(timeout_millis=1000)
except Exception:
    pass  # Never block the swarm
</code></pre>
<p>I tried 2000ms first and the swarm felt slow. 100ms lost spans on cold TLS connections. 1000ms worked. If Jaeger is down, the swarm keeps running regardless.</p>
<h2 id="heading-viewing-traces-in-the-jaeger-ui">Viewing Traces in the Jaeger UI</h2>
<p>Open <code>http://localhost:16686</code>. Pick <code>claude-forge</code> from the service dropdown. Click "Find Traces."</p>
<p>The trace search filters by operation name, tags, and time range. Since session spans take their name from your prompt, searching "login form" pulls up the runs where you asked for one.</p>
<p>The timeline view is where I spend most of my time. Every span is a horizontal bar, nested by parent-child relationships. I can see the planner took 12 seconds, the implementer 45, the reviewer 8. Click any bar to see token counts, prompts, outputs, error status.</p>
<p>Trace comparison puts two runs side by side. This is good for figuring out why one run succeeded and another did not.</p>
<h2 id="heading-lessons-from-the-trenches">Lessons from the Trenches</h2>
<p><strong>One trace per swarm, not per subagent:</strong> My first version wiped the root span's state file on every Stop event, so each subagent started a new trace. I changed Stop to mark a timestamp while preserving the root.</p>
<p><strong>Use descriptions, not type names:</strong> Subagents all report their type as <code>general-purpose</code>. The description field is where the actual role lives.</p>
<p><strong>Token attribution needs per-agent transcripts:</strong> Claude Code writes subagent transcripts to <code>~/.claude/projects/&lt;project&gt;/&lt;session&gt;/subagents/agent-*.jsonl</code>. Match them via <code>agent-*.meta.json</code>.</p>
<p><strong>Parse boolean env vars explicitly:</strong> <code>bool("0")</code> in Python is <code>True</code>. Use an allowlist: <code>{"1", "true", "yes", "on"}</code>.</p>
<h2 id="heading-environment-variable-reference">Environment Variable Reference</h2>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td><code>CLAUDE_FORGE_TRACING=1</code></td>
<td>Master opt-in. Hook is a no-op without this.</td>
</tr>
<tr>
<td><code>CLAUDE_FORGE_TRACE_MUTATIONS=0</code></td>
<td>Disable default mutation spans (Write/Edit/Bash). On by default.</td>
</tr>
<tr>
<td><code>CLAUDE_FORGE_TRACE_INNER=1</code></td>
<td>Capture all inner tool calls as child spans (off by default).</td>
</tr>
<tr>
<td><code>CLAUDE_FORGE_TRACE_TOOL_BLOCKLIST</code></td>
<td>Comma-separated tools to skip when inner tracing is on. Defaults to <code>Read,Glob,Grep,TodoWrite,NotebookRead</code>.</td>
</tr>
<tr>
<td><code>CLAUDE_FORGE_HOOK_DEBUG=1</code></td>
<td>Enable debug logging of raw hook payloads. Off by default.</td>
</tr>
<tr>
<td><code>CLAUDE_FORGE_HOOK_DEBUG_LOG</code></td>
<td>Override debug log path. Defaults to <code>~/.cache/claude-forge/hook.log</code>.</td>
</tr>
<tr>
<td><code>OTEL_EXPORTER_OTLP_ENDPOINT</code></td>
<td>OTLP/gRPC endpoint. Defaults to <code>http://localhost:4317</code>.</td>
</tr>
</tbody></table>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>Without visibility into the process, you're being inefficient with tokens and your time. Multi-agent swarms cost real money on every run. When an agent fails and retries, or when a reviewer rejects work that was close, you're paying for that blind.</p>
<p>Tracing gives you the map. You find out where the failure modes are. You find out which agents burn tokens going nowhere. A 45-second implementer run might have been 10 seconds with a better planner prompt. But you would never know that without seeing the breakdown.</p>
<p>Get observability in early. Jaeger and OpenTelemetry make it cheap to set up. Once you can see where things go wrong you can actually fix them.</p>
<p>Claude Forge tracing is on the <a href="https://github.com/HatmanStack/claude-forge">main branch</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How I Built a Production-Ready CI/CD Pipeline for a Monorepo-Based Microservices System with Jenkins, Docker Compose, and Traefik ]]>
                </title>
                <description>
                    <![CDATA[ This tutorial is a complete, real-world guide to building a production-ready CI/CD pipeline using Jenkins, Docker Compose, and Traefik on a single Linux server. You’ll learn how to expose services on  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-production-ready-ci-cd-pipeline-for-monorepo-based-microservices-system/</link>
                <guid isPermaLink="false">69ea60c8904b915438a58ca2</guid>
                
                    <category>
                        <![CDATA[ Jenkins ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Traefik ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Md Tarikul Islam ]]>
                </dc:creator>
                <pubDate>Thu, 23 Apr 2026 18:11:20 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/66cb39fcaa2a09f9a8d691c1/d59c62f5-e376-4f09-851f-83e437f9960a.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>This tutorial is a complete, real-world guide to building a production-ready CI/CD pipeline using Jenkins, Docker Compose, and Traefik on a single Linux server.</p>
<p>You’ll learn how to expose services on a custom domain with auto-renewing HTTPS, and implement a smart deployment strategy that detects changes and redeploys only the affected microservices. This helps avoid unnecessary full-stack redeploys. We'll also cover real production issues and the exact fixes for each one.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a href="#heading-1-what-youll-build">1. What you'll build</a></p>
</li>
<li><p><a href="#heading-2-architecture">2. Architecture</a></p>
</li>
<li><p><a href="#heading-3-server-prerequisites">3. Server prerequisites</a></p>
</li>
<li><p><a href="#heading-4-traefik-the-reverse-proxy">4. Traefik — the reverse proxy</a></p>
</li>
<li><p><a href="#heading-5-run-jenkins-in-docker">5. Run Jenkins in Docker</a></p>
</li>
<li><p><a href="#heading-6-expose-jenkins-on-a-domain-via-traefik">6. Expose Jenkins on a domain via Traefik</a></p>
</li>
<li><p><a href="#heading-7-first-time-jenkins-setup">7. First-time Jenkins setup</a></p>
</li>
<li><p><a href="#heading-8-add-the-github-credential">8. Add the GitHub credential</a></p>
</li>
<li><p><a href="#heading-9-create-the-pipeline-job">9. Create the pipeline job</a></p>
</li>
<li><p><a href="#heading-10-the-jenkinsfile-deploy-only-what-changed">10. The Jenkinsfile (deploy only what changed)</a></p>
</li>
<li><p><a href="#heading-11-end-to-end-test">11. End-to-end test</a></p>
</li>
<li><p><a href="#heading-12-troubleshooting-every-error-we-hit">12. Troubleshooting — every error we hit</a></p>
</li>
<li><p><a href="#heading-13-mental-model-host-vs-container">13. Mental model: host vs. container</a></p>
</li>
<li><p><a href="#heading-14-daily-operations-cheat-sheet">14. Daily operations cheat sheet</a></p>
</li>
<li><p><a href="#heading-15-what-id-do-differently-next-time">15. What I'd do differently next time</a></p>
</li>
<li><p><a href="#heading-closing-thoughts">Closing thoughts</a></p>
</li>
</ul>
<h2 id="heading-1-what-youll-build">1. What You'll Build</h2>
<p>In this tutorial, you'll build a Jenkins instance running inside Docker on the same Linux server as your application stack.</p>
<p>Traefik will act as a reverse proxy in front of Jenkins, exposing it via a clean URL (<a href="https://jenkins.example.com"><code>https://jenkins.example.com</code></a>) with <strong>auto-renewing Let's Encrypt certificates</strong>.</p>
<p>You'll also create a Jenkinsfile in your application repository that:</p>
<ul>
<li><p>Automatically triggers on every push to the <code>staging</code> branch,</p>
</li>
<li><p>Detects which microservices changed in each commit,</p>
</li>
<li><p>Pulls the latest code on the host machine,</p>
</li>
<li><p>Rebuilds and restarts <strong>only the affected services</strong>.</p>
</li>
</ul>
<p>On every push, only the relevant services are redeployed.</p>
<h3 id="heading-prerequisites">Prerequisites</h3>
<p>Before jumping in, this guide assumes you’re already comfortable with a few core concepts and tools.</p>
<p>This isn't a beginner-level tutorial — we’ll be working directly with infrastructure, containers, and CI/CD pipelines.</p>
<p>You should be familiar with:</p>
<ul>
<li><p>Basic Linux commands (SSH, file system navigation, permissions)</p>
</li>
<li><p>Docker fundamentals (images, containers, volumes, networks)</p>
</li>
<li><p>Git workflows (clone, pull, branches)</p>
</li>
<li><p>General idea of CI/CD pipelines</p>
</li>
</ul>
<p>Tools and environment required:</p>
<ul>
<li><p>A Linux server (Ubuntu recommended)</p>
</li>
<li><p>Docker Engine + Docker Compose (v2)</p>
</li>
<li><p>A domain name (for Traefik + HTTPS)</p>
</li>
<li><p>GitHub repository (for your backend project)</p>
</li>
<li><p>Basic understanding of microservices architecture</p>
</li>
</ul>
<p>If you’re comfortable with the above, you’re ready to follow along.</p>
<h2 id="heading-2-architecture">2. Architecture</h2>
<p>Here's an overview of the architecture:</p>
<pre><code class="language-plaintext">┌──────────────────────────── Linux server (Ubuntu) ────────────────────────────┐
│                                                                               │
│   /home/developer/projects/                                                  │
│       └── project-prod-configs/             ← infra repo (compose, Traefik) │
│              ├── docker-compose.staging.yml                                   │
│              ├── traefik.staging.yml                                          │
│              └── project-backend/          ← app repo (services, gateways) │
│                     ├── Jenkinsfile                                           │
│                     ├── docker-compose.staging.yml                            │
│                     └── apps/                                                 │
│                            ├── services/&lt;name&gt;/                               │
│                            ├── gateways/&lt;name&gt;/                               │
│                            └── core/&lt;name&gt;/                                   │
│                                                                               │
│   ┌─────────────────────── Docker network: proxy ──────────────────────┐      │
│   │  traefik (80, 443)                                                 │      │
│   │     │                                                              │      │
│   │     ├──► jenkins  (projects-jenkins-staging)                     │      │
│   │     │      ↳ /projects  ← bind-mount of the host project tree     │      │
│   │     │      ↳ /var/run/docker.sock ← controls host Docker           │      │
│   │     │                                                              │      │
│   │     └──► your services &amp; gateways (built by the pipeline)          │      │
│   └────────────────────────────────────────────────────────────────────┘      │
│                                                                               │
└───────────────────────────────────────────────────────────────────────────────┘
            ▲
            │  webhook on push
            │
   GitHub: &lt;org&gt;/project-backend (branch: staging)
</code></pre>
<p>There are two key ideas here:</p>
<ol>
<li><p><strong>Jenkins runs in a container</strong>, but it controls the <strong>host's</strong> Docker by mounting <code>/var/run/docker.sock</code>. It also bind-mounts the project folder as <code>/projects/...</code>, so it can <code>cd</code> into the real code on the host and run <code>docker compose</code> there.</p>
</li>
<li><p>The <strong>Jenkinsfile lives inside the app repo</strong>, so the pipeline definition is versioned with the code. Jenkins simply points at it.</p>
</li>
</ol>
<h3 id="heading-3-server-prerequisites">3. Server Prerequisites</h3>
<p>Before we start configuring Jenkins or Traefik, we need to prepare the server properly.</p>
<p>In this step, we’ll:</p>
<ul>
<li><p>Create a dedicated Linux user for managing the project</p>
</li>
<li><p>Install Docker and Docker Compose</p>
</li>
<li><p>Set up the folder structure for our repositories</p>
</li>
</ul>
<p>This ensures our CI/CD pipeline runs in a clean and predictable environment.</p>
<pre><code class="language-bash"># Linux user that owns the project tree
sudo adduser developer

# Docker engine + Compose plugin
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker developer

# Sanity check Compose v2
docker compose version
# -&gt; Docker Compose version v2.x.y

# Find where the Compose plugin binary lives — write it down, you'll need it
ls /usr/libexec/docker/cli-plugins/docker-compose
# (some distros use /usr/lib/docker/cli-plugins/docker-compose)

# Project layout
sudo mkdir -p /home/developer/project
sudo chown -R developer:developer /home/developer/project

# Clone both repos in the right place
cd /home/developer/projects
git clone https://github.com/&lt;org&gt;/projects-prod-configs.git
cd projects-prod-configs
git clone -b staging https://github.com/&lt;org&gt;/projects-backend.git
</code></pre>
<p>You should now have:</p>
<pre><code class="language-plaintext">/home/developer/projects/projects-prod-configs/projects-backend
</code></pre>
<p>Memorize this path — your Jenkinsfile references it.</p>
<h3 id="heading-dns">DNS</h3>
<p>Point an A-record for your Jenkins subdomain to the server's public IP <strong>before</strong> the next steps so Let's Encrypt can validate via HTTP challenge:</p>
<pre><code class="language-plaintext">jenkins.example.com   A   &lt;server-public-ip&gt;
</code></pre>
<h2 id="heading-4-traefik-the-reverse-proxy">4. Traefik — the Reverse Proxy</h2>
<p>Traefik acts as the entry point to your entire system. Instead of exposing each service manually with ports, Traefik automatically:</p>
<ul>
<li><p>Routes traffic based on domain names</p>
</li>
<li><p>Generates and renews HTTPS certificates using Let’s Encrypt</p>
</li>
<li><p>Connects to Docker and detects services dynamically</p>
</li>
</ul>
<p>In simple terms, Traefik lets you access services like:</p>
<p><a href="https://jenkins.example.com">https://jenkins.example.com</a><br><a href="https://api.example.com">https://api.example.com</a></p>
<p>…without manually configuring NGINX or managing SSL certificates.</p>
<p>In this setup, Traefik watches Docker containers and routes traffic using labels we'll define later.</p>
<p>Traefik gives every container a real domain and a real cert with <strong>zero per-service config</strong> — you just add a few labels.</p>
<h3 id="heading-traefikstagingyml-static-config"><code>traefik.staging.yml</code> (static config)</h3>
<p>Put this at the root of your infra repo:</p>
<pre><code class="language-yaml">api:
  dashboard: true

entryPoints:
  web:
    address: ":80"
  websecure:
    address: ":443"

certificatesResolvers:
  letsencrypt:
    acme:
      httpChallenge:
        entryPoint: web
      email: admin@example.com           # ← change me
      storage: /etc/traefik/acme.json

providers:
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false              # only containers with traefik.enable=true
    network: proxy
  file:
    directory: /etc/traefik/dynamic
    watch: true

log:
  level: INFO

accessLog: {}
</code></pre>
<h3 id="heading-the-traefik-service-in-docker-composestagingyml">The Traefik service in <code>docker-compose.staging.yml</code></h3>
<pre><code class="language-yaml">networks:
  proxy:
    name: proxy
    driver: bridge
  internal:
    name: internal
    driver: bridge

volumes:
  acme-data:
  traefik-logs:
  jenkins-data:

services:
  traefik:
    image: traefik:v2.11
    container_name: projects-traefik-staging
    restart: unless-stopped
    ports:
      - "80:80"        # HTTP (auto-redirects to HTTPS)
      - "443:443"      # HTTPS
      - "8080:8080"    # Traefik dashboard (internal only — protect via firewall)
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik.staging.yml:/etc/traefik/traefik.yml:ro
      - ./dynamic:/etc/traefik/dynamic:ro
      - acme-data:/etc/traefik           # persists Let's Encrypt certs
      - traefik-logs:/var/log/traefik
    networks:
      - proxy
    command:
      - '--api.insecure=false'
      - '--api.dashboard=true'
      - '--providers.docker=true'
      - '--providers.docker.exposedbydefault=false'
      - '--providers.docker.network=proxy'
      - '--entrypoints.web.address=:80'
      - '--entrypoints.websecure.address=:443'
      - '--entrypoints.web.http.redirections.entryPoint.to=websecure'
      - '--entrypoints.web.http.redirections.entryPoint.scheme=https'
      - '--certificatesresolvers.letsencrypt.acme.httpchallenge=true'
      - '--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web'
      - '--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL:-admin@example.com}'
      - '--certificatesresolvers.letsencrypt.acme.storage=/etc/traefik/acme.json'
      - '--log.level=INFO'
      - '--accesslog=true'
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"
      # Traefik's own dashboard
      - "traefik.http.routers.traefik-dash.rule=Host(`traefik.example.com`)"
      - "traefik.http.routers.traefik-dash.entrypoints=websecure"
      - "traefik.http.routers.traefik-dash.tls.certresolver=letsencrypt"
      - "traefik.http.routers.traefik-dash.service=api@internal"
</code></pre>
<p>Bring it up:</p>
<pre><code class="language-bash">cd /home/developer/projects/projects-prod-configs
docker compose -f docker-compose.staging.yml up -d traefik
</code></pre>
<p>Watch the logs the first time — Traefik will request a cert for the dashboard host as soon as DNS resolves.</p>
<pre><code class="language-bash">docker logs -f projects-traefik-staging
</code></pre>
<p><strong>Tip.</strong> While testing, switch ACME to staging endpoint (<code>acme.caServer=https://acme-staging-v02.api.letsencrypt.org/directory</code>) so you don't burn through Let's Encrypt's rate limits if you misconfigure DNS. Remove that flag before going live.</p>
<h2 id="heading-5-run-jenkins-in-docker">5. Run Jenkins in Docker</h2>
<p>Add this Jenkins service to the same <code>docker-compose.staging.yml</code>. Every line matters (and the comments explain why).</p>
<pre><code class="language-yaml">  jenkins:
    image: jenkins/jenkins:lts
    container_name: projects-jenkins-staging
    restart: unless-stopped
    user: root                           # to use host docker.sock without UID juggling
    environment:
      - JAVA_OPTS=-Xmx1g -Xms512m -Duser.timezone=Asia/Dhaka
      - TZ=Asia/Dhaka                    # OS-level timezone inside container
      - JENKINS_OPTS=--prefix=/
    ports:
      - "3095:8080"                      # web UI (also reachable directly if needed)
      - "50000:50000"                    # inbound agent port
    volumes:
      - jenkins-data:/var/jenkins_home   # Jenkins config/jobs/secrets persistence
      - /var/run/docker.sock:/var/run/docker.sock                          # control host Docker
      - /usr/bin/docker:/usr/bin/docker                                     # docker CLI from host
      - /usr/libexec/docker/cli-plugins:/usr/libexec/docker/cli-plugins:ro  # docker compose plugin
      - /home/developer/projects:/projects                                # project tree
      - /etc/localtime:/etc/localtime:ro                                    # match host clock
      - /etc/timezone:/etc/timezone:ro
    networks:
      - proxy
      - internal
    healthcheck:
      test: ['CMD', 'curl', '-f', 'http://localhost:8080/login']
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 120s
    deploy:
      resources:
        limits:
          memory: 1024M
</code></pre>
<p><strong>Why</strong> <code>user: root</code><strong>?</strong> It's the simplest way to share <code>docker.sock</code> and the project bind-mount without UID/GID gymnastics. If you prefer an unprivileged user, you'll need to set <code>group: docker</code> and align UIDs/perms on host folders — possible but out of scope here.</p>
<h2 id="heading-6-expose-jenkins-on-a-domain-via-traefik">6. Expose Jenkins on a Domain via Traefik</h2>
<p>This is the section many guides skip. We'll add <strong>labels</strong> to the Jenkins service so Traefik picks it up automatically. No editing of Traefik config required.</p>
<pre><code class="language-yaml">  jenkins:
    # ... everything above ...
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"

      # 1) Router — match incoming Host
      - "traefik.http.routers.jenkins.rule=Host(`jenkins.example.com`)"
      - "traefik.http.routers.jenkins.entrypoints=websecure"
      - "traefik.http.routers.jenkins.tls.certresolver=letsencrypt"
      - "traefik.http.routers.jenkins.service=jenkins"

      # 2) Service — tell Traefik which container port is the app
      - "traefik.http.services.jenkins.loadbalancer.server.port=8080"

      # 3) Middleware — Jenkins needs X-Forwarded-Proto so it knows it's behind HTTPS
      - "traefik.http.middlewares.jenkins-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
      - "traefik.http.routers.jenkins.middlewares=jenkins-headers"
</code></pre>
<p>What each line does:</p>
<table>
<thead>
<tr>
<th>Label</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td><code>traefik.enable=true</code></td>
<td>Opts this container in (we set <code>exposedByDefault=false</code>).</td>
</tr>
<tr>
<td><code>traefik.docker.network=proxy</code></td>
<td>Tells Traefik which network to talk to Jenkins on (Jenkins is on both <code>proxy</code> and <code>internal</code>).</td>
</tr>
<tr>
<td><code>routers.jenkins.rule=Host(...)</code></td>
<td>Forwards only this hostname to Jenkins.</td>
</tr>
<tr>
<td><code>routers.jenkins.entrypoints=websecure</code></td>
<td>Listens only on 443. (HTTP redirect was set up in section 4.)</td>
</tr>
<tr>
<td><code>routers.jenkins.tls.certresolver=letsencrypt</code></td>
<td>Auto-issues + renews the cert.</td>
</tr>
<tr>
<td><code>services.jenkins.loadbalancer.server.port=8080</code></td>
<td>Jenkins listens on 8080 inside the container.</td>
</tr>
<tr>
<td><code>customrequestheaders.X-Forwarded-Proto=https</code></td>
<td>Without this, Jenkins generates <code>http://</code> URLs in webhooks/links and breaks.</td>
</tr>
</tbody></table>
<p>Bring Jenkins up:</p>
<pre><code class="language-bash">cd /home/developer/projects/projects-prod-configs
docker compose -f docker-compose.staging.yml up -d jenkins

# Watch Traefik issue the certificate
docker logs -f projects-traefik-staging | grep -i acme
</code></pre>
<p>After 10–60 seconds you should be able to open <code>https://jenkins.example.com</code> and see Jenkins's setup wizard with a valid lock icon.</p>
<p>Inside Jenkins (after first login):</p>
<p>Manage Jenkins → System → Jenkins URL → set this to: <a href="https://jenkins.example.com/">https://jenkins.example.com/</a></p>
<p>This is important because Jenkins uses this base URL to generate:</p>
<ul>
<li><p>Webhook endpoints (for GitHub triggers)</p>
</li>
<li><p>Links inside emails and build logs</p>
</li>
</ul>
<p>If this isn't set correctly, GitHub webhooks may fail, and any links Jenkins generates will point to the wrong address (often localhost or internal IPs).</p>
<h2 id="heading-7-first-time-jenkins-setup">7. First-Time Jenkins Setup</h2>
<p>If you're running Jenkins for the first time on this server, follow this section to complete the initial setup.</p>
<p>If you already have Jenkins configured, you can skip this section — but make sure the required plugins and settings match what we use later in this guide.</p>
<ol>
<li><p>Open <code>https://jenkins.example.com</code>. Get the initial admin password:</p>
<pre><code class="language-bash">docker exec projects-jenkins-staging cat /var/jenkins_home/secrets/initialAdminPassword
</code></pre>
</li>
<li><p>Paste it, choose Install suggested plugins.</p>
</li>
<li><p>Create your admin user.</p>
</li>
<li><p>Manage Jenkins → Plugins → Available and install:</p>
<ul>
<li><p>GitHub (and GitHub Branch Source)</p>
</li>
<li><p>Pipeline: GitHub</p>
</li>
<li><p>Credentials Binding (usually preinstalled)</p>
</li>
</ul>
</li>
</ol>
<p>That's all the plugins you need for the rest of this guide.</p>
<h2 id="heading-8-add-the-github-credential">8. Add the GitHub Credential</h2>
<p>Jenkins needs permission to access your GitHub repository.</p>
<p>This is done using a GitHub Personal Access Token (PAT), which acts like a password for secure API and Git operations.</p>
<p>We’ll store this token inside Jenkins as a credential so it can pull code during pipeline execution and authenticate securely without exposing secrets in code.</p>
<p>This single credential is used both for the SCM checkout and for the deploy-time <code>git pull</code>.</p>
<ol>
<li><p>Create a Personal Access Token (classic) on GitHub with <code>repo</code> scope.</p>
</li>
<li><p>In Jenkins: Manage Jenkins → Credentials → System → Global → Add Credentials.</p>
</li>
<li><p>Fill in:</p>
<ul>
<li><p>Kind: Username with password</p>
</li>
<li><p>Username: your GitHub username</p>
</li>
<li><p>Password: the token</p>
</li>
<li><p><strong>ID:</strong> <code>github_classic_token</code> <em>(the Jenkinsfile references this exact ID)</em></p>
</li>
</ul>
</li>
</ol>
<h2 id="heading-9-create-the-pipeline-job">9. Create the Pipeline Job</h2>
<p>Now that Jenkins has access to your repository, the next step is to define how deployments should run.</p>
<p>A pipeline job tells Jenkins:</p>
<ul>
<li><p>where your code lives,</p>
</li>
<li><p>which branch to monitor,</p>
</li>
<li><p>and how to execute your deployment process.</p>
</li>
</ul>
<p>In Jenkins, create a new Pipeline job and connect it to your GitHub repository. Once this is set up, Jenkins will automatically trigger deployments whenever you push to the <code>staging</code> branch.</p>
<p>Start by creating a new job:</p>
<p>New Item → Pipeline → name it <code>projects-staging</code> → OK</p>
<p>Then configure the job:</p>
<ul>
<li><p>Under <strong>Build Triggers</strong>, enable:<br><strong>GitHub hook trigger for GITScm polling</strong></p>
</li>
<li><p>Under <strong>Pipeline</strong>:</p>
<ul>
<li><p>Definition: Pipeline script from SCM</p>
</li>
<li><p>SCM: Git</p>
</li>
<li><p>Repository URL: <code>https://github.com/&lt;org&gt;/projects-backend.git</code></p>
</li>
<li><p>Credentials: <code>github_classic_token</code></p>
</li>
<li><p>Branch: <code>*/staging</code></p>
</li>
<li><p>Script Path: <code>Jenkinsfile</code></p>
</li>
</ul>
</li>
</ul>
<p>Save the configuration.</p>
<p>At this point, Jenkins is fully connected to your repository and ready to run your deployment pipeline automatically.</p>
<h2 id="heading-10-the-jenkinsfile-deploy-only-what-changed">10. The Jenkinsfile (Deploy Only What Changed)</h2>
<p>Place this at the root of the <strong>app</strong> repo (<code>projects-backend/Jenkinsfile</code>), branch <code>staging</code>.</p>
<pre><code class="language-groovy">pipeline {
  agent any

  environment {
    PROJECT_PATH = "/projects/projects-prod-configs/projects-backend"
    COMPOSE_FILE = "docker-compose.staging.yml"
  }

  stages {

    stage('Checkout') {
      steps {
        checkout scm
        echo "Checkout completed for branch: ${env.BRANCH_NAME ?: 'staging'}"
      }
    }

    stage('Detect Changes') {
      steps {
        script {
          def changedFiles = sh(
            script: "git diff --name-only HEAD~1 HEAD",
            returnStdout: true
          ).trim()

          echo "Changed files:\n${changedFiles}"

          def services = [] as Set
          changedFiles.split('\n').each { file -&gt;
            def svc  = file =~ /^apps\/services\/([a-z0-9-]+)\//
            def gw   = file =~ /^apps\/gateways\/([a-z0-9-]+)\//
            def core = file =~ /^apps\/core\/([a-z0-9-]+)\//
            if (svc)  { services &lt;&lt; svc[0][1]  }
            if (gw)   { services &lt;&lt; gw[0][1]   }
            if (core) { services &lt;&lt; core[0][1] }
          }
          services = services.findAll { !it.endsWith('-e2e') }
          env.CHANGED_SERVICES = services.join(' ')

          echo "Services to deploy: ${env.CHANGED_SERVICES ?: '(none)'}"
        }
      }
    }

    stage('Deploy') {
      when { expression { return env.CHANGED_SERVICES?.trim() } }
      steps {
        withCredentials([usernamePassword(
          credentialsId: 'github_classic_token',
          usernameVariable: 'GIT_USER',
          passwordVariable: 'GIT_TOKEN'
        )]) {
          sh '''
            set -eu
            git config --global --add safe.directory "${PROJECT_PATH}"
            cd "${PROJECT_PATH}"
            git remote set-url origin "https://github.com/&lt;org&gt;/projects-backend.git"
            git -c credential.helper= \
                -c "credential.helper=!f() { echo username=\({GIT_USER}; echo password=\){GIT_TOKEN}; }; f" \
                pull origin staging
            docker compose -f "\({COMPOSE_FILE}" up -d --build \){CHANGED_SERVICES}
          '''
        }
        echo "Deployed: ${env.CHANGED_SERVICES}"
      }
    }

    stage('Skip Deployment') {
      when { expression { return !env.CHANGED_SERVICES?.trim() } }
      steps { echo "No service changes detected — nothing to deploy." }
    }
  }
}
</code></pre>
<p>Why each tricky line is there:</p>
<ul>
<li><p><code>git config --global --add safe.directory ...</code> — git refuses to operate on a repo whose owner UID differs from the current user's. The repo on disk is owned by <code>developer</code>, but Git inside the container runs as <code>root</code>. This whitelists the path.</p>
</li>
<li><p><code>git remote set-url origin "https://..."</code> — flips the on-disk remote to HTTPS so the <strong>token can be used</strong>. (A PAT can't authenticate <code>git@github.com:</code> URLs — those use SSH.) Idempotent — safe to re-run.</p>
</li>
<li><p><code>git -c credential.helper="!f() { echo username=...; echo password=...; }; f"</code> — feeds the username/token to git for that one command without writing the token to disk and without exposing it on the process command line.</p>
</li>
<li><p><code>${CHANGED_SERVICES}</code> is unquoted on purpose so multiple service names expand as separate args.</p>
</li>
</ul>
<h2 id="heading-11-end-to-end-test">11. End-to-End Test</h2>
<p>Before considering the setup complete, we need to verify that the entire pipeline works as expected.</p>
<p>This end-to-end test ensures that:</p>
<ul>
<li><p>GitHub webhooks are triggering Jenkins correctly,</p>
</li>
<li><p>Jenkins can detect which services changed,</p>
</li>
<li><p>and only the affected services are rebuilt and deployed.</p>
</li>
</ul>
<p>In other words, this simulates a real production deployment.</p>
<p>Start by making a small change in your repository. For example, modify a file inside:</p>
<p>apps/gateways/student-apigw/</p>
<p>Then push the change to the <code>staging</code> branch.</p>
<p>Once pushed, Jenkins should automatically trigger via the webhook. If not, you can manually click <strong>Build Now</strong>.</p>
<p>Now open the build’s <strong>Console Output</strong> and verify the flow. You should see something like:</p>
<ul>
<li><p>Checkout completed for branch: staging</p>
</li>
<li><p>Services to deploy: student-apigw</p>
</li>
<li><p>git pull origin staging (successful)</p>
</li>
<li><p>docker compose ... up -d --build student-apigw</p>
</li>
<li><p>Deployed: student-apigw</p>
</li>
</ul>
<p>If you see this sequence, your pipeline is working correctly.</p>
<p>If anything fails, don’t worry — jump to Section 12 where every common issue and its fix is documented.</p>
<h2 id="heading-12-troubleshooting-every-error-we-hit">12. Troubleshooting — Every Error We Hit</h2>
<p>This section covers real issues we faced while setting up this pipeline — and more importantly, <em>why each fix works</em>. Understanding the “why” will help you debug similar problems in your own setup.</p>
<h3 id="heading-cd-cant-cd-to-projectsprojects-prod-configsprojects-backend">cd: can't cd to /projects/projects-prod-configs/projects-backend</h3>
<p><strong>Cause:</strong><br>The Jenkinsfile runs <code>cd $PROJECT_PATH</code>, but inside the container that path doesn’t exist. This usually happens when:</p>
<ul>
<li><p>the project wasn’t cloned on the host, or</p>
</li>
<li><p>the bind mount isn’t configured correctly.</p>
</li>
</ul>
<p><strong>Fix:</strong></p>
<pre><code class="language-bash">ls /home/developer/projects/projects-prod-configs/projects-backend
# If missing: git clone -b staging &lt;url&gt; there.
</code></pre>
<p>Confirm the bind mount:</p>
<pre><code class="language-plaintext">docker inspect projects-jenkins-staging --format '{{range .Mounts}}{{.Source}} -&gt; {{.Destination}}{{println}}{{end}}'
</code></pre>
<p>If missing, recreate the container:</p>
<pre><code class="language-plaintext">docker compose -f docker-compose.staging.yml up -d --force-recreate jenkins
</code></pre>
<p><strong>Why this works:</strong></p>
<p>Jenkins runs inside a container, but your code lives on the host. The bind mount connects them. Without it, Jenkins cannot access your project directory.</p>
<h3 id="heading-fatal-detected-dubious-ownership-in-repository">fatal: detected dubious ownership in repository</h3>
<p><strong>Cause:</strong><br>Git blocks access when the repository owner differs from the current user.</p>
<ul>
<li><p>Repo owner: <code>developer</code> (host)</p>
</li>
<li><p>Git runs as: <code>root</code> (inside container)</p>
</li>
</ul>
<p><strong>Fix:</strong></p>
<pre><code class="language-plaintext">git config --global --add safe.directory "${PROJECT_PATH}"
</code></pre>
<p><strong>Why this works:</strong></p>
<p>This explicitly tells Git that the directory is trusted, bypassing ownership mismatch security restrictions.</p>
<h3 id="heading-host-key-verification-failed-could-not-read-from-remote-repository"><code>Host key verification failed</code> / <code>Could not read from remote repository</code></h3>
<h4 id="heading-cause">Cause:</h4>
<p>The repository uses SSH (<code>git@github.com:...</code>), but:</p>
<ul>
<li><p>the container has no SSH keys</p>
</li>
<li><p>no known_hosts file exists</p>
</li>
</ul>
<p>Also, GitHub tokens cannot authenticate over SSH.</p>
<p><strong>Fix (recommended):</strong></p>
<pre><code class="language-plaintext">git remote set-url origin "https://github.com/&lt;org&gt;/projects-backend.git"
</code></pre>
<p><strong>Why this works:</strong></p>
<p>HTTPS uses token-based authentication (PAT), which works inside containers without SSH configuration.</p>
<h3 id="heading-unknown-shorthand-flag-f-in-f-docker-compose"><code>unknown shorthand flag: 'f' in -f</code> ( <code>docker compose</code>)</h3>
<p><strong>Cause:</strong><br>The Docker CLI exists, but the Docker Compose plugin is missing inside the container.</p>
<p><strong>Fix:</strong></p>
<pre><code class="language-plaintext">volumes:
  - /usr/libexec/docker/cli-plugins:/usr/libexec/docker/cli-plugins:ro
</code></pre>
<p>Find your path if needed:</p>
<pre><code class="language-plaintext">find /usr -name docker-compose -type f 2&gt;/dev/null
</code></pre>
<p>Verify:</p>
<pre><code class="language-plaintext">docker exec projects-jenkins-staging docker compose version
</code></pre>
<p><strong>Why this works:</strong></p>
<p>Docker Compose v2 is a CLI plugin. Mounting this directory makes the <code>docker compose</code> command available inside the container.</p>
<h3 id="heading-wrong-timezone-in-build-timestamps-and-jenkins-ui">Wrong timezone in build timestamps and Jenkins UI</h3>
<p><strong>Fix:</strong> Set both env var and JVM flag, and bind-mount the host's clock files:</p>
<pre><code class="language-yaml">environment:
  - TZ=Asia/Dhaka
  - JAVA_OPTS=... -Duser.timezone=Asia/Dhaka
volumes:
  - /etc/localtime:/etc/localtime:ro
  - /etc/timezone:/etc/timezone:ro
</code></pre>
<p>You <strong>must</strong> recreate the container for env-var changes to take effect:</p>
<pre><code class="language-bash">docker compose -f docker-compose.staging.yml up -d --force-recreate jenkins
</code></pre>
<p><strong>Why this works:</strong><br>Jenkins runs on Java, which uses its own timezone separate from the OS.<br>By aligning OS timezone, JVM timezone, and host clock, you ensure consistent timestamps everywhere.</p>
<h3 id="heading-errsockettimeout-pnpm-install-fails">ERR_SOCKET_TIMEOUT (pnpm install fails)</h3>
<h4 id="heading-cause">Cause:</h4>
<p>If you have multiple services building in parallel and each runs pnpm install with ~1500 packages, the network gets saturated and a timeout occurs.</p>
<h4 id="heading-fixes">Fixes:</h4>
<p>a) Increase timeout + control concurrency</p>
<pre><code class="language-xml">RUN pnpm install --frozen-lockfile --ignore-scripts 
--network-timeout 600000 
--network-concurrency 8
</code></pre>
<p>Why: Gives pnpm more time and reduces network overload.</p>
<p>b) Enable pnpm cache (BuildKit)</p>
<pre><code class="language-xml">RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store 
pnpm install --frozen-lockfile --ignore-scripts
</code></pre>
<p>Why: Dependencies are cached and reused instead of downloading every time.</p>
<p>c) Avoid unnecessary rebuilds</p>
<pre><code class="language-xml">docker compose -f \(COMPOSE_FILE build \)CHANGED_SERVICES docker compose -f \(COMPOSE_FILE up -d --no-build \)CHANGED_SERVICES
</code></pre>
<p>Why: Only changed services are rebuilt → less network load → fewer failures.</p>
<h3 id="heading-container-changes-dont-apply-after-editing-docker-composeyml">Container changes don’t apply after editing docker-compose.yml</h3>
<h4 id="heading-cause">Cause:</h4>
<p>Docker compose up -d does not update running containers.</p>
<h4 id="heading-fix">Fix:</h4>
<pre><code class="language-xml">docker compose -f docker-compose.staging.yml up -d --force-recreate jenkins
</code></pre>
<p><strong>Why this works:</strong></p>
<p>This forces Docker to recreate the container with updated configuration (env, volumes, labels).</p>
<h3 id="heading-traefik-shows-default-certificate-no-https">Traefik shows default certificate (no HTTPS)</h3>
<h4 id="heading-common-causes">Common causes:</h4>
<p>DNS not pointing to server Port 80 blocked Wrong Docker network</p>
<h4 id="heading-check">Check:</h4>
<pre><code class="language-xml">dig +short jenkins.example.com docker logs projects-traefik-staging 2&gt;&amp;1 | grep -i acme
</code></pre>
<p><strong>Why this works:</strong></p>
<p>Let’s Encrypt uses HTTP-01 challenge, so it must reach your server via port 80. If DNS or networking is wrong, certificate issuance fails.</p>
<h3 id="heading-jenkins-reverse-proxy-setup-is-broken">Jenkins: "Reverse proxy setup is broken"</h3>
<h4 id="heading-fix">Fix:</h4>
<p>Set the Jenkins URL to <a href="https://jenkins.example.com/">https://jenkins.example.com/</a><br>Ensure header:</p>
<pre><code class="language-xml">X-Forwarded-Proto: https
</code></pre>
<p><strong>Why this works:</strong></p>
<p>Jenkins needs to know it's behind HTTPS. Without this, it generates incorrect URLs (http instead of https), breaking redirects and webhooks.</p>
<h2 id="heading-13-mental-model-host-vs-container">13. Mental Model: Host vs. Container</h2>
<p>Many setup mistakes come from confusing the <strong>host</strong> filesystem with the <strong>container</strong> filesystem. This table makes it explicit:</p>
<table>
<thead>
<tr>
<th>Inside the Jenkins container</th>
<th>Comes from on the host</th>
</tr>
</thead>
<tbody><tr>
<td><code>/var/jenkins_home</code></td>
<td>docker volume <code>jenkins-data</code> (Jenkins config, jobs, secrets)</td>
</tr>
<tr>
<td><code>/projects/...</code></td>
<td><code>/home/developer/projects/...</code> (your project tree)</td>
</tr>
<tr>
<td><code>/usr/bin/docker</code></td>
<td>host's <code>/usr/bin/docker</code></td>
</tr>
<tr>
<td><code>/usr/libexec/docker/cli-plugins/docker-compose</code></td>
<td>host plugin (lets <code>docker compose</code> work)</td>
</tr>
<tr>
<td><code>/var/run/docker.sock</code></td>
<td>host Docker daemon (so builds happen on the host's engine)</td>
</tr>
<tr>
<td><code>/etc/localtime</code>, <code>/etc/timezone</code></td>
<td>host clock</td>
</tr>
<tr>
<td><code>~/.ssh</code></td>
<td><strong>nothing</strong> — that's why SSH-to-GitHub doesn't work without extra setup</td>
</tr>
</tbody></table>
<p>When debugging, always ask: <em>"Inside which filesystem is this command running, and does the file/folder it's looking for exist there?"</em></p>
<h2 id="heading-14-daily-operations-cheat-sheet">14. Daily Operations Cheat Sheet</h2>
<pre><code class="language-bash"># Recreate Jenkins after changing compose
cd /home/developer/Projects/projects-prod-configs
docker compose -f docker-compose.staging.yml up -d --force-recreate jenkins

# Tail Jenkins logs
docker logs -f projects-jenkins-staging

# Open a shell inside the Jenkins container
docker exec -it projects-jenkins-staging bash

# From inside the container — sanity checks
docker compose version
ls /projects/projects-prod-configs/projects-backend
git -C /projects/projects-prod-configs/projects-backend remote -v

# Manually trigger the same deploy the pipeline does
cd /projects/projects-configs/projects-backend
git pull origin staging
docker compose -f docker-compose.staging.yml up -d --build student-apigw

# Inspect Traefik routing decisions
docker logs projects-traefik-staging 2&gt;&amp;1 | grep -i jenkins

# Check renewed certs
docker exec projects-traefik-staging cat /etc/traefik/acme.json | head -50
</code></pre>
<h2 id="heading-15-what-id-do-differently-next-time">15. What I'd Do Differently Next Time</h2>
<ul>
<li><p><strong>Pre-build a base image</strong> with all node_modules baked in. With ~1500 packages × 15 services, every clean build re-downloads ~22k tarballs. A shared base cuts that 90%.</p>
</li>
<li><p><strong>Run a private npm proxy</strong> (Verdaccio / Nexus / GitHub Packages) on the same Docker network — eliminates flaky <code>npmjs.org</code> timeouts entirely.</p>
</li>
<li><p><strong>Per-service Jenkinsfile</strong> if your services drift apart in tooling. With one Jenkinsfile, every team contends for the same pipeline definition.</p>
</li>
<li><p><strong>Replace</strong> <code>git diff HEAD~1 HEAD</code> with <code>git diff $(git merge-base HEAD origin/staging~1) HEAD</code> so squash-merges and force-pushes don't accidentally skip services.</p>
</li>
<li><p><strong>Move secrets to a vault</strong> (HashiCorp Vault / AWS Secrets Manager / Doppler). PATs in Jenkins work, but rotation across many jobs is painful.</p>
</li>
<li><p><strong>Use Jenkins' Configuration-as-Code (JCasC)</strong> so the entire Jenkins setup (jobs, credentials definitions, plugins) is in git. Then a server rebuild is a one-command operation.</p>
</li>
</ul>
<h2 id="heading-closing-thoughts">Closing Thoughts</h2>
<p>The pipeline itself is just three stages: <strong>Checkout → Detect Changes → Deploy</strong> — but a real production setup is mostly about <strong>plumbing</strong>: reverse proxy, certificates, bind-mounts, credentials, timezones, build caches. None of these are exotic. Together they decide whether your Friday-afternoon deploy goes silently green or eats your weekend.</p>
<p>Follow sections 1–11 to get a working pipeline. Bookmark section 12 to keep it working.</p>
<p>Happy shipping.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build Microservices-Based REST APIs for Healthcare Portals ]]>
                </title>
                <description>
                    <![CDATA[ Microservices architecture enables healthcare portals to scale, secure sensitive data, and evolve rapidly. Using ASP.NET 10 and C#, you can build independent REST APIs for services like patients, appo ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-microservices-based-rest-apis-for-healthcare-portals/</link>
                <guid isPermaLink="false">69e2610cfd22b8ad6251e84b</guid>
                
                    <category>
                        <![CDATA[ REST APIs ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Microservices ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ASP.NET 10 ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Database per Service Pattern ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Service Communication ]]>
                    </category>
                
                    <category>
                        <![CDATA[ containerization ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Gopinath Karunanithi ]]>
                </dc:creator>
                <pubDate>Fri, 17 Apr 2026 16:30:00 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/d834b346-3fcf-442c-836c-94ed7ef8a17d.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Microservices architecture enables healthcare portals to scale, secure sensitive data, and evolve rapidly.</p>
<p>Using ASP.NET 10 and C#, you can build independent REST APIs for services like patients, appointments, and authentication, each with its own database and deployment lifecycle.</p>
<p>Combined with API gateways, JWT-based security, observability, and containerization, this approach ensures reliable, maintainable, and production-ready healthcare systems.</p>
<p>In this tutorial, you’ll learn how to design and build a microservices-based healthcare portal using ASP.NET 10 and C#. We’ll cover how to structure services, implement REST APIs, secure endpoints, enable service communication, and deploy using modern containerization practices.</p>
<p>By the end, you’ll have a clear understanding of how to create scalable, secure, and production-ready healthcare systems.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-overview">Overview</a></p>
</li>
<li><p><a href="#heading-why-use-microservices-for-healthcare-portals">Why Use Microservices for Healthcare Portals?</a></p>
</li>
<li><p><a href="#heading-high-level-architecture">High-Level Architecture</a></p>
</li>
<li><p><a href="#heading-designing-rest-apis-for-healthcare-services">Designing REST APIs for Healthcare Services</a></p>
</li>
<li><p><a href="#heading-how-to-build-a-microservice-with-aspnet-10">How to Build a Microservice with ASP.NET 10</a></p>
</li>
<li><p><a href="#heading-database-per-service-pattern">Database per Service Pattern</a></p>
</li>
<li><p><a href="#heading-service-communication">Service Communication</a></p>
</li>
<li><p><a href="#heading-api-gateway-implementation">API Gateway Implementation</a></p>
</li>
<li><p><a href="#heading-implementing-security-in-healthcare-apis">Implementing Security in Healthcare APIs</a></p>
</li>
<li><p><a href="#heading-observability-and-logging">Observability and Logging</a></p>
</li>
<li><p><a href="#heading-containerization-with-docker">Containerization with Docker</a></p>
</li>
<li><p><a href="#heading-deployment-strategies">Deployment Strategies</a></p>
</li>
<li><p><a href="#heading-best-practices-with-examples">Best Practices (With Examples)</a></p>
</li>
<li><p><a href="#heading-when-not-to-use-microservices">When NOT to Use Microservices</a></p>
</li>
<li><p><a href="#heading-future-enhancements">Future Enhancements</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before getting started, you should be familiar with:</p>
<ul>
<li><p>C# and ASP.NET Core fundamentals</p>
</li>
<li><p>REST API concepts (HTTP methods, routing, status codes)</p>
</li>
<li><p>Basic understanding of microservices architecture</p>
</li>
</ul>
<p>Tools required:</p>
<ul>
<li><p>.NET 10 SDK</p>
</li>
<li><p>Visual Studio or VS Code</p>
</li>
<li><p>Postman or Swagger</p>
</li>
<li><p>Docker (optional but recommended)</p>
</li>
</ul>
<h2 id="heading-overview">Overview</h2>
<p>Healthcare portals power critical workflows such as patient registration, appointment scheduling, electronic health records (EHR), billing, and telemedicine. These systems must handle sensitive data, high availability requirements, and frequent updates.</p>
<p>Traditionally, many healthcare applications were built as monolithic systems. While simple to start with, monoliths quickly become difficult to scale, maintain, and secure. A single failure can impact the entire system, and even small changes require redeploying the entire application.</p>
<p>Microservices architecture addresses these challenges by breaking the application into smaller, independent services. Each service is responsible for a specific domain, such as patient management or appointment scheduling, and can be developed, deployed, and scaled independently.</p>
<p>In this article, you'll learn how to design and implement a microservices-based healthcare REST API using ASP.NET 10 and C#. We'll walk through architecture design, service implementation, communication patterns, security, observability, and deployment strategies.</p>
<h2 id="heading-why-use-microservices-for-healthcare-portals">Why Use Microservices for Healthcare Portals?</h2>
<p>Healthcare systems are inherently complex. They involve multiple domains such as patient records, appointments, billing, authentication and authorization. A microservices approach allows each of these domains to be handled independently. There are many benefits to this approach such as:</p>
<ul>
<li><p><strong>Scalability</strong>: Scale only the services under heavy load (for example, appointments during peak hours)</p>
</li>
<li><p><strong>Fault isolation</strong>: Failure in one service does not crash the entire system</p>
</li>
<li><p><strong>Faster deployment</strong>: Teams can deploy updates independently</p>
</li>
<li><p><strong>Improved security</strong>: Sensitive services can have stricter access controls</p>
</li>
</ul>
<p>For example, a patient service can handle personal data, while a billing service manages transactions, each with different security policies.</p>
<h2 id="heading-high-level-architecture"><strong>High-Level Architecture</strong></h2>
<p>A typical healthcare microservices architecture includes API Gateway (central entry point), microservices (Patient, Appointment, Auth), database per Service and service Communication Layer.</p>
<p>The request flow starts with the client sending a request. Then the API Gateway routes the request and the target microservice processes it. Then a response is returned. This separation ensures modularity and maintainability.</p>
<h2 id="heading-designing-rest-apis-for-healthcare-services">Designing REST APIs for Healthcare Services</h2>
<p>Designing REST APIs in a microservices architecture requires clear, consistent naming conventions so that endpoints are intuitive, predictable, and easy to consume by clients and other services.</p>
<h3 id="heading-naming-conventions">Naming Conventions</h3>
<p>REST APIs are resource-oriented, meaning URLs should represent entities (nouns), not actions (verbs). Each resource corresponds to a domain object in your system, such as patients, appointments, or billing records.</p>
<p><strong>Key principles:</strong></p>
<ul>
<li><p>Use plural nouns for resources (for example, <code>/patients</code>, <code>/appointments</code>)</p>
</li>
<li><p>Avoid verbs in URLs (don't use <code>/getPatients</code>)</p>
</li>
<li><p>Use hierarchical structure for relationships (for example, <code>/patients/{id}/appointments</code>)</p>
</li>
<li><p>Keep naming consistent across all services</p>
</li>
</ul>
<p>These conventions improve API readability, developer experience, and maintainability across teams</p>
<h4 id="heading-example-patient-api-endpoints">Example: Patient API Endpoints</h4>
<p>The following endpoints represent standard CRUD (Create, Read, Update, Delete) operations for managing patients:</p>
<pre><code class="language-plaintext">GET    /api/patients        // Retrieve all patients
GET    /api/patients/{id}   // Retrieve a specific patient
POST   /api/patients        // Create a new patient
PUT    /api/patients/{id}   // Update an existing patient
DELETE /api/patients/{id}   // Delete a patient
</code></pre>
<p>Each HTTP method defines the type of operation being performed:</p>
<ul>
<li><p>GET: Fetch data (read-only)</p>
</li>
<li><p>POST: Create new resources</p>
</li>
<li><p>PUT: Update existing resources</p>
</li>
<li><p>DELETE: Remove resources</p>
</li>
</ul>
<p>These operations follow REST standards, ensuring consistency across services and making APIs easier to integrate with frontend apps, mobile clients, or third-party healthcare systems</p>
<h3 id="heading-best-practices-for-designing-healthcare-rest-apis">Best Practices for Designing Healthcare REST APIs</h3>
<p>Designing REST APIs for healthcare systems requires more than standard conventions. It demands careful consideration of performance, data sensitivity, and interoperability.</p>
<h4 id="heading-1-use-proper-http-methods">1. Use proper HTTP methods</h4>
<p>Ensure each endpoint uses the correct HTTP verb (GET, POST, PUT, DELETE) to clearly communicate its purpose. This improves API predictability and aligns with REST standards used across healthcare platforms.</p>
<h4 id="heading-2-return-meaningful-status-codes">2. Return meaningful status codes</h4>
<p>Use appropriate HTTP status codes to indicate the result of a request. For example:</p>
<ul>
<li><p>200 OK for successful retrieval</p>
</li>
<li><p>201 Created for successful resource creation</p>
</li>
<li><p>400 Bad Request for validation errors</p>
</li>
<li><p>404 Not Found when a resource doesn’t exist<br>Clear status codes help clients handle responses correctly.</p>
</li>
</ul>
<h4 id="heading-3-implement-pagination-for-large-datasets">3. Implement pagination for large datasets</h4>
<p>Healthcare systems often deal with large volumes of data (for example, patient records, appointment logs). Use pagination to limit response size:</p>
<p><code>GET /api/patients?page=1&amp;pageSize=20</code></p>
<p>This improves performance and reduces server load.</p>
<h4 id="heading-4-use-api-versioning">4. Use API versioning</h4>
<p>Version your APIs to avoid breaking existing clients when making changes:</p>
<p><code>/api/v1/patients</code></p>
<p>This is especially important in healthcare, where integrations with external systems must remain stable over time.</p>
<h4 id="heading-5-validate-and-sanitize-input-data">5. Validate and sanitize input data</h4>
<p>Always validate incoming data to prevent errors and ensure data integrity. For example, enforce required fields like patient name, date of birth, and contact details.</p>
<h4 id="heading-6-protect-sensitive-data">6. Protect sensitive data</h4>
<p>Avoid exposing sensitive patient information unnecessarily. Use filtering, masking, or field-level access control where needed to comply with healthcare data regulations.</p>
<h4 id="heading-7-ensure-consistent-response-structure">7. Ensure consistent response structure</h4>
<p>Return responses in a standard format (for example, including data, status, and message fields). This makes APIs easier to consume and debug across multiple services.</p>
<h2 id="heading-how-to-build-a-microservice-with-aspnet-10">How to Build a Microservice with ASP.NET 10</h2>
<p>Let’s implement a simple Patient Service.</p>
<h3 id="heading-step-1-create-project">Step 1: Create Project</h3>
<p>In this step, we'll create a new <a href="http://ASP.NET">ASP.NET</a> Web API project that will serve as our Patient microservice. This project provides the foundation for defining endpoints, handling HTTP requests, and structuring our service independently from other parts of the system.</p>
<pre><code class="language-shell">dotnet new webapi -n PatientService
cd PatientService
</code></pre>
<h3 id="heading-step-2-define-model">Step 2: Define Model</h3>
<p>Next, we'll define a simple data model representing a patient. Models define the structure of the data your API will send and receive, and they typically map to database entities in real-world applications.</p>
<pre><code class="language-csharp">public class Patient
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Email { get; set; }
}
</code></pre>
<h3 id="heading-step-3-create-controller">Step 3: Create Controller</h3>
<p>Here, we're creating a controller to handle incoming HTTP requests. Controllers define API endpoints and contain the logic for processing requests, interacting with data, and returning responses to clients.</p>
<pre><code class="language-csharp">[ApiController]
[Route("api/patients")]
public class PatientController : ControllerBase
{
    private static List&lt;Patient&gt; patients = new();

    [HttpGet]
    public IActionResult GetPatients()
    {
        return Ok(patients);
    }

    [HttpPost]
    public IActionResult AddPatient(Patient patient)
    {
        patients.Add(patient);
        return CreatedAtAction(nameof(GetPatients), patient);
    }
}
</code></pre>
<h2 id="heading-database-per-service-pattern">Database per Service Pattern</h2>
<p>Each microservice should manage its own database to ensure loose coupling and independent operation. This allows services to evolve, scale, and be deployed without affecting others. It also improves data isolation and aligns with the core principles of microservices architecture.</p>
<p>Here's an example with Entity Framework Core:</p>
<pre><code class="language-csharp">public class PatientDbContext : DbContext
{
    public PatientDbContext(DbContextOptions&lt;PatientDbContext&gt; options)
        : base(options) { }

    public DbSet&lt;Patient&gt; Patients { get; set; }
}
</code></pre>
<p>This matters because it avoids cross-service dependencies, enables independent scaling, and improves data security, making microservices more efficient and secure.</p>
<h2 id="heading-service-communication">Service Communication</h2>
<p>Microservices communicate with each other to share data and coordinate workflows across the system. This communication can be handled through synchronous requests or asynchronous messaging, depending on the use case.</p>
<p>Choosing the right approach helps ensure scalability, reliability, and responsiveness in distributed systems</p>
<h3 id="heading-1-synchronous-communication-http">1. Synchronous Communication (HTTP)</h3>
<pre><code class="language-csharp">var response = await httpClient.GetAsync("http://appointment-service/api/appointments");
</code></pre>
<h3 id="heading-2-asynchronous-communication-messaging">2. Asynchronous Communication (Messaging)</h3>
<p>Using message brokers like RabbitMQ:</p>
<ul>
<li><p>Services publish events</p>
</li>
<li><p>Other services consume them</p>
</li>
</ul>
<p><strong>Example:</strong></p>
<p>When a patient registers, an event triggers an appointment service.</p>
<h2 id="heading-api-gateway-implementation"><strong>API Gateway Implementation</strong></h2>
<p>An API Gateway acts as the central entry point for all client requests in a microservices architecture. It handles routing, authentication, and request aggregation, simplifying how clients interact with multiple services. This layer helps improve security, scalability, and overall system management.</p>
<p>Here's an example (Ocelot configuration):</p>
<pre><code class="language-json">{
  "Routes": [
    {
      "DownstreamPathTemplate": "/api/patients",
      "UpstreamPathTemplate": "/patients",
      "DownstreamHostAndPorts": [
        { "Host": "localhost", "Port": 5001 }
      ]
    }
  ]
}
</code></pre>
<p>Benefits include centralized routing, authentication handling, and rate limiting</p>
<h2 id="heading-implementing-security-in-healthcare-apis">Implementing Security in Healthcare APIs</h2>
<p>Security is critical in healthcare systems due to the sensitive nature of patient data. APIs must enforce strong authentication, authorization, and data protection mechanisms. Proper security ensures compliance, prevents unauthorized access, and safeguards user trust.</p>
<h3 id="heading-1-jwt-authentication">1. JWT Authentication</h3>
<pre><code class="language-csharp">builder.Services.AddAuthentication("Bearer")
    .AddJwtBearer(options =&gt;
    {
        options.Authority = "https://auth-server";
        options.Audience = "healthcare-api";
    });
</code></pre>
<p>JWT (JSON Web Token) authentication is used to verify the identity of users accessing the API.</p>
<p>The authentication scheme ("Bearer") tells the API to expect a token in the Authorization header: <code>Authorization: Bearer &lt;token&gt;</code></p>
<p>Authority represents the trusted authentication server (identity provider) that issues tokens.</p>
<p>And audience ensures that the token is intended specifically for this API.</p>
<p>When a request is made, the API:</p>
<ol>
<li><p>Extracts the JWT from the request header</p>
</li>
<li><p>Validates its signature using the authority</p>
</li>
<li><p>Checks claims like expiration and audience</p>
</li>
<li><p>Grants access only if the token is valid</p>
</li>
</ol>
<p>This ensures that only authenticated users can access healthcare services.</p>
<h3 id="heading-2-role-based-authorization">2. Role-Based Authorization</h3>
<pre><code class="language-csharp">[Authorize(Roles = "Doctor")]
public IActionResult GetSensitiveData()
{
    return Ok();
}
</code></pre>
<p>Role-based authorization restricts access based on user roles.</p>
<ul>
<li><p>The <code>[Authorize]</code> attribute enforces that only authenticated users can access the endpoint.</p>
</li>
<li><p>The <code>Roles = "Doctor"</code> condition ensures that only users with the Doctor role can access this resource.</p>
</li>
</ul>
<p>When a user sends a request:</p>
<ol>
<li><p>Their JWT token is validated</p>
</li>
<li><p>The system checks the role claim inside the token</p>
</li>
<li><p>Access is granted only if the required role matches</p>
</li>
</ol>
<p>This is critical in healthcare systems where doctors access medical records, admins manage system data, and patients access only their own information.</p>
<h3 id="heading-3-secure-secrets-management">3. Secure Secrets Management</h3>
<pre><code class="language-csharp">var connectionString = Environment.GetEnvironmentVariable("DB_CONNECTION");
</code></pre>
<p>Sensitive configuration data such as database connection strings should never be hardcoded in the application.</p>
<p><code>Environment.GetEnvironmentVariable()</code> retrieves secrets securely from the environment. These values are typically stored in:</p>
<ul>
<li><p>Environment variables</p>
</li>
<li><p>Secret managers (Azure Key Vault, AWS Secrets Manager)</p>
</li>
<li><p>Container orchestration platforms</p>
</li>
</ul>
<p>Benefits:</p>
<ul>
<li><p>Prevents exposure of credentials in source code</p>
</li>
<li><p>Supports secure deployments across environments</p>
</li>
<li><p>Simplifies secret rotation without code changes</p>
</li>
</ul>
<h3 id="heading-4-enforce-https">4. Enforce HTTPS</h3>
<pre><code class="language-csharp">app.UseHttpsRedirection();
</code></pre>
<p>HTTPS ensures that all communication between the client and server is encrypted.</p>
<p><code>UseHttpsRedirection()</code> automatically redirects HTTP requests to HTTPS. This protects sensitive healthcare data (such as patient records and credentials) from Man-in-the-Middle attacks, data interception, and unauthorized access.</p>
<p>In healthcare systems, encryption is essential for compliance with data protection standards and regulations.</p>
<p>Together, these security mechanisms provide multiple layers of protection:</p>
<ul>
<li><p>Authentication verifies identity</p>
</li>
<li><p>Authorization controls access</p>
</li>
<li><p>Secrets management protects credentials</p>
</li>
<li><p>HTTPS secures data in transit</p>
</li>
</ul>
<p>This layered approach is essential for safeguarding sensitive healthcare data and ensuring compliance with industry standards.</p>
<h2 id="heading-observability-and-logging"><strong>Observability and Logging</strong></h2>
<p>Observability enables you to monitor system health, diagnose issues, and understand how services interact in real time. By implementing logging, metrics, and tracing, teams can quickly identify failures and performance bottlenecks. This is essential for maintaining reliability in distributed systems.</p>
<p>Here's a basic logging example:</p>
<pre><code class="language-csharp">_logger.LogInformation("Fetching patients");
</code></pre>
<p>This line writes an informational log entry whenever the patient data is being retrieved. The _logger instance is part of ASP.NET’s built-in logging framework and is typically injected into the class through dependency injection.</p>
<p>Logging at this level helps developers trace normal application behavior and understand when specific operations occur, which is especially useful during debugging and monitoring in production environments.</p>
<h3 id="heading-application-insights-integration">Application Insights Integration</h3>
<pre><code class="language-csharp">builder.Services.AddApplicationInsightsTelemetry();
</code></pre>
<p>This configuration enables integration with Application Insights, a cloud-based monitoring service. By adding this line, the application automatically collects telemetry data such as request rates, response times, failure rates, and dependency calls. This allows teams to monitor the health of the application in real time and quickly identify performance bottlenecks or failures across distributed microservices.</p>
<h3 id="heading-custom-metrics">Custom Metrics</h3>
<pre><code class="language-csharp">var telemetryClient = new TelemetryClient();
telemetryClient.TrackMetric("PatientsFetched", 1);
</code></pre>
<p>Here, a TelemetryClient instance is used to send custom metrics to the monitoring system. The TrackMetric method records a numerical value –&nbsp;in this case, tracking how many times patients are fetched.</p>
<p>Custom metrics like this help measure business-specific operations and provide deeper insight into how the system is being used beyond standard performance metrics.</p>
<h3 id="heading-health-checks">Health Checks</h3>
<pre><code class="language-csharp">app.MapHealthChecks("/health");
</code></pre>
<p>This line exposes a health check endpoint at /health that external systems can use to verify whether the service is running correctly. When this endpoint is called, it returns the status of the application and any configured dependencies, such as databases or external services.</p>
<p>Health checks are commonly used by load balancers, container orchestrators, and monitoring tools to automatically detect failures and restart or reroute traffic if needed.</p>
<p>Together, logging, telemetry, custom metrics, and health checks provide a complete observability strategy. They allow teams to understand system behavior, detect issues early, and maintain reliability across distributed healthcare services where uptime and performance are critical.</p>
<h2 id="heading-containerization-with-docker">Containerization with Docker</h2>
<p>Containerization allows microservices to run in isolated and consistent environments across development and production. Using Docker, you can package applications with all dependencies, ensuring portability and easier deployment. This approach simplifies scaling and infrastructure management.</p>
<p>The following Dockerfile shows a minimal setup for packaging the Patient Service into a container image:</p>
<pre><code class="language-dockerfile">FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY . .
ENTRYPOINT ["dotnet", "PatientService.dll"]
</code></pre>
<p>This Dockerfile defines how the Patient Service is packaged into a container image so it can run consistently across different environments.</p>
<p>The <strong>FROM</strong> instruction specifies the base image, which in this case is the official ASP.NET runtime image for .NET 10. This image includes all the necessary runtime components required to execute the application, so you don’t need to install .NET separately inside the container.</p>
<p>The <strong>WORKDIR /app</strong> line sets the working directory inside the container. All subsequent commands will run relative to this directory, helping organize application files in a predictable structure.</p>
<p>The <strong>COPY . .</strong> instruction copies all files from the current project directory on your machine into the container’s working directory. This includes the compiled application binaries and any required resources.</p>
<p>Finally, the <strong>ENTRYPOINT</strong> defines the command that runs when the container starts. In this case, it launches the PatientService application using the .NET runtime.</p>
<p>Together, these steps package the microservice into a portable unit that can be deployed consistently across development, staging, and production environments. This ensures that the application behaves the same regardless of where it is deployed, which is a key advantage of containerization in microservices architectures.</p>
<h2 id="heading-deployment-strategies"><strong>Deployment Strategies</strong></h2>
<p>Deploying microservices requires strategies that minimize downtime and reduce risk during updates.</p>
<p>Techniques like rolling updates, canary releases, and blue-green deployments help ensure smooth transitions. These approaches improve system stability and user experience during releases.</p>
<h3 id="heading-key-strategies">Key Strategies</h3>
<p>Deploying microservices requires strategies that minimize downtime, reduce risk, and ensure system stability –&nbsp;especially in healthcare systems where availability and data integrity are critical.</p>
<h4 id="heading-1-rolling-updates">1. Rolling Updates</h4>
<p>Rolling updates deploy changes gradually by updating instances of a service one at a time instead of all at once. As new versions are deployed, old instances are terminated in phases, ensuring that the system remains available throughout the process.</p>
<p>This approach works well for stateless services and is commonly used in container orchestration platforms. It allows continuous availability while still enabling safe deployment of new features.</p>
<p>Rolling updates are best used when:</p>
<ul>
<li><p>You want zero downtime deployments</p>
</li>
<li><p>Backward compatibility between versions is maintained</p>
</li>
<li><p>Changes are relatively low risk</p>
</li>
</ul>
<h4 id="heading-2-canary-deployments">2. Canary Deployments</h4>
<p>Canary deployments release a new version of a service to a small subset of users before rolling it out to everyone. This allows teams to monitor the behavior of the new version in a real-world environment with limited exposure.</p>
<p>If issues are detected, the deployment can be rolled back quickly without affecting the majority of users.</p>
<p>Canary deployments are ideal when:</p>
<ul>
<li><p>Releasing high-risk or complex features</p>
</li>
<li><p>Testing performance under real traffic</p>
</li>
<li><p>Gradually validating new functionality</p>
</li>
</ul>
<h4 id="heading-3-blue-green-deployments">3. Blue-Green Deployments</h4>
<p>Blue-green deployment involves maintaining two identical environments: one running the current version (blue) and one running the new version (green). Traffic is switched from blue to green once the new version is fully tested and ready.</p>
<p>If something goes wrong, traffic can be immediately switched back to the previous version.</p>
<p>This strategy is particularly useful when:</p>
<ul>
<li><p>You need instant rollback capability</p>
</li>
<li><p>System stability is critical</p>
</li>
<li><p>Downtime must be completely avoided</p>
</li>
</ul>
<h3 id="heading-choosing-the-right-strategy-for-healthcare-microservices">Choosing the Right Strategy for Healthcare Microservices</h3>
<p>In a healthcare portal, where reliability and patient data integrity are essential, blue-green deployments are often the safest choice. They allow full validation of the new version before exposing it to users and provide immediate rollback in case of failure.</p>
<p>But rolling updates are also commonly used for routine updates where backward compatibility is ensured, while canary deployments are useful when introducing new features like AI diagnostics or analytics modules.</p>
<h4 id="heading-example-blue-green-deployment-with-containers">Example: Blue-Green Deployment with Containers</h4>
<p>Let’s walk through a simple conceptual example using containers.</p>
<p>Assume you have two environments:</p>
<ul>
<li><p>Blue (current version) running PatientService v1</p>
</li>
<li><p>Green (new version) running PatientService v2</p>
</li>
</ul>
<p>First, you deploy the new version (v2) alongside the existing one without affecting users.</p>
<p>Then you run tests and verify that the new version behaves correctly.</p>
<p>After that, you update the load balancer or API gateway to route traffic from blue to green. Then you monitor the system for errors or performance issues.</p>
<p>If everything is stable, you keep green as the active environment. If not, switch traffic back to blue instantly.</p>
<p>In a real-world setup, this traffic switching is typically handled by:</p>
<ul>
<li><p>API Gateways</p>
</li>
<li><p>Load balancers</p>
</li>
<li><p>Kubernetes services</p>
</li>
</ul>
<p>This approach ensures that users experience no downtime while giving teams full control over deployment risk.</p>
<p>In practice, many production systems combine these strategies –&nbsp;for example, starting with a canary release and then completing deployment with a rolling update – to balance risk and efficiency.</p>
<h2 id="heading-best-practices-with-examples">Best Practices (With Examples)</h2>
<p>Designing reliable microservices for healthcare systems requires applying proven patterns that improve stability, maintainability, and resilience. Below are some key best practices with practical examples.</p>
<h3 id="heading-1-use-api-versioning">1. Use API Versioning</h3>
<p>API versioning ensures backward compatibility when your service evolves. In healthcare systems, where integrations with external systems (labs, insurance, EHR) are common, breaking changes can cause serious issues.</p>
<p>Here's an example:</p>
<pre><code class="language-csharp">[Route("api/v1/patients")]
</code></pre>
<p>This route attribute defines the base URL for the API and explicitly includes a version identifier (v1). By embedding the version in the route, the service can support multiple versions of the same API simultaneously. This allows existing clients to continue using older versions while newer versions are introduced without breaking compatibility.</p>
<p>You can later introduce a new version:</p>
<pre><code class="language-csharp">[Route("api/v2/patients")]
</code></pre>
<p>This represents a newer version of the same API with potentially updated functionality or structure. By separating versions at the routing level, developers can evolve the API safely while giving clients time to migrate.</p>
<p>This approach is especially important in healthcare systems where external integrations must remain stable over long periods.</p>
<p>This allows safe rollout of new features, support for legacy clients and gradual migration between versions.</p>
<h3 id="heading-2-implement-retry-policies">2. Implement Retry Policies</h3>
<p>Network calls between microservices can fail due to transient issues such as timeouts or temporary service unavailability. Retry policies help automatically recover from such failures.</p>
<p>Here's an example (using Polly):</p>
<pre><code class="language-csharp">services.AddHttpClient("api")
    .AddTransientHttpErrorPolicy(p =&gt; p.RetryAsync(3));
</code></pre>
<p>This code configures an HTTP client with a retry policy using <a href="https://www.pollydocs.org/">Polly</a>, a .NET resilience and transient-fault-handling library. Polly allows developers to define policies such as retries, circuit breakers, and timeouts for handling unreliable network calls.</p>
<p>The <code>AddTransientHttpErrorPolicy</code> method applies a retry strategy for temporary failures such as network timeouts or server errors. The <code>RetryAsync(3)</code> configuration means that if a request fails due to a transient issue, it will automatically be retried up to three times before returning an error.</p>
<p>This improves system reliability by handling temporary issues without requiring manual intervention.</p>
<p>This configuration retries failed requests up to three times before failing.</p>
<p>You can also add exponential backoff:</p>
<pre><code class="language-csharp">.AddTransientHttpErrorPolicy(p =&gt;
    p.WaitAndRetryAsync(3, retryAttempt =&gt;
        TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))));
</code></pre>
<p>This configuration enhances the retry mechanism by introducing exponential backoff. Instead of retrying immediately, the system waits progressively longer between each retry attempt.</p>
<p>Exponential backoff means:</p>
<ul>
<li><p>The first retry waits for 2¹ seconds</p>
</li>
<li><p>The second retry waits for 2² seconds</p>
</li>
<li><p>The third retry waits for 2³ seconds</p>
</li>
</ul>
<p>This approach reduces pressure on failing services and avoids overwhelming them with repeated requests. It's particularly useful in distributed systems where temporary failures are common and services need time to recover.</p>
<p>This helps in improving reliability, reducing temporary failures and avoiding manual retries.</p>
<h3 id="heading-3-enforce-input-validation">3. Enforce Input Validation</h3>
<p>Validating incoming data is critical, especially in healthcare systems where incorrect data can lead to serious consequences.</p>
<p>Here's an example:</p>
<pre><code class="language-csharp">if (string.IsNullOrEmpty(patient.Name))
    return BadRequest("Name is required");
</code></pre>
<p>This is a simple manual validation check that ensures the Name field is provided before processing the request. If the value is missing or empty, the API immediately returns a <code>BadRequest</code> response, preventing invalid data from entering the system.</p>
<p>A better approach is using data annotations:</p>
<pre><code class="language-csharp">public class Patient
{
    public int Id { get; set; }

    [Required]
    public string Name { get; set; }
}
</code></pre>
<p>This example uses data annotations to enforce validation rules at the model level. The [Required] attribute ensures that the Name property must be provided when a request is made. ASP.NET automatically validates the model during request processing and returns an error response if validation fails.</p>
<p>This approach is more scalable and maintainable than manual checks, especially in larger applications.</p>
<p>This ensures clean and valid data, reduced runtime errors, and better API usability.</p>
<h3 id="heading-4-use-circuit-breaker-pattern">4. Use Circuit Breaker Pattern</h3>
<p>The circuit breaker pattern prevents cascading failures when a dependent service is down or slow.</p>
<p>For example, if the Appointment Service is unavailable, repeated calls from the Patient Service can overload the system. A circuit breaker stops these calls temporarily.</p>
<p>Here's an example (again using Polly):</p>
<pre><code class="language-csharp">services.AddHttpClient("api")
    .AddTransientHttpErrorPolicy(p =&gt;
        p.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30)));
</code></pre>
<p>This means:</p>
<ul>
<li><p>After 5 consecutive failures, the circuit opens</p>
</li>
<li><p>No further requests are sent for 30 seconds</p>
</li>
<li><p>System gets time to recover</p>
</li>
</ul>
<p>This helps in protecting system stability, preventing resource exhaustion, and improving overall resilience.</p>
<p>These practices ensure your microservices are backward-compatible (versioning), resilient (retry + circuit breaker), and reliable (validation).</p>
<p>In healthcare systems, where uptime and data integrity are critical, applying these patterns is essential.</p>
<p>This code configures a circuit breaker policy using Polly to protect the system from repeated failures when calling external services.</p>
<p>The <code>CircuitBreakerAsync(5, TimeSpan.FromSeconds(30))</code> configuration means that if five consecutive requests fail, the circuit will open and block further requests for 30 seconds. During this time, the system will not attempt to call the failing service, allowing it time to recover.</p>
<p>After the break period, the circuit enters a half-open state where a limited number of requests are allowed to test if the service has recovered. If successful, normal operation resumes. Otherwise, the circuit opens again.</p>
<p>This pattern prevents cascading failures, reduces unnecessary load on failing services, and improves overall system resilience.</p>
<p>These examples demonstrate how small design decisions (like versioning, retries, validation, and fault handling) can significantly improve the reliability and maintainability of microservices, especially in healthcare systems where failures can have serious consequences.</p>
<h2 id="heading-when-not-to-use-microservices">When NOT to Use Microservices</h2>
<p>Microservices are powerful, but they're not a universal solution. In many cases, adopting microservices too early can introduce unnecessary complexity instead of solving real problems.</p>
<p>Before choosing this architecture, it’s important to understand when a simpler approach—such as a monolith—is more appropriate.</p>
<h3 id="heading-1-when-the-application-is-small">1. When the Application Is Small</h3>
<p>If your application has limited functionality (for example, a basic patient registration system or internal tool), splitting it into multiple services adds unnecessary overhead.</p>
<p>A monolithic architecture allows you to develop faster with less setup, debug issues more easily, and avoid managing multiple deployments.</p>
<p><strong>Example:</strong> A simple clinic portal with only patient registration and appointment booking doesn't require separate services for each feature.</p>
<h3 id="heading-2-when-the-team-size-is-limited">2. When the Team Size Is Limited</h3>
<p>When the team size is limited, microservices can become challenging. Managing multiple codebases, handling service communication, and dealing with deployments and monitoring can slow down development, making it tough for small teams to handle the complexity.</p>
<p><strong>Example:</strong> A team of 2–3 developers may spend more time managing infrastructure than building features if microservices are used prematurely.</p>
<h3 id="heading-3-when-deployment-complexity-outweighs-benefits">3. When Deployment Complexity Outweighs Benefits</h3>
<p>Microservices introduce operational complexity, including API gateways, service discovery, container orchestration (for example, Kubernetes), and monitoring and logging across services.</p>
<p>If your application doesn't require independent scaling or frequent deployments, this complexity may not be justified.</p>
<p><strong>Example:</strong> If all components of your system scale together and are updated at the same time, a monolith is often more efficient.</p>
<h3 id="heading-4-when-domain-boundaries-arent-clear">4. When Domain Boundaries Aren't Clear</h3>
<p>Microservices rely on well-defined service boundaries. If your domain isn't clearly understood, splitting into services too early can lead to tight coupling between services, frequent cross-service changes, and poorly designed APIs.</p>
<p>In such cases, starting with a monolith and refactoring later is a better approach.</p>
<h3 id="heading-5-when-you-lack-devops-and-observability-maturity">5. When You Lack DevOps and Observability Maturity</h3>
<p>Microservices require strong DevOps practices, including CI/CD pipelines, centralized logging, distributed tracing and monitoring &amp; alerting. Without these, debugging issues becomes extremely difficult.</p>
<h2 id="heading-future-enhancements"><strong>Future Enhancements</strong></h2>
<p>Healthcare systems are evolving rapidly, and microservices architectures can adapt to support new capabilities. Future improvements may include:</p>
<h3 id="heading-1event-driven-architecture">1.Event-Driven Architecture</h3>
<p>Adopting an event-driven approach allows services to communicate asynchronously through events rather than direct requests. This improves scalability, responsiveness, and fault tolerance, making it easier to handle high volumes of patient data and real-time updates across multiple services.</p>
<h3 id="heading-2-ai-powered-diagnostics">2. AI-Powered Diagnostics</h3>
<p>Integrating AI and machine learning can enhance diagnostic capabilities by analyzing patient data, detecting patterns, and providing predictive insights. This can improve clinical decision-making and streamline workflows within the healthcare portal.</p>
<h3 id="heading-3integration-with-fhir-standards">3.Integration with FHIR Standards</h3>
<p>Supporting FHIR (Fast Healthcare Interoperability Resources) standards enables seamless data exchange between different healthcare systems, labs, and third-party applications. Standardized APIs ensure better interoperability, compliance, and easier integration with external platforms.</p>
<h3 id="heading-4real-time-analytics">4.Real-Time Analytics</h3>
<p>Real-time analytics allows healthcare providers to monitor patient data, system performance, and operational metrics continuously. This supports proactive decision-making, early detection of anomalies, and improved overall quality of care.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Microservices-based REST API development provides a powerful foundation for building scalable and secure healthcare portals. By breaking applications into independent services, teams can achieve better scalability, faster deployments, and improved fault isolation.</p>
<p>However, adopting microservices is not just a technical shift—it is an architectural and operational commitment. Developers should start small, identify clear service boundaries, and gradually evolve their systems.</p>
<p>As your application grows, focus on strengthening security, improving observability, and automating deployments. These practices will ensure your healthcare platform remains reliable, compliant, and ready to scale in a cloud-native world.</p>
<p>The next step is to build your first microservice, deploy it using containers, and incrementally expand your system into a fully distributed healthcare platform.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Open Source Data Lake for Batch Ingestion ]]>
                </title>
                <description>
                    <![CDATA[ Creating a data platform has been made easier by cloud data analytics platforms like Databricks, Snowflake, and BigQuery. They offer excellent ramp-up and scaling options for small to mid-size teams.  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-an-open-source-data-lake-for-batch-ingestion/</link>
                <guid isPermaLink="false">69e0f1a7b67a275a9d3c9122</guid>
                
                    <category>
                        <![CDATA[ data-engineering ]]>
                    </category>
                
                    <category>
                        <![CDATA[ apache-airflow ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ingestion ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Puneet Singh ]]>
                </dc:creator>
                <pubDate>Thu, 16 Apr 2026 14:26:47 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/ef685075-beac-4bf4-b435-6e942e5e1ac1.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Creating a data platform has been made easier by cloud data analytics platforms like Databricks, Snowflake, and BigQuery. They offer excellent ramp-up and scaling options for small to mid-size teams.</p>
<p>But the trade-off isn't just merely renting the outside infrastructure. It also includes proprietary abstraction lock-in, and an operational and security surface area built on top of vendor capabilities.</p>
<p>In this article, you'll set up a batch ingestion layer on an open-source data lake stack where you own every component.</p>
<p>The focus is deliberately narrow. We'll get the ingestion layer up and running end-to-end. Then we'll build on foundations that allow future extension: analytics, governance, and stream processing without locking you into any single tool for those layers. We'll also review documented integration failures along the way: misconfigured catalogs, partition values written as NULL, and Python version mismatches.</p>
<p>By the end, you'll have:</p>
<ul>
<li><p>A working single-node data lake running on Docker (compose), built on RustFS (object storage), Apache Iceberg (table format), and Project Nessie (catalog).</p>
</li>
<li><p>A batch pipeline orchestrated with Apache Airflow, executing PySpark jobs that write versioned, partitioned Iceberg tables.</p>
</li>
<li><p>A real-world ingestion pattern, an external web scraper decoupled from Airflow via Redis, writing raw data to object storage with a lightweight signal table.</p>
</li>
<li><p>A view of what this stack is and isn't, and what you'd add to take it toward production.</p>
</li>
</ul>
<p>A word on scope: this covers the E in <a href="https://www.getdbt.com/blog/extract-load-transform">ELT</a>: getting data in. Transformation (dbt, Spark SQL) and analytics (Trino, Superset) are a natural next layer, but are outside the scope of this article. What you build here is the foundation they'd sit on.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><p><a href="#heading-the-ingestion-problem">The Ingestion Problem</a></p>
</li>
<li><p><a href="#heading-stack">Stack</a></p>
</li>
<li><p><a href="#heading-system-overview">System Overview</a></p>
</li>
<li><p><a href="#heading-quick-start">Quick Start</a></p>
</li>
<li><p><a href="#heading-running-the-pipelines">Running the Pipelines</a></p>
</li>
<li><p><a href="#heading-setup">Setup</a></p>
<ul>
<li><p><a href="#heading-rustfs">RustFS</a></p>
</li>
<li><p><a href="#heading-nessie">Nessie</a></p>
</li>
<li><p><a href="#heading-spark">Spark</a></p>
</li>
<li><p><a href="#heading-apache-airflow">Apache Airflow</a></p>
</li>
<li><p><a href="#heading-scrapredis">Scrapredis</a></p>
</li>
<li><p><a href="#heading-scrapworker">Scrapworker</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-path-forward">Path Forward</a></p>
<ul>
<li><p><a href="#heading-extending-capabilities">Extending Capabilities</a></p>
</li>
<li><p><a href="#heading-adding-layers">Adding Layers</a></p>
</li>
</ul>
</li>
</ul>
<h2 id="heading-the-ingestion-problem">The Ingestion Problem</h2>
<p>The structure of a stack/solution is easier to understand with a use case. A high-level goal is to ingest financial data from external market APIs for trend analysis. You'll focus specifically on setting up ingestion of such data into the warehouse for further analytics.</p>
<p>The data is ingested via a web crawler with a specific rate limit per endpoint. In Batch processing, time-based partitioning is effective for processing by downstream pipelines. It also favors cleaner data retention.</p>
<p>The crawler runs as an external process, decoupled from Airflow via a Redis job queue. This keeps rate limiting and crawl lifecycle outside the orchestration layer, with each component failing and recovering independently.</p>
<p>During ingestion, the priority is data landing with high reliability due to the lack of idempotency in crawl jobs.</p>
<h2 id="heading-stack">Stack</h2>
<ul>
<li><p><a href="https://rustfs.com/"><strong>RustFS</strong></a><strong>:</strong> An S3-compatible object store written in Rust</p>
</li>
<li><p><a href="https://projectnessie.org/"><strong>Project Nessie</strong></a><strong>:</strong> Transactional catalog for Apache Iceberg tables</p>
</li>
<li><p><a href="https://spark.apache.org/"><strong>Apache Spark</strong></a><strong>:</strong> Distributed compute engine</p>
</li>
<li><p><a href="https://airflow.apache.org/"><strong>Apache Airflow</strong></a><strong>:</strong> Job scheduling and orchestration</p>
</li>
<li><p><a href="https://jupyter.org/"><strong>Jupyter Notebook</strong></a> <em>(optional)</em>: Ad-hoc Spark queries against Iceberg tables, not covered in this article</p>
</li>
<li><p><strong>Scrapredis:</strong> Job queue for the web crawler</p>
</li>
<li><p><strong>Scrapworker:</strong> Web crawler and ingestion worker</p>
</li>
</ul>
<p>This setup was tested on a 4-core x86/AMD CPU, 16GB RAM, 60GB disk GCP VM running Debian GNU/Linux 11 (Bullseye). Docker with Compose v2 is required. The setup should work on any comparable Linux environment with similar or better specs.</p>
<h2 id="heading-system-overview">System Overview</h2>
<img src="https://cdn.hashnode.com/uploads/covers/69607e708806706b5c49c7af/429a1e8a-bc39-44dc-8e0b-2cd9152370f5.png" alt="Data Platform Architecture" style="display:block;margin:0 auto" width="3202" height="2385" loading="lazy">

<p>The crawler runs as an external process, decoupled from Airflow via a Redis job queue. Airflow pushes a job specification to the queue containing the endpoint, query params, and target path. The crawler picks it up, executes the crawl, and writes raw results directly to object storage.</p>
<p>This separation keeps rate limiting and crawl lifecycle concerns outside the orchestration layer, and isolates failure modes.</p>
<p>A crawl failure is harder to recover since crawl jobs lack idempotency. Pipeline failures after the crawl stage are independently retryable without re-triggering a crawl.</p>
<h2 id="heading-quick-start">Quick Start</h2>
<p>First, initialize the project:</p>
<pre><code class="language-bash"># Clone the repository
git clone https://github.com/ps-mir/data-platform

# Create the shared Docker network
docker network create data-platform

# Create host directories, set permissions, and download Spark JARs
chmod +x init.sh &amp;&amp; ./init.sh
</code></pre>
<p>Start services in this order (shutdown in reverse):</p>
<ol>
<li><strong>RustFS</strong></li>
</ol>
<pre><code class="language-bash">cd rustfs &amp;&amp; docker compose up -d
</code></pre>
<ol>
<li><strong>Nessie</strong></li>
</ol>
<pre><code class="language-bash">cd nessie &amp;&amp; docker compose up -d
</code></pre>
<ol>
<li><strong>Spark</strong> — requires a build on first run</li>
</ol>
<pre><code class="language-bash">cd spark &amp;&amp; docker compose build &amp;&amp; docker compose up -d
</code></pre>
<ol>
<li><strong>Scrapredis</strong></li>
</ol>
<pre><code class="language-bash">cd scrapredis &amp;&amp; docker compose up -d
</code></pre>
<ol>
<li><strong>Airflow</strong> — requires a build on first run</li>
</ol>
<pre><code class="language-bash">cd airflow-docker &amp;&amp; docker compose build &amp;&amp; docker compose up -d
</code></pre>
<p>Create the Nessie namespaces once after Nessie is up:</p>
<pre><code class="language-bash">curl -X POST http://localhost:19120/iceberg/v1/main/namespaces \
  -H "Content-Type: application/json" \
  -d '{"namespace": ["default"]}'

curl -X POST http://localhost:19120/iceberg/v1/main/namespaces \
  -H "Content-Type: application/json" \
  -d '{"namespace": ["scraper"]}'
</code></pre>
<p>Scrapworker runs on the host directly (it's not dockerized). It requires Python &gt;=3.14:</p>
<pre><code class="language-bash">cd scrapworker
pip install -e .
CONFIG_PATH=./config/config.local.yaml RUSTFS_ACCESS_KEY=rustfsadmin RUSTFS_SECRET_KEY=rustfsadmin python -m scrapworker
</code></pre>
<p>Scrapworker must be running before activating <code>scraper_pipeline_v1</code> in Airflow. Without it, the pipeline will push jobs to the queue with no worker to pick them up and hang indefinitely in <code>wait_for_completion</code>.</p>
<p>Trino is also present in setup but not tested for integration with Nessie yet.</p>
<h2 id="heading-running-the-pipelines">Running the Pipelines</h2>
<p>With the stack running, the next step is to activate the pipelines in Airflow. All DAGs are paused at creation by default. The four pipelines build on each other in complexity. Working through them in order is the fastest way to confirm that each layer of the stack is wired correctly before moving to the next.</p>
<p>All four pipelines are loaded but paused by default. Unpause each one in the Airflow UI before triggering.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69607e708806706b5c49c7af/38f95d52-c092-4a00-b660-1233077b781b.png" alt="All Airflow Pipelines" style="display:block;margin:0 auto" width="2678" height="1234" loading="lazy">

<p>Let's go over each pipeline:</p>
<h3 id="heading-sparkstaticdatav1skeleton-hello-dag">spark_static_data_v1_skeleton: <a href="https://github.com/ps-mir/data-platform/blob/07ad47d68fec51f48cd41560921d509a70c5bb6f/airflow-docker/dags/step1_hello_dag.py">Hello DAG</a></h3>
<p>This is a minimal DAG with no Spark, just a Python task that prints a message. If it goes green, Airflow's scheduler and worker are healthy. <code>[2026-04-09 22:00:01] INFO - Task operator:&lt;Task(_PythonDecoratedOperator): say_hello&gt;</code></p>
<h3 id="heading-sparkstaticdatav2submit-spark-submit">spark_static_data_v2_submit: <a href="https://github.com/ps-mir/data-platform/blob/07ad47d68fec51f48cd41560921d509a70c5bb6f/airflow-docker/dags/step2_spark_submit.py">Spark Submit</a></h3>
<p>This submits a PySpark job via <code>SparkSubmitOperator</code> that writes a static dataset to an Iceberg table. No partitioning, every run overwrites the previous content.</p>
<p>In Nessie catalog it appears as:</p>
<pre><code class="language-bash">Type: ICEBERG_TABLE
Metadata Location:s3://warehouse/default/static_data_e7e43123-95a7-44d2-b6d5-67c9c7aa4321/metadata/00000-08a5a2db-6f12-4f21-b2a9-de3d9123fbd3.metadata.json
</code></pre>
<h3 id="heading-sparkpartitioneddatav1-spark-partitioned">spark_partitioned_data_v1: <a href="https://github.com/ps-mir/data-platform/blob/07ad47d68fec51f48cd41560921d509a70c5bb6f/airflow-docker/dags/step3_spark_partitioned.py">Spark Partitioned</a></h3>
<p>This extends step2 with time-based partitioning. Partition values are derived from the scheduled slot time, so every run writes to its own <code>(ds, hr, min)</code> partition without touching previous ones.</p>
<p>Example file path in RustFS: <code>warehouse/default/static_data_partitioned_b172c66f-722b-44f3-bbee-069355753ff6/data/ds=2026-03-28/hr=23/min=15/00000-4-7a196a47-2ac0-4023-af68-ca10487fccb2-0-00001.parquet</code></p>
<h3 id="heading-scraperpipelinev1-scraper-pipeline">scraper_pipeline_v1: <a href="https://github.com/ps-mir/data-platform/blob/07ad47d68fec51f48cd41560921d509a70c5bb6f/airflow-docker/dags/scraper_pipeline.py">Scraper Pipeline</a></h3>
<p>This is the full ingestion flow. Airflow pushes a job to Scrapredis, Scrapworker calls the Binance API and writes raw results to RustFS, then Airflow publishes a signal row to the Nessie catalog.</p>
<p>Every run fetches: <code>https://api.binance.com/api/v3/trades?symbol=BTCUSDT&amp;limit=10</code></p>
<h2 id="heading-setup">Setup</h2>
<p>This is a single-node development setup using Docker Compose. It's built on a well-structured base config that can be extended to production with targeted changes.</p>
<ul>
<li><p>A production deployment would require HA configuration, persistent volume management, and security hardening for each component.</p>
</li>
<li><p>Images are pinned to specific versions to avoid silent breakage between pulls.</p>
</li>
<li><p>All containers share a common external Docker network named <code>data-platform</code>, which allows services to communicate using container names as hostnames.</p>
</li>
<li><p>An <code>init.sh</code> script creates the required local dirs inside the data folder and also creates the Docker network.</p>
</li>
</ul>
<h3 id="heading-rustfs">RustFS</h3>
<p>RustFS is the object storage layer in this stack. Nessie's REST catalog mode has a hard dependency on an S3-compatible endpoint. Running it against a local filesystem fails the Nessie healthcheck at startup and causes catalog initialization to error out. The REST catalog is the recommended mode for new setups because it enables credential vending and multi-engine coordination.</p>
<p>MinIO was the natural choice for self-hosted S3-compatible storage, but it shifted to a more restrictive license. RustFS is the open-source alternative, written in Rust and backed by local disk.</p>
<p>At write time, Spark pushes Parquet files directly to RustFS via S3FileIO. Nessie commits the table metadata alongside, so data and catalog state land together or not at all. This is <a href="https://iceberg.apache.org/">Apache Iceberg</a>'s core guarantee: atomic commits across both data files and metadata.</p>
<p>For production or cloud deployments, managed object storage services like AWS S3, Google Cloud Storage, or Azure Blob Storage are the natural next step. Self-hosted alternatives at scale include <a href="https://github.com/seaweedfs/seaweedfs">SeaweedFS</a>, <a href="https://docs.ceph.com/en/latest/radosgw/">Ceph/RGW</a>, and <a href="https://garagehq.deuxfleurs.fr/">Garage</a>.</p>
<h4 id="heading-notes">Notes:</h4>
<ul>
<li><p><strong>Bucket creation:</strong> A <code>rustfs-init</code> sidecar using <code>amazon/aws-cli</code> runs after RustFS passes its healthcheck and creates the <code>s3://warehouse</code> bucket automatically. You don't create the bucket manually.</p>
</li>
<li><p><strong>Permissions:</strong> RustFS runs as uid=10001 inside the container. The host directories (<code>data/rustfs/data</code> and <code>data/rustfs/applogs</code>) must be owned by that uid before the container starts, or it will fail silently. <code>init.sh</code> handles this with <code>sudo chown -R 10001:10001</code>.</p>
</li>
<li><p><strong>Image pinning:</strong> The compose file pins to <code>rustfs/rustfs:1.0.0-alpha.85-glibc</code>. Before upgrading, verify the uid hasn't changed: <code>docker run --rm --entrypoint id rustfs/rustfs:&lt;new-tag&gt;</code>. If it has, re-run <code>init.sh</code> or re-chown manually.</p>
</li>
<li><p><strong>Spark writes:</strong> Spark writes data files directly to RustFS via S3FileIO. Nessie only manages catalog metadata, it doesn't proxy data. The two interact at commit time, not at write time.</p>
</li>
</ul>
<h3 id="heading-nessie">Nessie</h3>
<p>The catalog tracks the list of tables in the warehouse, along with their data files and schema. Without it, it's hard for Spark to agree on what's in the warehouse.</p>
<p><a href="https://hive.apache.org/docs/latest/admin/adminmanual-metastore-administration/">Hive Metastore</a> offers a Thrift-based API and has been the catalog standard for years. It provides transaction semantics on metadata updates through its backing database, but those transactions stop at the catalog layer. Data files underneath aren't part of the same commit, and there's no cross-table history beyond what the database retains.</p>
<p>Apache Iceberg closes the data and metadata gap with atomic table commits. Nessie builds on that and goes further: it treats the catalog like a Git repository. Every table write is a commit. You can branch, tag, and roll back across multiple tables atomically.</p>
<p>Spark reads and writes table metadata through Nessie's Iceberg REST endpoint. Catalog state is persisted to Postgres, so it survives container restarts.</p>
<h4 id="heading-namespace-bootstrap">Namespace bootstrap</h4>
<p>Unlike Hive Metastore, Nessie doesn't auto-create namespaces. Attempting to write a table to a namespace that doesn't exist fails after data has already been written to RustFS, leaving orphaned files with no catalog entry. Namespaces are structural metadata and belong in a one-time bootstrap step, not in a pipeline.</p>
<p>Nessie manages the Iceberg catalog metadata under <code>s3://warehouse/</code>. Iceberg table data lands under paths derived from the namespace, for example, <code>s3://warehouse/default/</code> for the <code>default</code> namespace.</p>
<h4 id="heading-s3-credential-configuration-issue">S3 Credential Configuration Issue</h4>
<p>Nessie's S3 credential fields don't accept plain strings (likely for security reasons). They require a secret URI in the form <code>urn:nessie-secret:quarkus:&lt;name&gt;</code> even for local credentials.</p>
<p>Additionally, the SCREAMING_SNAKE_CASE environment variable convention is ambiguous for Quarkus property names containing hyphens. The property is silently ignored, and the default (which fails) is used instead. The working approach is dot-notation keys passed directly in the compose environment block, which Quarkus reads without conversion:</p>
<pre><code class="language-properties">nessie.catalog.service.s3.default-options.access-key: "urn:nessie-secret:quarkus:nessie.catalog.secrets.access-key"
nessie.catalog.secrets.access-key.name: rustfsadmin
nessie.catalog.secrets.access-key.secret: rustfsadmin
</code></pre>
<h4 id="heading-nessie-health-check">Nessie health check</h4>
<p>Once the RustFS settings are corrected, Nessie's health check URL(<a href="http://localhost:9090/q/health">http://localhost:9090/q/health</a>) should return the following response:</p>
<pre><code class="language-json">{
    "status": "UP",
    "checks": [
        {
            "name": "MongoDB connection health check",
            "status": "UP"
        },
        {
            "name": "Warehouses Object Stores",
            "status": "UP",
            "data": {
                "warehouse.warehouse.status": "UP"
            }
        },
        {
            "name": "Database connections health check",
            "status": "UP",
            "data": {
                "&lt;default&gt;": "UP"
            }
        }
    ]
}
</code></pre>
<p>The MongoDB connection health check appears in the response even though this stack doesn't use MongoDB. It's a Quarkus built-in probe registered automatically regardless of store type. With JDBC configured, MongoDB is never connected and the UP report is just a placeholder response.</p>
<h4 id="heading-catalog-endpoint-vs-management">Catalog endpoint vs Management</h4>
<p>Nessie exposes two separate APIs. The Iceberg REST catalog is at <code>/iceberg</code>. This is what Spark and Trino connect to. The Nessie management API is at <code>/api/v2</code>, which is for branch operations, commit history, and table inspection. They aren't interchangeable.</p>
<pre><code class="language-properties"># Iceberg REST API
http://localhost:19120/iceberg/v1/main/namespaces
http://localhost:19120/iceberg/v1/config

# Nessie management API
http://localhost:19120/api/v2/config
</code></pre>
<h4 id="heading-notes">Notes:</h4>
<ul>
<li><p><code>path-style-access: true</code> is required for any non-AWS S3 endpoint. <code>region</code> is a dummy value required by the AWS SDK internally.</p>
</li>
<li><p>Nessie's internal port 9000 is remapped to 9090 on the host to avoid conflict with RustFS which occupies 9000 and 9001.</p>
</li>
</ul>
<h4 id="heading-forward-path">Forward path</h4>
<p>Nessie is a stateless REST service, so scaling reads can be done with LB with no coordination between nodes. Durability comes entirely from backend store.</p>
<h3 id="heading-spark">Spark</h3>
<p>As a distributed compute engine, Apache Spark is a reliable and stable choice for long-running jobs. In the current setup, it executes PySpark jobs submitted by Airflow, reads and writes Iceberg tables via the Nessie REST catalog, and writes data files directly to RustFS using S3FileIO. Spark runs in standalone mode with a single master and worker, configured via <code>spark-defaults.conf</code>.</p>
<p>Two JARs are required and must be placed in <code>data/spark/jars/</code> before starting:</p>
<ul>
<li><p><code>iceberg-spark-runtime-3.5_2.12</code>: Iceberg integration for Spark: SparkCatalog, DataFrameWriterV2, SQL extensions, and all table format logic.</p>
</li>
<li><p><code>iceberg-aws-bundle</code>: AWS SDK v2 and Iceberg's S3FileIO, the storage transport layer for writing data files to RustFS. The Spark base image ships only Hadoop AWS (SDK v1). This bundle provides the SDK v2 classes that S3FileIO requires.</p>
</li>
</ul>
<p>Spark uses a custom Dockerfile to install Python 3.12. Build the image before first use:</p>
<pre><code class="language-bash">cd spark
docker compose build
docker compose up -d
</code></pre>
<p>The PySpark jobs are covered in the Airflow section, where we walk through each DAG and its corresponding Spark script as part of the pipeline.</p>
<p>Before submitting any Spark job that writes an Iceberg table, the target namespace must exist in Nessie. Nessie doesn't auto-create namespaces, unlike Hive Metastore. Attempting to write to a missing namespace fails after data has already been written to RustFS, leaving orphaned files with no catalog entry.</p>
<p>Create the <code>default</code> namespace once before running any pipeline:</p>
<pre><code class="language-bash"># Nessie should be up and running at this point
curl -X POST http://localhost:19120/iceberg/v1/main/namespaces \
  -H "Content-Type: application/json" \
  -d '{"namespace": ["default"]}'
{
  "namespace" : [ "default" ],
  "properties" : { }
}
</code></pre>
<p>Verify:</p>
<pre><code class="language-bash">curl http://localhost:19120/iceberg/v1/main/namespaces
</code></pre>
<h4 id="heading-catalog-mismatch-tables-missing-across-query-engines">Catalog Mismatch: Tables Missing Across Query Engines</h4>
<p>If tables written by Spark aren't visible in Trino, the likely cause is a catalog mismatch. Spark configured with <code>NessieCatalog</code> and Trino using the Iceberg REST catalog maintain separate metadata views — they don't share table state. Both engines must point at the same catalog endpoint: <code>http://nessie:19120/iceberg</code>.</p>
<h4 id="heading-notes">Notes:</h4>
<ul>
<li><p><strong>Worker memory:</strong> The worker is configured with <code>SPARK_WORKER_MEMORY: 8g</code>. Spark's default is 1g is enough to register but not enough to run a job without queuing. Tune this based on available host memory.</p>
</li>
<li><p><strong>Remote signing:</strong> <code>remote-signing-enabled: false</code> Nessie's REST catalog supports credential vending via IAM/STS, but since that integration isn't present here, remote signing is disabled explicitly to avoid request failures.</p>
</li>
<li><p><strong>Config changes need full restart:</strong> Docker file-level bind mounts cache the inode at container start. Editing <code>spark-defaults.conf</code> won't take effect until Spark and the Airflow worker are restarted. In client mode, the Airflow worker is the Spark driver (the process that reads the config on job submission) and must be restarted too.</p>
</li>
<li><p><strong>Jupyter Notebook:</strong> A Jupyter instance with PySpark is included in the stack for ad-hoc queries against Iceberg tables. It connects to the same Spark cluster and Nessie catalog, so any table written by a pipeline is immediately queryable.</p>
</li>
</ul>
<p>⚠️ <strong>Warning:</strong> The Spark worker and Airflow worker (the driver) must run the same Python minor version. PySpark enforces this at runtime and fails immediately if they diverge. The Spark image in this stack uses a custom Dockerfile to install Python 3.12, matching Airflow's base image. If you upgrade either, verify that the versions stay aligned.</p>
<h3 id="heading-apache-airflow">Apache Airflow</h3>
<p>Airflow makes it easier to author, schedule and monitor workflows. In this case, it handles the ingestion for batch processing, but it can be extended to use cases like stream processing.</p>
<p>The Airflow components resemble more closely the DAG processor Airflow Architecture from the <a href="https://airflow.apache.org/docs/apache-airflow/stable/core-concepts/overview.html">official docs</a>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69607e708806706b5c49c7af/a438e02b-0b16-44c7-bcae-92c954a942cc.png" alt="DAG Processor Airflow Architecture" style="display:block;margin:0 auto" width="2308" height="1455" loading="lazy">

<p>Key aspects:</p>
<ul>
<li><p>The DAG Processor continuously parses DAG files and serializes them to the Metadata DB.</p>
</li>
<li><p>The Scheduler reads from there, detects when a DAG run is due, creates task instances, and pushes them to the CeleryExecutor (via Redis queue).</p>
</li>
<li><p>The Celery worker picks up a task and executes it. In the case of a <code>SparkSubmitOperator</code>, the worker process becomes the Spark driver, submitting the job to the Spark cluster.</p>
</li>
<li><p>Executors run on the Spark worker, write Parquet files directly to RustFS, and commit the table metadata to Nessie. Airflow records the task outcome back in the Metadata DB.</p>
</li>
</ul>
<p>Airflow uses a custom Dockerfile to install Java 17 and additional providers. Build the image before first use:</p>
<pre><code class="language-bash">cd airflow-docker
docker compose build
docker compose up -d
</code></pre>
<h4 id="heading-pipelines">Pipelines</h4>
<p>Pipelines need to be created inside <code>airflow-docker/dags</code> folder for dag processor to pick up load the pipeline DAG in metadata DB. Four pipeline examples are provided with varying complexity.</p>
<ol>
<li><p><code>step1_hello_dag.py</code>: single-task DAG with no dependencies, just a Python function that prints a message.</p>
</li>
<li><p><code>step2_spark_submit.py</code>: submits a PySpark job via SparkSubmitOperator. The job writes a static dataset to an Iceberg table via the Nessie catalog.</p>
</li>
<li><p><code>step3_spark_partitioned.py</code>: extends step 2 with time-based partitioning. The scheduled slot time is passed to the PySpark script.</p>
<ul>
<li>Time-based partition values are derived from <code>data_interval_start</code> for idempotency (Backfill, Reruns).</li>
</ul>
</li>
<li><p><code>scraper_pipeline</code>: a real-world ingestion pipeline. Coordinates with the external task executor <code>scrapworker</code> via the Redis queue <code>scrapredis</code>.</p>
<ul>
<li>Both <code>scrapredis</code> and <code>scrapworker</code> must be up and running for this pipeline to work.</li>
</ul>
</li>
</ol>
<h4 id="heading-deploy-mode-and-driver-config">Deploy Mode and Driver Config</h4>
<p>The initial <code>SparkSubmitOperator</code> configuration used <code>deploy_mode="cluster"</code>, which runs the driver on the Spark cluster rather than the submitting machine. This fails immediately on Spark standalone clusters with a hard error:</p>
<pre><code class="language-plaintext">Cluster deploy mode is currently not supported for python applications on standalone clusters.
</code></pre>
<p>Cluster mode for Python is only available on YARN and Kubernetes. The fix is <code>deploy_mode="client"</code>, but this shifts the problem: in client mode, the driver runs on the Airflow worker container, which means the worker needs everything the Spark containers have.</p>
<p>Overall, three changes are required in the Airflow worker:</p>
<ul>
<li><p>The Iceberg and Nessie JARs at <code>/opt/spark/user-jars/</code></p>
</li>
<li><p><code>spark-defaults.conf</code> with catalog, extension, and JAR config</p>
</li>
<li><p><code>SPARK_CONF_DIR=/opt/spark/conf</code>, without this, pip-installed PySpark's <code>spark-submit</code> silently ignores the mounted conf file and runs with no catalog config</p>
</li>
</ul>
<p>The fix was adding all three to <code>x-airflow-common</code> in <code>airflow-docker/docker-compose.yaml</code> so every Airflow service inherits them:</p>
<pre><code class="language-yaml">environment:
  SPARK_CONF_DIR: /opt/spark/conf

volumes:
  - ../data/spark/jars:/opt/spark/user-jars:ro
  - ../spark/spark-defaults.conf:/opt/spark/conf/spark-defaults.conf:ro
</code></pre>
<h4 id="heading-partition-values-written-as-null">Partition Values Written as NULL</h4>
<p>When the third pipeline (Spark Partitioned) ran for the first time, the data landed correctly in RustFS, but querying the Iceberg partitions metadata showed:</p>
<pre><code class="language-plaintext">+------------------+----------+
|         partition|file_count|
+------------------+----------+
|{NULL, NULL, NULL}|         2|
+------------------+----------+
</code></pre>
<p>The original script used Spark's DataSource V1 API:</p>
<pre><code class="language-python">df.write.format("iceberg").mode("overwrite").saveAsTable(table)
</code></pre>
<p>The script used Spark's V1 DataFrame write API with format("iceberg"), which loads an isolated table reference and bypasses Iceberg's catalog write path. As a result, Iceberg committed the data files to storage but wrote NULL partition values into the manifest metadata.</p>
<p>The fix is in Iceberg's native DataFrameWriterV2 API:</p>
<pre><code class="language-python">df.writeTo(table).overwritePartitions()
</code></pre>
<p>This routes through Iceberg's native write path, evaluates partition transforms from the real column values (ds, hr, min), and registers them correctly in the manifest. <code>overwritePartitions()</code> overwrites only the partitions present in the DataFrame. A rerun with the same scheduled time produces the same values and atomically replaces that partition, leaving all others untouched.</p>
<p>⚠️ Existing NULL-partition manifest entries aren't retroactively corrected by subsequent V2 writes. For a brand-new table containing only bad data, DROP TABLE and rewrite is the simplest recovery.</p>
<h3 id="heading-scrapredis">Scrapredis</h3>
<p>Scrapredis is a dedicated Redis instance that sits between Airflow and Scrapworker as a job queue. It's separate from Airflow's internal Redis, which exists solely for CeleryExecutor task dispatch. The separation means the crawler's job queue can be managed, scaled, or replaced without touching Airflow's internals.</p>
<p>The pattern generalises beyond scraping. Any external process that needs its own lifecycle, resource profile, or rate limiting can be wired the same way: Airflow pushes a job, the external worker pops it, and Airflow polls for the result.</p>
<p>The scraper pipeline follows this round-trip:</p>
<ol>
<li>Airflow pushes the job payload to the queue:</li>
</ol>
<pre><code class="language-python">QUEUE_KEY = "scrapworker:jobs"
client.lpush(QUEUE_KEY, json.dumps(payload))
</code></pre>
<ol>
<li>Scrapworker blocks on the queue and pops the next job:</li>
</ol>
<pre><code class="language-python">while True:
    _, payload = client.blpop(redis_cfg["queue_key"])
</code></pre>
<ol>
<li>Once the crawl finishes, Scrapworker writes the outcome and <code>s3_path</code> back to Redis:</li>
</ol>
<pre><code class="language-python">client.set(status_key, json.dumps({"status": "finished", "worker_id": worker_id, "s3_path": job["s3_path"]}), ex=TERMINAL_TTL)
</code></pre>
<ol>
<li>The <code>wait_for_completion</code> task polls for that status key. On success, <code>publish_nessie_signal</code> picks up the <code>s3_path</code> and writes the signal row to Nessie.</li>
</ol>
<h3 id="heading-scrapworker">Scrapworker</h3>
<p>Scrapworker is a Python app that uses the Scrapy crawl framework to crawl all pages of the request. It's decoupled from Airflow due to URL/client specific rate limit semantics. For simplicity, consider it a type of external worker that receives and executes requests from Airflow.</p>
<p>It's responsible for downloading and writing content to object storage (RustFS). The Nessie catalog update is decoupled and kept in a separate Airflow pipeline task.</p>
<h4 id="heading-fixed-signal-table">Fixed Signal Table</h4>
<p>Scrapworker writes raw JSON to RustFS rather than writing scraped data directly as Iceberg columns. The pipeline then publishes a single lightweight signal row to a Nessie-managed Iceberg table.</p>
<p>The signal schema is fixed and minimal (<code>run_id</code>, <code>endpoint</code>, <code>s3_path</code>, <code>ds</code>, <code>hr</code>, <code>min</code>, <code>published_at</code>). It never changes, regardless of what's being scraped.</p>
<p>Mirroring the scraped payload as Iceberg columns would force Scrapworker to own schema evolution across different endpoints. This isn't an ideal place for schema ownership. Instead, schema ownership sits downstream:</p>
<pre><code class="language-plaintext">Scrapworker  →  raw files in RustFS  +  signal row in Iceberg (from Pipeline)
Airflow job  →  reads raw via s3_path, applies schema, writes structured Iceberg table
</code></pre>
<p>The downstream job knows the domain, knows the schema, and is the right place to handle type casting, nulls, and partition layout. Scrapworker stays generic and thin — the same code handles any endpoint without modification.</p>
<h4 id="heading-why-signal-publish-is-a-separate-airflow-task">Why Signal Publish is a Separate Airflow Task</h4>
<p>Scrapworker writes to RustFS and sets <code>status: finished</code> in Redis with the <code>s3_path</code>. A separate Airflow task reads that status and publishes the signal row to Nessie. The two writes are intentionally decoupled.</p>
<p>If scrapworker published to Nessie directly after writing to RustFS, the two writes would share a failure mode. A Nessie failure after a successful RustFS write would leave data stranded with no signal and no clean recovery path. The only option would be a re-crawl which lacks idempotency.</p>
<p>With the decoupled approach, each failure is isolated. A Nessie failure triggers an Airflow retry of the signal publish task only, no re-scrape, no duplicate crawl. RustFS and Nessie failures are independently recoverable.</p>
<h4 id="heading-notes">Notes:</h4>
<ul>
<li><p>Raw scraped files are written directly to <code>s3://warehouse/raw/</code>, entirely outside Nessie's management. Nothing in the Iceberg layer touches this path.</p>
</li>
<li><p>The scrapworker signal table lives in a dedicated <code>scraper</code> namespace. Create it once before scrapworker runs for the first time.</p>
</li>
</ul>
<pre><code class="language-bash">curl -X POST http://localhost:19120/iceberg/v1/main/namespaces \
  -H "Content-Type: application/json" \
  -d '{"namespace": ["scraper"]}'
</code></pre>
<h2 id="heading-path-forward">Path Forward</h2>
<p>The stack we've built here is a working ingestion layer. It lands data reliably, tracks it in a versioned catalog, and gives you a foundation to build on. Two directions are worth considering from here.</p>
<h3 id="heading-extending-capabilities">Extending Capabilities</h3>
<p>These are improvements to what's already in the stack, making it more robust without adding new components.</p>
<p><strong>Ingestion reliability:</strong> Scrapworker currently handles failures by setting <code>status: failed</code> in Redis, which requires Airflow to re-trigger the full pipeline. Adding client-side rate limiting and per-endpoint retry logic with backoff would make crawl jobs more self-healing, so that a failed page fetch can retry independently without surfacing to Airflow at all.</p>
<p><strong>Config validation:</strong> A misconfigured endpoint schema in <code>config.yaml</code> fails silently at runtime, often deep into a crawl. A <code>validate_config()</code> call at startup would catch missing required fields like <code>offset_param</code> or <code>response_map</code> before any job runs. This becomes more important as more endpoints are added.</p>
<p><strong>Observability:</strong> Airflow alerting and SLA monitoring give early warning when pipelines miss their schedule or tasks take longer than expected. The signal table is useful here too. A lightweight monitor that checks for expected signal rows within a time window is a simple SLA check that works without external tooling.</p>
<h3 id="heading-adding-layers">Adding Layers</h3>
<p>These are new capabilities that build on the ingestion foundation.</p>
<p><strong>Transform layer:</strong> The raw Iceberg tables written by the ingestion layer are the input for a transform step. dbt or Spark SQL can read from raw, apply schema, clean types, and write structured tables to a separate namespace. This is the L in ELT and the natural next step once ingestion is stable.</p>
<p><strong>Analytics:</strong> Trino is already in the stack and partially integrated. Connecting it fully to Nessie enables SQL queries across all Iceberg tables. Adding Superset on top gives a visualisation layer without requiring any changes to the ingestion pipeline.</p>
<p><strong>Broader source onboarding:</strong> The current stack handles one ingestion pattern: a scheduled Airflow pipeline triggering an external HTTP crawler. The same foundation supports pull-based sources like databases using CDC, and push-based sources like event streams via Kafka. The Iceberg tables and Nessie catalog serve as the landing zone regardless of how data arrives.</p>
<p><strong>Governance:</strong> Iceberg and Nessie provide the foundations, covering snapshots, schema evolution, commit history, and time travel. The governance layer on top requires deliberate additions: access control, data quality checks, lineage tracking, and schema enforcement. None of these require replacing what's here, as they sit on top of it.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Fashion App That Helps You Organize Your Wardrobe  ]]>
                </title>
                <description>
                    <![CDATA[ I used to spend too long deciding what to wear, even when my closet was full. That frustration made the problem feel very clear to me: it was not about having fewer clothes. It was about having better ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-fashion-app-to-organize-your-wardrobe/</link>
                <guid isPermaLink="false">69de6abf91716f3cfb5448a1</guid>
                
                    <category>
                        <![CDATA[ webdev ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ full stack ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ MathJax ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Mokshita V P ]]>
                </dc:creator>
                <pubDate>Tue, 14 Apr 2026 16:26:39 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/bf593ff6-6de8-4b30-ab0a-700c3410ccb1.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>I used to spend too long deciding what to wear, even when my closet was full.</p>
<p>That frustration made the problem feel very clear to me: it was not about having fewer clothes. It was about having better organization, better visibility, and better guidance when making outfit decisions.</p>
<p>So I built a fashion web app that helps users organize their wardrobe, get outfit suggestions, evaluate shopping decisions, and improve recommendations over time using feedback.</p>
<p>In this article, I’ll walk through what the app does, how I built it, the decisions I made along the way, and the challenges that shaped the final result.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-table-of-contents">Table of Contents</a></p>
</li>
<li><p><a href="#heading-what-the-app-does">What the App Does</a></p>
</li>
<li><p><a href="#heading-why-i-built-it">Why I Built It</a></p>
</li>
<li><p><a href="#heading-tech-stack">Tech Stack</a></p>
</li>
<li><p><a href="#heading-product-walkthrough-what-users-see">Product Walkthrough (What Users See)</a></p>
</li>
<li><p><a href="#heading-how-i-built-it">How I Built It</a></p>
</li>
<li><p><a href="#heading-challenges-i-faced">Challenges I Faced</a></p>
</li>
<li><p><a href="#heading-what-i-learned">What I Learned</a></p>
</li>
<li><p><a href="#heading-what-i-want-to-improve-next">What I Want to Improve Next</a></p>
</li>
<li><p><a href="#heading-future-improvements">Future Improvements</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-the-app-does">What the App Does</h2>
<p>At a high level, the app combines six core capabilities:</p>
<ol>
<li><p>Wardrobe management</p>
</li>
<li><p>Outfit recommendations</p>
</li>
<li><p>Shopping suggestions</p>
</li>
<li><p>Discard recommendations</p>
</li>
<li><p>Feedback and usage tracking</p>
</li>
<li><p>Secure multi-user accounts</p>
</li>
</ol>
<p>Users can upload clothing items, explore suggested outfits, and mark recommendations as helpful or not helpful. They can also rate outfits and track whether items are worn, kept, or discarded.</p>
<p>That feedback becomes structured data for improving future recommendation quality.</p>
<h2 id="heading-why-i-built-it">Why I Built It</h2>
<p>I wanted to create something that felt personal and actually useful. A lot of fashion apps look polished, but they do not always help with everyday decisions. My goal was to build something that could make wardrobe management easier and outfit selection less overwhelming. The app needed to do three things well:</p>
<ul>
<li><p>store each user’s wardrobe data</p>
</li>
<li><p>personalize recommendations</p>
</li>
<li><p>learn from user feedback over time .</p>
</li>
</ul>
<p>That feedback loop mattered to me because it makes the app feel more alive instead of static.</p>
<h2 id="heading-tech-stack">Tech Stack</h2>
<p>Here are the tools I used to built the app:</p>
<ul>
<li><p>Frontend: React + Vite</p>
</li>
<li><p>Backend: FastAPI</p>
</li>
<li><p>Database: SQLite (local development)</p>
</li>
<li><p>Background jobs: Celery + Redis</p>
</li>
<li><p>Authentication: JWT (access + refresh token flow)</p>
</li>
<li><p>Deployment support: Docker and GitHub Codespaces</p>
</li>
</ul>
<p>This ended up giving me a pretty modular setup, which helped a lot as features started increasing: fast frontend iteration, clean API boundaries, and room to evolve recommendations separately from UI.</p>
<h2 id="heading-product-walkthrough-what-users-see">Product Walkthrough (What Users See)</h2>
<h3 id="heading-1-onboarding-and-account-setup">1. Onboarding and Account Setup</h3>
<p>To start using the app, a user needs to register, verify their email, and complete some profile basics.</p>
<img src="https://cdn.hashnode.com/uploads/covers/68ab1274684dc97382d342ea/1ff4fb0d-dc97-4088-b720-db917b53ba5b.png" alt="Onboarding screen showing account creation, email verification, and profile fields for body shape, height, weight, and style preferences." style="display:block;margin:0 auto" width="1319" height="850" loading="lazy">

<p>Each account is isolated, so wardrobe history and recommendations stay user-specific.</p>
<p>In this onboarding screen above, you can see account creation, email verification, and profile fields for body shape, height, weight, and style preferences.</p>
<h3 id="heading-2-wardrobe-upload">2. Wardrobe Upload</h3>
<p>Users can upload clothing images .</p>
<img src="https://cdn.hashnode.com/uploads/covers/68ab1274684dc97382d342ea/d69bf10b-b79b-4294-923c-5c9e5840098a.png" alt="Wardrobe upload form showing clothing image analysis results with category, dominant color, secondary color, and pattern details." style="display:block;margin:0 auto" width="1320" height="625" loading="lazy">

<p>Image analysis labels each item and makes it searchable for recommendations. The wardrobe upload form shows image analysis results with category, dominant color, secondary color, and pattern details listed.</p>
<h3 id="heading-3-outfit-recommendations">3. Outfit Recommendations</h3>
<p>Users can request recommendations, then rate outputs.</p>
<img src="https://cdn.hashnode.com/uploads/covers/68ab1274684dc97382d342ea/61527ddf-11e4-4284-92fd-2d0c948ae2db.png" alt="Outfit recommendation dashboard showing ranked outfit cards with feedback and rating actions." style="display:block;margin:0 auto" width="1011" height="692" loading="lazy">

<p>Above you can see the outfit recommendation dashboard that shows ranked outfit cards with feedback and rating actions. Recommendations are ranked by a weighted scoring model.</p>
<h3 id="heading-4-shopping-and-discard-assistants">4. Shopping and Discard Assistants</h3>
<p>The app evaluates new items against existing wardrobe data and flags low-value wardrobe items that may be worth removing.</p>
<img src="https://cdn.hashnode.com/uploads/covers/68ab1274684dc97382d342ea/88ed83c4-fdba-40e7-ad32-f77bdf21cb4d.png" alt="Shopping and discard analysis screen showing recommendation scores, written reasons, and styling guidance for each item." style="display:block;margin:0 auto" width="1324" height="852" loading="lazy">

<p>You can see the recommendation scores, written reasons (not just a binary decision), and styling guidance for each item above. It also features a "how to style it" incase the user still wants to keep the item.</p>
<h2 id="heading-how-i-built-it">How I Built It</h2>
<h3 id="heading-1-frontend-setup-react-vite">1. Frontend Setup (React + Vite)</h3>
<p>I used React + Vite because I wanted fast iteration and a clean component structure.</p>
<p>The frontend is split into feature areas like onboarding, wardrobe management, outfits, shopping, and discarded-item suggestions. I also keep API calls in a service layer so the UI components stay focused on rendering and interaction.</p>
<p>The snippet below is a simplified example of the API service pattern used in the app. It is not meant to be copy-pasted as-is, but it shows the same structure the frontend uses when talking to the backend.</p>
<p>Example API client pattern:</p>
<pre><code class="language-javascript">export async function getOutfitRecommendations(userId, params = {}) {
  const query = new URLSearchParams(params).toString();
  const url = `/users/\({userId}/outfits/recommend\){query ? `?${query}` : ""}`;

  const response = await fetch(url, {
    headers: {
      Authorization: `Bearer ${localStorage.getItem("access_token")}`,
    },
  });

  if (!response.ok) {
    throw new Error("Failed to fetch outfit recommendations");
  }

  return response.json();
}
</code></pre>
<p>Here's what's happening in that snippet:</p>
<ul>
<li><p><code>URLSearchParams</code> builds optional query strings like <code>occasion</code>, <code>season</code>, or <code>limit</code>.</p>
</li>
<li><p>The request path is user-scoped, which keeps each user’s recommendations isolated.</p>
</li>
<li><p>The <code>Authorization</code> header sends the access token so the backend can verify the session.</p>
</li>
<li><p>The response is checked before parsing so the UI can surface a useful error if the request fails.</p>
</li>
</ul>
<p>This pattern kept the frontend simple and reusable as the number of API calls grew.</p>
<h3 id="heading-2-backend-architecture-with-fastapi">2. Backend Architecture with FastAPI</h3>
<p>The backend is organized around clear route groups:</p>
<ul>
<li><p>auth routes for register, login, refresh, logout, and sessions</p>
</li>
<li><p>user analysis routes</p>
</li>
<li><p>wardrobe CRUD routes</p>
</li>
<li><p>recommendation routes for outfits, shopping, and discard analysis</p>
</li>
<li><p>feedback routes for ratings and helpfulness signals</p>
</li>
</ul>
<p>One of the most important design choices was enforcing ownership checks on user-scoped resources. That prevented one user from accessing another user’s wardrobe or feedback data.</p>
<p>The backend snippet below is another simplified example from the app’s route layer. It shows the request validation and orchestration logic, while the actual scoring work stays in the recommendation service.</p>
<pre><code class="language-python">@app.get("/users/{user_id}/outfits/recommend")
def recommend_outfits(user_id: int, occasion: str | None = None, season: str | None = None, limit: int = 10):
    user = get_user_or_404(user_id)
    wardrobe_items = get_user_wardrobe(user_id)

    if len(wardrobe_items) &lt; 2:
        raise HTTPException(status_code=400, detail="Not enough wardrobe items")

    recommendations = outfit_generator.generate_outfit_recommendations(
        wardrobe_items=wardrobe_items,
        body_shape=user.body_shape,
        undertone=user.undertone,
        occasion=occasion,
        season=season,
        top_k=limit,
    )

    return {"user_id": user_id, "recommendations": recommendations}
</code></pre>
<p>Here's how to read that code:</p>
<ul>
<li><p><code>get_user_or_404</code> loads the profile data needed for personalization.</p>
</li>
<li><p><code>get_user_wardrobe</code> fetches only the current user’s items.</p>
</li>
<li><p>The minimum wardrobe check prevents the recommendation logic from running on incomplete data.</p>
</li>
<li><p><code>generate_outfit_recommendations</code> handles the scoring logic separately, which keeps the route handler small and easier to test.</p>
</li>
<li><p>The response returns the results in a shape the frontend can consume directly.</p>
</li>
</ul>
<p>That separation helped keep the API layer readable while the recommendation logic stayed isolated in its own service.</p>
<h3 id="heading-3-recommendation-logic">3. Recommendation Logic</h3>
<p>I intentionally started with deterministic rules before introducing heavy ML. That made behavior easier to debug and explain.</p>
<p>The outfit recommender scores combinations using weighted signals:</p>
<p>$$\text{outfit score} = 0.4 \cdot \text{color harmony} + 0.4 \cdot \text{body-shape fit} + 0.2 \cdot \text{undertone fit}$$</p>
<p>The snippet below is a simplified example from the recommendation engine. It shows how the app combines multiple signals into a single score:</p>
<pre><code class="language-python">def score_outfit(combo, user_context):
    color_score = color_harmony.score(combo)
    shape_score = body_shape_rules.score(combo, user_context.body_shape)
    undertone_score = undertone_rules.score(combo, user_context.undertone)

    total = 0.4 * color_score + 0.4 * shape_score + 0.2 * undertone_score
    return round(total, 3)
</code></pre>
<p>The logic behind this approach is straightforward:</p>
<ul>
<li><p>color harmony helps the outfit feel visually coherent</p>
</li>
<li><p>body-shape scoring helps the outfit feel flattering</p>
</li>
<li><p>undertone scoring helps the colors work better with the user’s profile</p>
</li>
</ul>
<p>I used a similar structure for discard recommendations and shopping suggestions, but with different factors and thresholds.</p>
<h3 id="heading-4-authentication-and-secure-multi-user-design">4. Authentication and Secure Multi-user Design</h3>
<p>Security was one of the most important parts of this build.</p>
<p>I implemented:</p>
<ul>
<li><p>short-lived access tokens</p>
</li>
<li><p>refresh tokens with JTI tracking</p>
</li>
<li><p>token rotation on refresh</p>
</li>
<li><p>session revocation (single session and all sessions)</p>
</li>
<li><p>email verification and password reset flows</p>
</li>
</ul>
<p>The snippet below is a simplified example of the refresh-token lifecycle used in the app. It shows the important control points rather than every helper function:</p>
<pre><code class="language-python">def refresh_access_token(refresh_token: str):
    payload = decode_jwt(refresh_token)
    jti = payload["jti"]

    token_record = db.get_refresh_token(jti)
    if not token_record or token_record.revoked:
        raise AuthError("Invalid refresh token")

    new_refresh, new_jti = issue_refresh_token(payload["sub"])
    token_record.revoked = True
    token_record.replaced_by_jti = new_jti

    new_access = issue_access_token(payload["sub"])
    return {"access_token": new_access, "refresh_token": new_refresh}
</code></pre>
<p>What this code is doing:</p>
<ul>
<li><p>It decodes the refresh token and looks up its JTI in the database.</p>
</li>
<li><p>It rejects reused or revoked sessions, which helps prevent replay attacks.</p>
</li>
<li><p>It rotates the refresh token instead of reusing it.</p>
</li>
<li><p>It issues a fresh access token so the session stays valid without forcing the user to log in again.</p>
</li>
</ul>
<p>This design made multi-device sessions safer and gave me server-side control over logout behavior.</p>
<h3 id="heading-5-background-jobs-for-long-running-operations">5. Background Jobs for Long-running Operations</h3>
<p>Image analysis can be expensive, especially when the app needs to classify clothing, analyze colors, and estimate body-shape-related signals. To keep the request path responsive, I added Celery + Redis support for background tasks.</p>
<p>That gave the app two modes:</p>
<ul>
<li><p>synchronous processing for simpler local development</p>
</li>
<li><p>queued processing for heavier or slower jobs</p>
</li>
</ul>
<p>That tradeoff mattered because it let me keep the developer experience simple without blocking the app during more expensive work.</p>
<h3 id="heading-6-data-model-and-feedback-capture">6. Data Model and Feedback Capture</h3>
<p>A recommendation system only improves if it captures the right signals.</p>
<p>So I added dedicated feedback tables for:</p>
<ul>
<li><p>outfit ratings (1-5 + optional comments)</p>
</li>
<li><p>recommendation helpful/unhelpful feedback</p>
</li>
<li><p>item usage actions (worn/kept/discarded)</p>
</li>
</ul>
<p>Here is the shape of one of those models:</p>
<pre><code class="language-python">class RecommendationFeedback(Base):
    __tablename__ = "recommendation_feedback"

    id = Column(Integer, primary_key=True)
    user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
    recommendation_type = Column(String(50), nullable=False)
    recommendation_id = Column(Integer, nullable=False)
    helpful = Column(Boolean, nullable=False)
    created_at = Column(DateTime, default=datetime.utcnow)
</code></pre>
<p>How to read this model:</p>
<ul>
<li><p><code>user_id</code> ties feedback to the person who gave it.</p>
</li>
<li><p><code>recommendation_type</code> tells me whether the feedback belongs to outfits, shopping, or discard suggestions.</p>
</li>
<li><p><code>recommendation_id</code> identifies the exact recommendation.</p>
</li>
<li><p><code>helpful</code> stores the user’s direct response.</p>
</li>
<li><p><code>created_at</code> makes it possible to analyze feedback trends over time.</p>
</li>
</ul>
<p>This part of the system gives the app a real learning foundation, even though the feedback-to-model-update loop is still a future improvement.</p>
<h2 id="heading-challenges-i-faced">Challenges I Faced</h2>
<p>This was the section that taught me the most.</p>
<h3 id="heading-1-image-heavy-endpoints-were-slower-than-i-wanted">1. Image-heavy endpoints were slower than I wanted</h3>
<p>The analyze and wardrobe upload flows were doing a lot of work at once: image validation, classification, color extraction, storage, and database writes.</p>
<p>At first, that made the request flow feel heavier than it should have.</p>
<p>What I changed:</p>
<ul>
<li><p>I bounded concurrent image jobs so the app wouldn't try to do too much at once.</p>
</li>
<li><p>I separated slower jobs into background processing where possible.</p>
</li>
<li><p>I used load-test results to confirm which endpoints were actually expensive.</p>
</li>
</ul>
<p>The practical effect was that heavy image requests stopped competing with each other so aggressively. Instead of letting many expensive tasks pile up inside the same request cycle, I limited the active work and pushed slower operations into the queue when needed.</p>
<p>Why this fixed it:</p>
<ul>
<li><p>Bounding concurrency prevented the system from overloading CPU-bound tasks.</p>
</li>
<li><p>Moving expensive work into async jobs kept the main request/response cycle more responsive.</p>
</li>
<li><p>Load testing gave me evidence instead of guesswork, so I could tune the system based on real performance behavior.</p>
</li>
</ul>
<p>In other words, I didn't just “optimize” the endpoint in theory. I changed the execution model so expensive analysis could not block every other request behind it.</p>
<h3 id="heading-2-jwt-sessions-needed-real-server-side-control">2. JWT sessions needed real server-side control</h3>
<p>A basic JWT setup is easy to get working, but it becomes less useful if you cannot revoke sessions or manage multiple devices cleanly.</p>
<p>What I changed:</p>
<ul>
<li><p>I stored refresh tokens in the database.</p>
</li>
<li><p>I tracked token JTI values.</p>
</li>
<li><p>I rotated refresh tokens when users refreshed their session.</p>
</li>
<li><p>I added endpoints for logging out a single session or all sessions.</p>
</li>
</ul>
<p>The important shift here was moving from “token exists, therefore session is valid” to “token exists, matches the database record, and has not been revoked or replaced.” That gave the server the authority to invalidate old sessions immediately.</p>
<p>Why this fixed it:</p>
<ul>
<li><p>Server-side token tracking made revocation possible.</p>
</li>
<li><p>Rotation reduced the chance of token reuse.</p>
</li>
<li><p>Session management became visible to the user, which made the app feel more trustworthy.</p>
</li>
</ul>
<p>This is what made logout-all and multi-device management work in a real way instead of just being cosmetic UI actions.</p>
<h3 id="heading-3-user-data-isolation-had-to-be-explicit">3. User data isolation had to be explicit</h3>
<p>Because this is a multi-user app, I had to be careful that one account could never accidentally see another account’s wardrobe data.</p>
<p>What I changed:</p>
<ul>
<li><p>I added ownership checks to user-scoped routes.</p>
</li>
<li><p>I kept all wardrobe and feedback queries filtered by <code>user_id</code>.</p>
</li>
<li><p>I used encrypted image storage instead of exposing raw paths.</p>
</li>
</ul>
<p>In practice, this meant every route had to ask the same question: “Does this user own the resource they are trying to access?” If the answer was no, the request stopped immediately.</p>
<p>Why this fixed it:</p>
<ul>
<li><p>Ownership checks made data access rules explicit.</p>
</li>
<li><p>User-filtered queries prevented accidental cross-account reads.</p>
</li>
<li><p>Encrypted storage improved privacy and reduced the risk of exposing image data directly.</p>
</li>
</ul>
<p>That combination is what kept wardrobe data, feedback history, and images separated correctly across accounts.</p>
<h3 id="heading-4-docker-made-the-project-easier-to-share-but-only-after-the-stack-was-organized">4. Docker made the project easier to share, but only after the stack was organized</h3>
<p>The app includes the frontend, backend, Redis, Celery worker, and Celery Beat, so the first challenge was making the setup feel reproducible instead of fragile.</p>
<p>What I changed:</p>
<ul>
<li><p>I defined the stack in Docker Compose.</p>
</li>
<li><p>I documented the required environment variables.</p>
</li>
<li><p>I kept the dev stack aligned with how the app runs in practice.</p>
</li>
</ul>
<p>This removed a lot of setup ambiguity. Instead of asking someone to manually figure out how the frontend, backend, Redis, and workers fit together, I made the stack describe itself.</p>
<p>Why this fixed it:</p>
<ul>
<li><p>Docker let contributors start the project with fewer manual steps.</p>
</li>
<li><p>Clear environment configuration reduced setup mistakes.</p>
</li>
<li><p>Matching the stack to the architecture made the app easier to understand and test.</p>
</li>
</ul>
<p>That was important because the app depends on several moving parts, and the simplest way to make the project approachable was to make startup behavior predictable.</p>
<h2 id="heading-what-i-learned">What I Learned</h2>
<p>This project taught me a few important lessons:</p>
<ul>
<li><p>Small features become much more valuable when they work together.</p>
</li>
<li><p>Feedback data is one of the strongest signals for improving recommendations.</p>
</li>
<li><p>Clean data modeling matters a lot when multiple users are involved.</p>
</li>
<li><p>Docker and clear setup instructions make a project much easier for other people to try.</p>
</li>
</ul>
<p>I also learned that a project does not need to be huge to be useful. A focused app that solves one problem well can still feel meaningful.</p>
<h2 id="heading-what-i-want-to-improve-next">What I Want to Improve Next</h2>
<p>My roadmap from here:</p>
<ol>
<li><p>Integrate feedback directly into ranking updates</p>
</li>
<li><p>Add visual analytics for recommendation quality trends</p>
</li>
<li><p>Improve mobile UX parity</p>
</li>
<li><p>Deploy with persistent cloud storage and production database defaults</p>
</li>
<li><p>Provide a public demo mode for easier evaluation</p>
</li>
</ol>
<h2 id="heading-future-improvements">Future Improvements</h2>
<p>There are still a few things I would like to add later:</p>
<ul>
<li><p>a more advanced recommendation engine</p>
</li>
<li><p>visual analytics for user feedback</p>
</li>
<li><p>better mobile support</p>
</li>
<li><p>live deployment with persistent cloud storage</p>
</li>
<li><p>a public demo mode for easier testing</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>This project began as a personal frustration and turned into a full web application with authentication, wardrobe storage, recommendation logic, and feedback infrastructure.</p>
<p>The most rewarding part was seeing how practical software decisions, not just flashy UI, can help people make everyday choices faster.</p>
<p>If you want to explore or run the project, <a href="https://github.com/Mokshitavp1/fashion_assistant">check out the repo</a>. You can try the flows and share feedback. I would especially love input on recommendation quality, UX clarity, and what features would make this genuinely useful in daily life.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build and Deploy Multi-Architecture Docker Apps on Google Cloud Using ARM Nodes (Without QEMU)
 ]]>
                </title>
                <description>
                    <![CDATA[ If you've bought a laptop in the last few years, there's a good chance it's running an ARM processor. Apple's M-series chips put ARM on the map for developers, but the real revolution is happening ins ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-and-deploy-multi-architecture-docker-apps-on-google-cloud-using-arm-nodes/</link>
                <guid isPermaLink="false">69dcf2c3f57346bc1e05a01d</guid>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ google cloud ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ARM ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Amina Lawal ]]>
                </dc:creator>
                <pubDate>Mon, 13 Apr 2026 13:42:27 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/e89ae65a-4b3a-44b7-94d8-d0638f017bf6.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you've bought a laptop in the last few years, there's a good chance it's running an ARM processor. Apple's M-series chips put ARM on the map for developers, but the real revolution is happening inside cloud data centers.</p>
<p>Google Cloud Axion is Google's own custom ARM-based chip, built to handle the demands of modern cloud workloads. The performance and cost numbers are striking: Google claims Axion delivers up to 60% better energy efficiency and up to 65% better price-performance compared to comparable x86 machines.</p>
<p>AWS has Graviton. Azure has Cobalt. ARM is no longer niche. It's the direction the entire cloud industry is moving.</p>
<p>But there's a problem that catches almost every team off guard when they start this transition: <strong>container architecture mismatch</strong>.</p>
<p>If you build a Docker image on your M-series Mac and push it to an x86 server, it crashes on startup with a cryptic <code>exec format error</code>.</p>
<p>The server isn't broken. It just can't read the compiled instructions inside your image. An ARM binary and an x86 binary are written in fundamentally different languages at the machine level. The CPU literally can't execute instructions it wasn't designed for.</p>
<p>We're going to solve this problem completely in this tutorial. You'll build a single Docker image tag that automatically serves the correct binary on both ARM and x86 machines — no separate pipelines, no separate tags. Then you'll provision Google Cloud ARM nodes in GKE and configure your Kubernetes deployment to route workloads precisely to those cost-efficient nodes.</p>
<p><strong>Here's what you'll build, step by step:</strong></p>
<ul>
<li><p>A Go HTTP server that reports the CPU architecture it's running on at runtime</p>
</li>
<li><p>A multi-stage Dockerfile that cross-compiles for both <code>linux/amd64</code> and <code>linux/arm64</code> without slow QEMU emulation</p>
</li>
<li><p>A multi-arch image in Google Artifact Registry that acts as a single entry point for any architecture</p>
</li>
<li><p>A GKE cluster with two node pools: a standard x86 pool and an ARM Axion pool</p>
</li>
<li><p>A Kubernetes Deployment that pins your workload exclusively to the ARM nodes</p>
</li>
</ul>
<p>By the end, you'll hit a live endpoint and see the word <code>arm64</code> staring back at you from a Google Cloud ARM node. Let's get into it.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-step-1-set-up-your-google-cloud-project">Step 1: Set Up Your Google Cloud Project</a></p>
</li>
<li><p><a href="#heading-step-2-create-the-gke-cluster">Step 2: Create the GKE Cluster</a></p>
</li>
<li><p><a href="#heading-step-3-write-the-application">Step 3: Write the Application</a></p>
</li>
<li><p><a href="#heading-step-4-enable-multi-arch-builds-with-docker-buildx">Step 4: Enable Multi-Arch Builds with Docker Buildx</a></p>
</li>
<li><p><a href="#heading-step-5-write-the-dockerfile">Step 5: Write the Dockerfile</a></p>
</li>
<li><p><a href="#heading-step-6-build-and-push-the-multi-arch-image">Step 6: Build and Push the Multi-Arch Image</a></p>
</li>
<li><p><a href="#heading-step-7-add-the-axion-arm-node-pool">Step 7: Add the Axion ARM Node Pool</a></p>
</li>
<li><p><a href="#heading-step-8-deploy-the-app-to-the-arm-node-pool">Step 8: Deploy the App to the ARM Node Pool</a></p>
</li>
<li><p><a href="#heading-step-9-verify-the-deployment">Step 9: Verify the Deployment</a></p>
</li>
<li><p><a href="#heading-step-10-cost-savings-and-tradeoffs">Step 10: Cost Savings and Tradeoffs</a></p>
</li>
<li><p><a href="#heading-cleanup">Cleanup</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-project-file-structure">Project File Structure</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you start, make sure you have the following ready:</p>
<ul>
<li><p><strong>A Google Cloud project</strong> with billing enabled. If you don't have one, create it at <a href="https://console.cloud.google.com">console.cloud.google.com</a>. The total cost to follow this tutorial is around $5–10.</p>
</li>
<li><p><code>gcloud</code> <strong>CLI</strong> installed and authenticated. Run <code>gcloud auth login</code> to sign in and <code>gcloud config set project YOUR_PROJECT_ID</code> to point it at your project.</p>
</li>
<li><p><strong>Docker Desktop</strong> version 19.03 or later. Docker Buildx (the tool we'll use for multi-arch builds) ships bundled with it.</p>
</li>
<li><p><code>kubectl</code> installed. This is the CLI for interacting with Kubernetes clusters.</p>
</li>
<li><p>Basic familiarity with <strong>Docker</strong> (images, layers, Dockerfile) and <strong>Kubernetes</strong> (pods, deployments, services). You don't need to be an expert, but you should know what these things are.</p>
</li>
</ul>
<h2 id="heading-step-1-set-up-your-google-cloud-project">Step 1: Set Up Your Google Cloud Project</h2>
<p>Before writing a single line of application code, let's get the cloud infrastructure side ready. This is the foundation everything else will build on.</p>
<h3 id="heading-enable-the-required-apis">Enable the Required APIs</h3>
<p>Google Cloud services are off by default in any new project. Run this command to turn on the three APIs we'll need:</p>
<pre><code class="language-bash">gcloud services enable \
  artifactregistry.googleapis.com \
  container.googleapis.com \
  containeranalysis.googleapis.com
</code></pre>
<p>Here's what each one does:</p>
<ul>
<li><p><code>artifactregistry.googleapis.com</code> — enables <strong>Artifact Registry</strong>, where we'll store our Docker images</p>
</li>
<li><p><code>container.googleapis.com</code> — enables <strong>Google Kubernetes Engine (GKE)</strong>, where our cluster will run</p>
</li>
<li><p><code>containeranalysis.googleapis.com</code> — enables vulnerability scanning for images stored in Artifact Registry</p>
</li>
</ul>
<h3 id="heading-create-a-docker-repository-in-artifact-registry">Create a Docker Repository in Artifact Registry</h3>
<p>Artifact Registry is Google Cloud's managed container image store — the place where our built images will live before being deployed to the cluster. Create a dedicated repository for this tutorial:</p>
<pre><code class="language-bash">gcloud artifacts repositories create multi-arch-repo \
  --repository-format=docker \
  --location=us-central1 \
  --description="Multi-arch tutorial images"
</code></pre>
<p>Breaking down the flags:</p>
<ul>
<li><p><code>--repository-format=docker</code> — tells Artifact Registry this repository stores Docker images (as opposed to npm packages, Maven artifacts, and so on)</p>
</li>
<li><p><code>--location=us-central1</code> — the Google Cloud region where your images will be stored. Use a region that's close to where your cluster will run to minimize image pull latency. Run <code>gcloud artifacts locations list</code> to see all options.</p>
</li>
<li><p><code>--description</code> — a human-readable label for the repository, shown in the console.</p>
</li>
</ul>
<h3 id="heading-authenticate-docker-to-push-to-artifact-registry">Authenticate Docker to Push to Artifact Registry</h3>
<p>Docker needs credentials before it can push images to Google Cloud. Run this command to wire up authentication automatically:</p>
<pre><code class="language-bash">gcloud auth configure-docker us-central1-docker.pkg.dev
</code></pre>
<p>This adds a credential helper entry to your <code>~/.docker/config.json</code> file. What that means in practice: any time Docker tries to push or pull from a URL under <code>us-central1-docker.pkg.dev</code>, it will automatically call <code>gcloud</code> to get a valid auth token. You won't need to run <code>docker login</code> manually.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/31fd020f-ffa2-40bd-9057-57b16a61b325.png" alt="Terminal output of the gcloud artifacts repositories list command, showing a row for multi-arch-repo with format DOCKER, location us-central1" style="display:block;margin:0 auto" width="2870" height="1512" loading="lazy">

<h2 id="heading-step-2-create-the-gke-cluster">Step 2: Create the GKE Cluster</h2>
<p>With Artifact Registry ready to receive images, let's create the Kubernetes cluster. We'll start with a standard cluster using x86 nodes and add an ARM node pool later once we have an image to deploy.</p>
<pre><code class="language-bash">gcloud container clusters create axion-tutorial-cluster \
  --zone=us-central1-a \
  --num-nodes=2 \
  --machine-type=e2-standard-2 \
  --workload-pool=PROJECT_ID.svc.id.goog
</code></pre>
<p>Replace <code>PROJECT_ID</code> with your actual Google Cloud project ID.</p>
<p>What each flag does:</p>
<ul>
<li><p><code>--zone=us-central1-a</code> — creates a zonal cluster in a single availability zone. A regional cluster (using <code>--region</code>) would spread nodes across three zones for higher resilience, but for this tutorial a single zone keeps things simple and avoids capacity issues that can affect specific zones. If <code>us-central1-a</code> is unavailable, try <code>us-central1-b</code>.</p>
</li>
<li><p><code>--num-nodes=2</code> — two x86 nodes in this zone. We need at least 2 to have enough capacity alongside our ARM node pool later.</p>
</li>
<li><p><code>--machine-type=e2-standard-2</code> — the machine type for this default node pool. <code>e2-standard-2</code> is a cost-effective x86 machine with 2 vCPUs and 8 GB of memory, good for general workloads.</p>
</li>
<li><p><code>--workload-pool=PROJECT_ID.svc.id.goog</code> — enables <strong>Workload Identity</strong>, which is Google's recommended way for pods to authenticate with Google Cloud APIs. It avoids the need to download and store service account key files inside your cluster.</p>
</li>
</ul>
<p>This command takes a few minutes. While it runs, you can move on to writing the application. We'll come back to the cluster in Step 6.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/332250a8-3f99-4eb1-849f-51ab054c9567.png" alt="GCP Console Kubernetes Engine Clusters page showing axion-tutorial-cluster with a green checkmark status, the zone us-central1-a, and Kubernetes version in the table." style="display:block;margin:0 auto" width="1457" height="720" loading="lazy">

<h2 id="heading-step-3-write-the-application">Step 3: Write the Application</h2>
<p>We need an application to containerize. We'll use <strong>Go</strong> for three specific reasons:</p>
<ol>
<li><p>Go compiles into a single, statically-linked binary. There's no runtime to install, no interpreter — just the binary. This makes for extremely lean container images.</p>
</li>
<li><p>Go has first-class, built-in cross-compilation support. We can compile an ARM64 binary from an x86 Mac, or vice versa, by setting two environment variables. This will matter a lot when we get to the Dockerfile.</p>
</li>
<li><p>Go exposes the architecture the binary was compiled for via <code>runtime.GOARCH</code>. Our server will report this at runtime, giving us hard proof that the correct binary is running on the correct hardware.</p>
</li>
</ol>
<p>Start by creating the project directories:</p>
<pre><code class="language-bash">mkdir -p hello-axion/app hello-axion/k8s
cd hello-axion/app
</code></pre>
<p>Initialize the Go module from inside <code>app/</code>. This creates <code>go.mod</code> in the current directory:</p>
<pre><code class="language-bash">go mod init hello-axion
</code></pre>
<p><code>go mod init</code> is Go's built-in command for starting a new module. It writes a <code>go.mod</code> file that declares the module name (<code>hello-axion</code>) and the minimum Go version required. Every modern Go project needs this file — without it, the compiler doesn't know how to resolve packages.</p>
<p>Now create the application at <code>app/main.go</code>:</p>
<pre><code class="language-go">package main

import (
    "fmt"
    "net/http"
    "os"
    "runtime"
)

func handler(w http.ResponseWriter, r *http.Request) {
    hostname, _ := os.Hostname()
    fmt.Fprintf(w, "Hello from freeCodeCamp!\n")
    fmt.Fprintf(w, "Architecture : %s\n", runtime.GOARCH)
    fmt.Fprintf(w, "OS           : %s\n", runtime.GOOS)
    fmt.Fprintf(w, "Pod hostname : %s\n", hostname)
}

func healthz(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    fmt.Fprintln(w, "ok")
}

func main() {
    http.HandleFunc("/", handler)
    http.HandleFunc("/healthz", healthz)
    fmt.Println("Server starting on port 8080...")
    if err := http.ListenAndServe(":8080", nil); err != nil {
        fmt.Fprintf(os.Stderr, "server error: %v\n", err)
        os.Exit(1)
    }
}
</code></pre>
<p>Verify both files were created:</p>
<pre><code class="language-bash">ls -la
</code></pre>
<p>You should see <code>go.mod</code> and <code>main.go</code> listed.</p>
<p>Let's walk through what this code does:</p>
<ul>
<li><p><code>import "runtime"</code> — imports Go's built-in <code>runtime</code> package, which exposes information about the Go runtime environment, including the CPU architecture.</p>
</li>
<li><p><code>runtime.GOARCH</code> — returns a string like <code>"arm64"</code> or <code>"amd64"</code> representing the architecture this binary was compiled for. When we deploy to an ARM node, this value will be <code>arm64</code>. This is the core of our proof.</p>
</li>
<li><p><code>os.Hostname()</code> — returns the pod's hostname, which Kubernetes sets to the pod name. This lets us see which specific pod responded when we test the app later.</p>
</li>
<li><p><code>handler</code> — the main HTTP handler, registered on the root path <code>/</code>. It writes the architecture, OS, and hostname to the response.</p>
</li>
<li><p><code>healthz</code> — a separate handler registered on <code>/healthz</code>. It returns HTTP 200 with the text <code>ok</code>. Kubernetes will use this endpoint to check whether the container is alive and ready to serve traffic — we'll wire this up in the deployment manifest later.</p>
</li>
<li><p><code>http.ListenAndServe(":8080", nil)</code> — starts the server on port 8080. If it fails to start (for example, if the port is already in use), it prints the error and exits with a non-zero code so Kubernetes knows something went wrong.</p>
</li>
</ul>
<h2 id="heading-step-4-enable-multi-arch-builds-with-docker-buildx">Step 4: Enable Multi-Arch Builds with Docker Buildx</h2>
<p>Before we write the Dockerfile, we need to understand a fundamental constraint, because it directly shapes how the Dockerfile must be written.</p>
<h3 id="heading-why-your-docker-images-are-architecture-specific-by-default">Why Your Docker Images Are Architecture-Specific By Default</h3>
<p>A CPU only understands instructions written for its specific <strong>Instruction Set Architecture (ISA)</strong>. ARM64 and x86_64 are different ISAs — different vocabularies of machine-level operations. When you compile a Go program, the compiler translates your source code into binary instructions for exactly one ISA. That binary can't run on a different ISA.</p>
<p>When you build a Docker image the normal way (<code>docker build</code>), the binary inside that image is compiled for your local machine's ISA. If you're on an Apple Silicon Mac, you get an ARM64 binary. Push that image to an x86 server, and when Docker tries to execute the binary, the kernel rejects it:</p>
<pre><code class="language-shell">standard_init_linux.go:228: exec user process caused: exec format error
</code></pre>
<p>That's the operating system saying: "This binary was written for a different processor. I have no idea what to do with it."</p>
<h3 id="heading-the-solution-a-single-image-tag-that-serves-any-architecture">The Solution: A Single Image Tag That Serves Any Architecture</h3>
<p>Docker solves this with a structure called a <strong>Manifest List</strong> (also called a multi-arch image index). Instead of one image, a Manifest List is a pointer table. It holds multiple image references — one per architecture — all under the same tag.</p>
<p>When a server pulls <code>hello-axion:v1</code>, here's what actually happens:</p>
<ol>
<li><p>Docker contacts the registry and requests the manifest for <code>hello-axion:v1</code></p>
</li>
<li><p>The registry returns the Manifest List, which looks like this internally:</p>
</li>
</ol>
<pre><code class="language-json">{
  "manifests": [
    { "digest": "sha256:a1b2...", "platform": { "architecture": "amd64", "os": "linux" } },
    { "digest": "sha256:c3d4...", "platform": { "architecture": "arm64", "os": "linux" } }
  ]
}
</code></pre>
<ol>
<li>Docker checks the current machine's architecture, finds the matching entry, and pulls only that specific image layer. The x86 image never downloads onto your ARM server, and vice versa.</li>
</ol>
<p>One tag, two actual images. Completely transparent to your deployment manifests.</p>
<h3 id="heading-set-up-docker-buildx">Set Up Docker Buildx</h3>
<p><strong>Docker Buildx</strong> is the CLI tool that builds these Manifest Lists. It's powered by the <strong>BuildKit</strong> engine and ships bundled with Docker Desktop. Run the following to create and activate a new builder instance:</p>
<pre><code class="language-bash">docker buildx create --name multiarch-builder --use
</code></pre>
<ul>
<li><p><code>--name multiarch-builder</code> — gives this builder a memorable name. You can have multiple builders. This command creates a new one named <code>multiarch-builder</code>.</p>
</li>
<li><p><code>--use</code> — immediately sets this new builder as the active one, so all future <code>docker buildx build</code> commands use it.</p>
</li>
</ul>
<p>Now boot the builder and confirm it supports the platforms we need:</p>
<pre><code class="language-bash">docker buildx inspect --bootstrap
</code></pre>
<ul>
<li><code>--bootstrap</code> — starts the builder container if it isn't already running, and prints its full configuration.</li>
</ul>
<p>You should see output like this:</p>
<pre><code class="language-plaintext">Name:          multiarch-builder
Driver:        docker-container
Platforms:     linux/amd64, linux/arm64, linux/arm/v7, linux/386, ...
</code></pre>
<p>The <code>Platforms</code> line lists every architecture this builder can produce images for. As long as you see <code>linux/amd64</code> and <code>linux/arm64</code> in that list, you're ready to build for both x86 and ARM.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/1c19aca1-30c4-406d-9c37-679ee4f2928f.png" alt="Terminal output showing the multiarch-builder details with Name, Driver set to docker-container, and a Platforms list that includes linux/amd64 and linux/arm64 highlighted." style="display:block;margin:0 auto" width="2188" height="1258" loading="lazy">

<h2 id="heading-step-5-write-the-dockerfile">Step 5: Write the Dockerfile</h2>
<p>Now we can write the Dockerfile. We'll use two techniques together: a <strong>multi-stage build</strong> to keep the final image tiny, and a <strong>cross-compilation trick</strong> to avoid slow CPU emulation.</p>
<p>Create <code>app/Dockerfile</code> with the following content:</p>
<pre><code class="language-dockerfile"># -----------------------------------------------------------
# Stage 1: Build
# -----------------------------------------------------------
# $BUILDPLATFORM = the machine running this build (your laptop)
# \(TARGETOS / \)TARGETARCH = the platform we are building FOR
# -----------------------------------------------------------
FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS builder

ARG TARGETOS
ARG TARGETARCH

WORKDIR /app

COPY go.mod .
RUN go mod download

COPY main.go .

RUN GOOS=\(TARGETOS GOARCH=\)TARGETARCH go build -ldflags="-w -s" -o server main.go

# -----------------------------------------------------------
# Stage 2: Runtime
# -----------------------------------------------------------

FROM alpine:latest

RUN addgroup -S appgroup &amp;&amp; adduser -S appuser -G appgroup
USER appuser

WORKDIR /app
COPY --from=builder /app/server .

EXPOSE 8080
CMD ["./server"]
</code></pre>
<p>There's a lot happening here. Let's go through it carefully.</p>
<h3 id="heading-stage-1-the-builder">Stage 1: The Builder</h3>
<p><code>FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS builder</code></p>
<p>This is the most important line in the file. <code>\(BUILDPLATFORM</code> is a special build argument that Docker Buildx automatically injects — it equals the platform of the machine <em>running the build</em> (your laptop). By pinning the builder stage to <code>\)BUILDPLATFORM</code>, the Go compiler always runs natively on your machine, not inside a CPU emulator. This is what makes multi-arch builds fast.</p>
<p>Without <code>--platform=$BUILDPLATFORM</code>, Buildx would have to use <strong>QEMU</strong> — a full CPU emulator — to run an ARM64 build environment on your x86 machine (or vice versa). QEMU works, but it's typically 5–10 times slower than native execution. For a project with many dependencies, that's the difference between a 2-minute build and a 20-minute build.</p>
<p><code>ARG TARGETOS</code> <strong>and</strong> <code>ARG TARGETARCH</code></p>
<p>These two lines declare that our Dockerfile expects build arguments named <code>TARGETOS</code> and <code>TARGETARCH</code>. Buildx injects these automatically based on the <code>--platform</code> flag you pass at build time. For a <code>linux/arm64</code> target, <code>TARGETOS</code> will be <code>linux</code> and <code>TARGETARCH</code> will be <code>arm64</code>.</p>
<p><code>COPY go.mod .</code> <strong>and</strong> <code>RUN go mod download</code></p>
<p>We copy <code>go.mod</code> first, before copying the rest of the source code. Docker builds images layer by layer and caches each layer. By copying only the module file first, we create a cached layer for <code>go mod download</code>.</p>
<p>On future builds, as long as <code>go.mod</code> hasn't changed, Docker skips the download step entirely — even if the source code changed. This speeds up iterative development significantly.</p>
<p><code>RUN GOOS=\(TARGETOS GOARCH=\)TARGETARCH go build -ldflags="-w -s" -o server main.go</code></p>
<p>This is the cross-compilation step. <code>GOOS</code> and <code>GOARCH</code> are Go's built-in cross-compilation environment variables. Setting them tells the Go compiler to produce a binary for a different target than the machine it's running on. We set them from the <code>\(TARGETOS</code> and <code>\)TARGETARCH</code> build args injected by Buildx.</p>
<p>The <code>-ldflags="-w -s"</code> flag strips the debug symbol table and the DWARF debugging information from the binary. This has no effect on runtime behavior but reduces the binary size by roughly 30%.</p>
<h3 id="heading-stage-2-the-runtime-image">Stage 2: The Runtime Image</h3>
<p><code>FROM alpine:latest</code></p>
<p>This starts a brand-new image from Alpine Linux — a minimal Linux distribution that weighs about 5 MB. Critically, <code>alpine:latest</code> is itself a multi-arch image, so Docker automatically selects the <code>arm64</code> or <code>amd64</code> Alpine variant depending on which platform this stage is built for.</p>
<p>Everything from Stage 1 — the Go toolchain, the source files, the intermediate object files — is discarded. The final image contains <em>only</em> Alpine Linux plus our binary. Compared to a naive single-stage Go image (~300 MB), this approach produces an image under 15 MB.</p>
<p><code>RUN addgroup -S appgroup &amp;&amp; adduser -S appuser -G appgroup</code> and <code>USER appuser</code></p>
<p>These two lines create a non-root user and set it as the active user for the container. Running containers as root is a security risk — if an attacker exploits a vulnerability in your application, they gain root access inside the container. Running as a non-root user limits the blast radius.</p>
<p><code>COPY --from=builder /app/server .</code></p>
<p>This is how multi-stage builds work: the <code>--from=builder</code> flag tells Docker to copy files from the <code>builder</code> stage (Stage 1), not from your local disk. Only the compiled binary (<code>server</code>) makes it into the final image.</p>
<h2 id="heading-step-6-build-and-push-the-multi-arch-image">Step 6: Build and Push the Multi-Arch Image</h2>
<p>With the application and Dockerfile in place, we can now build images for both architectures and push them to Artifact Registry — all in a single command.</p>
<p>From inside the <code>app/</code> directory, run:</p>
<pre><code class="language-bash">docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t us-central1-docker.pkg.dev/PROJECT_ID/multi-arch-repo/hello-axion:v1 \
  --push \
  .
</code></pre>
<p>Replace <code>PROJECT_ID</code> with your actual GCP project ID.</p>
<p>Here's what each part of this command does:</p>
<ul>
<li><p><code>docker buildx build</code> — uses the Buildx CLI instead of the standard <code>docker build</code>. Buildx is required for multi-platform builds.</p>
</li>
<li><p><code>--platform linux/amd64,linux/arm64</code> — instructs Buildx to build the image twice: once targeting x86 Intel/AMD machines, and once targeting ARM64. Both builds run in parallel. Because our Dockerfile uses the <code>$BUILDPLATFORM</code> cross-compilation trick, both builds run natively on your machine without QEMU emulation.</p>
</li>
<li><p><code>-t us-central1-docker.pkg.dev/PROJECT_ID/multi-arch-repo/hello-axion:v1</code> — the full image path in Artifact Registry. The format is always <code>REGION-docker.pkg.dev/PROJECT_ID/REPO_NAME/IMAGE_NAME:TAG</code>.</p>
</li>
<li><p><code>--push</code> — multi-arch images can't be loaded into your local Docker daemon (which only understands single-architecture images). This flag tells Buildx to skip local storage and push the completed Manifest List — with both architecture variants — directly to the registry.</p>
</li>
<li><p><code>.</code> — the build context, the directory Docker scans for the Dockerfile and any files the build needs.</p>
</li>
</ul>
<p>Watch the output as the build runs. You'll see BuildKit working on both platforms simultaneously:</p>
<pre><code class="language-plaintext"> =&gt; [linux/amd64 builder 1/5] FROM golang:1.23-alpine
 =&gt; [linux/arm64 builder 1/5] FROM golang:1.23-alpine
 ...
 =&gt; pushing manifest for us-central1-docker.pkg.dev/.../hello-axion:v1
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/dc88f558-b4ee-4100-bfe1-eaa943bec9bc.png" alt="Terminal showing docker buildx build output with two parallel build tracks labeled linux/amd64 and linux/arm64, and a final line reading pushing manifest for the Artifact Registry image path." style="display:block;margin:0 auto" width="2188" height="1258" loading="lazy">

<h3 id="heading-verify-the-multi-arch-image-in-artifact-registry">Verify the Multi-Arch Image in Artifact Registry</h3>
<p>Once the push completes, navigate to <strong>GCP Console → Artifact Registry → Repositories → multi-arch-repo</strong> and click on <code>hello-axion</code>.</p>
<p>You won't see a single image — you'll see something labelled <strong>"Image Index"</strong>. That's the Manifest List we created. Click into it, and you'll find two child images with separate digests, one for <code>linux/amd64</code> and one for <code>linux/arm64</code>.</p>
<p>You can also inspect this from the command line:</p>
<pre><code class="language-bash">docker buildx imagetools inspect \
  us-central1-docker.pkg.dev/PROJECT_ID/multi-arch-repo/hello-axion:v1
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/28d0e4a4-1d45-4c0b-ac47-34dc3b72c11d.png" alt="Google Cloud Artifact Registry console showing hello-axion as an Image Index with two child images: one labeled linux/amd64 and one labeled linux/arm64, each with its own digest and size." style="display:block;margin:0 auto" width="2188" height="1258" loading="lazy">

<p>The output lists every manifest inside the image index. You'll see entries for <code>linux/amd64</code> and <code>linux/arm64</code> — those are our two real images. You'll also see two entries with <code>Platform: unknown/unknown</code> labelled as <code>attestation-manifest</code>. These are <strong>build provenance records</strong> that Docker Buildx automatically attaches to prove how and where the image was built (a supply chain security feature called SLSA attestation).</p>
<p>The two entries you care about are <code>linux/amd64</code> and <code>linux/arm64</code>. Note the digest for the <code>arm64</code> entry — we'll use it in the verification step to confirm the cluster pulled the right variant.</p>
<h2 id="heading-step-7-add-the-axion-arm-node-pool">Step 7: Add the Axion ARM Node Pool</h2>
<p>We have a universal image. Now we need somewhere to run it.</p>
<p>Recall the cluster we created in Step 2 — it's running <code>e2-standard-2</code> x86 machines. We're going to add a second node pool running ARM machines. This is the key architectural move: a <strong>mixed-architecture cluster</strong> where different workloads can be routed to different hardware.</p>
<h3 id="heading-choosing-your-arm-machine-type">Choosing Your ARM Machine Type</h3>
<p>Google Cloud currently offers two ARM-based machine series in GKE:</p>
<table>
<thead>
<tr>
<th>Series</th>
<th>Example type</th>
<th>What it is</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Tau T2A</strong></td>
<td><code>t2a-standard-2</code></td>
<td>First-gen Google ARM (Ampere Altra). Broadly available across regions. Great for getting started.</td>
</tr>
<tr>
<td><strong>Axion (C4A)</strong></td>
<td><code>c4a-standard-2</code></td>
<td>Google's custom ARM chip (Arm Neoverse V2 core). Newest generation, best price-performance. Still expanding availability.</td>
</tr>
</tbody></table>
<p>This tutorial uses <code>t2a-standard-2</code> because it's widely available. The commands are identical for <code>c4a-standard-2</code> — just swap the <code>--machine-type</code> value. If <code>t2a-standard-2</code> isn't available in your zone, GKE will tell you immediately when you run the node pool creation command below, and you can try a neighbouring zone.</p>
<h3 id="heading-create-the-arm-node-pool">Create the ARM Node Pool</h3>
<p>Add the ARM node pool to your existing cluster:</p>
<pre><code class="language-bash">gcloud container node-pools create axion-pool \
  --cluster=axion-tutorial-cluster \
  --zone=us-central1-a \
  --machine-type=t2a-standard-2 \
  --num-nodes=2 \
  --node-labels=workload-type=arm-optimized
</code></pre>
<p>What each flag does:</p>
<ul>
<li><p><code>--cluster=axion-tutorial-cluster</code> — the name of the cluster we created in Step 2. Node pools are always added to an existing cluster.</p>
</li>
<li><p><code>--zone=us-central1-a</code> — must match the zone you used when creating the cluster.</p>
</li>
<li><p><code>--machine-type=t2a-standard-2</code> — GKE detects this is an ARM machine type and automatically provisions the nodes with an ARM-compatible version of Container-Optimized OS (COS). You don't need to configure anything special for ARM at the OS level.</p>
</li>
<li><p><code>--num-nodes=2</code> — two ARM nodes in the zone, enough to schedule our 3-replica deployment alongside other cluster overhead.</p>
</li>
<li><p><code>--node-labels=workload-type=arm-optimized</code> — attaches a custom label to every node in this pool. We'll use this label in our deployment manifest to target these specific nodes. Using a descriptive custom label (rather than just relying on the automatic <code>kubernetes.io/arch=arm64</code> label) is good practice in real clusters — it communicates the <em>intent</em> of the pool, not just its hardware.</p>
</li>
</ul>
<p>This command takes a few minutes. Once it completes, let's confirm our cluster now has both node pools:</p>
<pre><code class="language-bash">gcloud container clusters get-credentials axion-tutorial-cluster --zone=us-central1-a

kubectl get nodes --label-columns=kubernetes.io/arch
</code></pre>
<p>The <code>get-credentials</code> command configures <code>kubectl</code> to authenticate with your new cluster. The <code>get nodes</code> command then lists all nodes and adds a column showing the <code>kubernetes.io/arch</code> label.</p>
<p>You should see something like:</p>
<pre><code class="language-plaintext">NAME                                    STATUS   ARCH    AGE
gke-...default-pool-abc...              Ready    amd64   15m
gke-...default-pool-def...              Ready    amd64   15m
gke-...axion-pool-jkl...                Ready    arm64   3m
gke-...axion-pool-mno...                Ready    arm64   3m
</code></pre>
<p><code>amd64</code> for the default x86 pool, <code>arm64</code> for our new Axion pool. This <code>kubernetes.io/arch</code> label is applied automatically by GKE — you don't set it, it's derived from the hardware.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/6389f4c6-17fe-4086-982f-39d94dbfa252.png" alt="Terminal output of kubectl get nodes with a ARCH column showing amd64 for two default-pool nodes and arm64 for two axion-pool nodes." style="display:block;margin:0 auto" width="2330" height="646" loading="lazy">

<h2 id="heading-step-8-deploy-the-app-to-the-arm-node-pool">Step 8: Deploy the App to the ARM Node Pool</h2>
<p>We have a multi-arch image and a mixed-architecture cluster. Here's something important to understand before writing the deployment manifest: <strong>Kubernetes doesn't know or care about image architecture by default</strong>.</p>
<p>If you applied a standard Deployment right now, the scheduler would look for any available node with enough CPU and memory and place pods there — potentially landing on x86 nodes instead of your ARM Axion nodes. The multi-arch Manifest List would handle this gracefully (the right binary would run regardless), but you'd lose the cost efficiency you provisioned Axion nodes for in the first place.</p>
<p>To guarantee that pods land on ARM nodes and only ARM nodes, we use a <code>nodeSelector</code>.</p>
<h3 id="heading-how-nodeselector-works">How nodeSelector Works</h3>
<p>A <code>nodeSelector</code> is a set of key-value pairs in your pod spec. Before the Kubernetes scheduler places a pod, it checks every available node's labels. If a node doesn't have all the labels in the <code>nodeSelector</code>, the scheduler skips it — the pod will remain in <code>Pending</code> state rather than land on the wrong node.</p>
<p>This is a hard constraint, which is exactly what we want for cost optimization. Contrast this with Node Affinity's soft preference mode (<code>preferredDuringSchedulingIgnoredDuringExecution</code>), which says "try to use ARM, but fall back to x86 if needed." Soft preferences are useful for resilience, but they undermine the whole point of dedicated ARM pools. We want the hard constraint.</p>
<h3 id="heading-write-the-deployment-manifest">Write the Deployment Manifest</h3>
<p>Create <code>k8s/deployment.yaml</code>:</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-axion
  labels:
    app: hello-axion
spec:
  replicas: 3
  selector:
    matchLabels:
      app: hello-axion
  template:
    metadata:
      labels:
        app: hello-axion
    spec:
      nodeSelector:
        kubernetes.io/arch: arm64

      containers:
      - name: hello-axion
        image: us-central1-docker.pkg.dev/PROJECT_ID/multi-arch-repo/hello-axion:v1
        ports:
        - containerPort: 8080
        livenessProbe:
          httpGet:
            path: /healthz
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /healthz
            port: 8080
          initialDelaySeconds: 3
          periodSeconds: 5
        resources:
          requests:
            cpu: "250m"
            memory: "64Mi"
          limits:
            cpu: "500m"
            memory: "128Mi"
</code></pre>
<p>Replace <code>PROJECT_ID</code> with your project ID. Here's what the key sections do:</p>
<p><code>replicas: 3</code> — tells Kubernetes to keep three instances of this pod running at all times. If one crashes or a node goes down, the scheduler spins up a replacement. Three replicas also means one pod per ARM node in <code>us-central1</code>, which distributes load across availability zones.</p>
<p><code>selector.matchLabels</code> and <code>template.metadata.labels</code> — these two blocks must match. The <code>selector</code> tells the Deployment which pods it "owns," and the <code>template.metadata.labels</code> is what those pods will be tagged with. If they don't match, Kubernetes won't be able to manage the pods.</p>
<p><code>nodeSelector: kubernetes.io/arch: arm64</code> — this is the pin. The Kubernetes scheduler filters out every node that doesn't carry this label before considering resource availability. Since GKE automatically applies <code>kubernetes.io/arch=arm64</code> to all ARM nodes, our pods will schedule only onto the <code>axion-pool</code> nodes.</p>
<p><code>livenessProbe</code> — periodically calls <code>GET /healthz</code>. If this check fails a certain number of times in a row (indicating the container has deadlocked or is otherwise unresponsive), Kubernetes restarts the container. <code>initialDelaySeconds: 5</code> gives the server 5 seconds to start up before the first check.</p>
<p><code>readinessProbe</code> — similar to the liveness probe, but with a different purpose. While the readiness probe is failing, Kubernetes removes the pod from the service's load balancer, so no traffic is sent to it. This is important during startup — the pod won't receive traffic until it signals it's ready.</p>
<p><code>resources.requests</code> — reserves <code>250m</code> (25% of a CPU core) and <code>64Mi</code> of memory on the node for this pod. The scheduler uses these numbers to decide whether a node has enough room for the pod. Setting requests is required for sensible bin-packing. Without them, nodes can be silently overcommitted.</p>
<p><code>resources.limits</code> — caps the container at <code>500m</code> CPU and <code>128Mi</code> memory. If the container exceeds these limits, Kubernetes throttles the CPU or kills the container (for memory). This prevents a single misbehaving pod from starving other workloads on the same node.</p>
<h3 id="heading-a-note-on-taints-and-tolerations">A Note on Taints and Tolerations</h3>
<p>Once you're comfortable with <code>nodeSelector</code>, the next step in production clusters is adding a <strong>taint</strong> to your ARM node pool. A taint is a repellent — any pod without an explicit <strong>toleration</strong> for that taint is blocked from landing on the tainted node.</p>
<p>This means other workloads in your cluster can't accidentally consume your ARM capacity. You'd add the taint when creating the pool:</p>
<pre><code class="language-bash"># Add --node-taints to the pool creation command:
--node-taints=workload-type=arm-optimized:NoSchedule
</code></pre>
<p>And a matching toleration in the pod spec:</p>
<pre><code class="language-yaml">tolerations:
- key: "workload-type"
  operator: "Equal"
  value: "arm-optimized"
  effect: "NoSchedule"
</code></pre>
<p>We're not doing this in the tutorial to keep things simple, but it's the pattern production multi-tenant clusters use to enforce hard separation between workload types.</p>
<h3 id="heading-write-the-service-manifest">Write the Service Manifest</h3>
<p>We also need a Kubernetes Service to expose the pods over the network. Create <code>k8s/service.yaml</code>:</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Service
metadata:
  name: hello-axion-svc
spec:
  selector:
    app: hello-axion
  ports:
  - protocol: TCP
    port: 80
    targetPort: 8080
  type: LoadBalancer
</code></pre>
<ul>
<li><p><code>selector: app: hello-axion</code> — the Service discovers pods using labels. Any pod with <code>app: hello-axion</code> on it will be added to this Service's load balancer pool.</p>
</li>
<li><p><code>port: 80</code> — the port the Service is reachable on from outside the cluster.</p>
</li>
<li><p><code>targetPort: 8080</code> — the port on the pod that traffic gets forwarded to. Our Go server listens on port 8080, so this must match.</p>
</li>
<li><p><code>type: LoadBalancer</code> — tells GKE to provision an external Google Cloud load balancer and assign it a public IP. This is what makes the Service reachable from the internet.</p>
</li>
</ul>
<h3 id="heading-apply-both-manifests">Apply Both Manifests</h3>
<pre><code class="language-bash">kubectl apply -f k8s/deployment.yaml
kubectl apply -f k8s/service.yaml
</code></pre>
<p><code>kubectl apply</code> reads each manifest file and creates or updates the resources described in it. If the resources don't exist yet, they're created. If they already exist, Kubernetes only applies the diff — it won't restart pods unnecessarily.</p>
<p>Watch the pods come up in real time:</p>
<pre><code class="language-bash">kubectl get pods -w
</code></pre>
<p>The <code>-w</code> flag watches for changes and prints updates as they happen. You should see pods transition from <code>Pending</code> → <code>ContainerCreating</code> → <code>Running</code>. Once all three show <code>Running</code>, press <code>Ctrl+C</code> to stop watching.</p>
<h2 id="heading-step-9-verify-the-deployment">Step 9: Verify the Deployment</h2>
<p>Everything is running. Now we need evidence — not just that pods are up, but that they're on the right nodes and serving the right binary.</p>
<h3 id="heading-confirm-pod-placement">Confirm Pod Placement</h3>
<pre><code class="language-bash">kubectl get pods -o wide
</code></pre>
<p>The <code>-o wide</code> flag adds extra columns to the output, including the name of the node each pod was scheduled on. Look at the <code>NODE</code> column:</p>
<pre><code class="language-plaintext">NAME                          READY   STATUS    NODE
hello-axion-7b8d9f-abc12      1/1     Running   gke-axion-tutorial-axion-pool-a-...
hello-axion-7b8d9f-def34      1/1     Running   gke-axion-tutorial-axion-pool-b-...
hello-axion-7b8d9f-ghi56      1/1     Running   gke-axion-tutorial-axion-pool-c-...
</code></pre>
<p>All three pods should show node names containing <code>axion-pool</code>. None should show <code>default-pool</code>.</p>
<h3 id="heading-confirm-the-nodes-are-arm">Confirm the Nodes Are ARM</h3>
<p>Take one of those node names and verify its architecture label:</p>
<pre><code class="language-bash">kubectl get node NODE_NAME --show-labels | grep kubernetes.io/arch
</code></pre>
<p>Replace <code>NODE_NAME</code> with one of the node names from the previous command. You should see:</p>
<pre><code class="language-plaintext">kubernetes.io/arch=arm64
</code></pre>
<p>That's the automatic label GKE applied when it provisioned the ARM hardware. Our <code>nodeSelector</code> matched on this label to pin the pods here.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/815312ea-e2bf-4106-863e-55cd0bdad5f7.png" alt="Terminal split into two sections: the top showing kubectl get pods -o wide with all pods scheduled on nodes containing axion-pool in the name, and the bottom showing kubectl get node with kubernetes.io/arch=arm64 in the labels output." style="display:block;margin:0 auto" width="2848" height="1500" loading="lazy">

<h3 id="heading-ask-the-application-itself">Ask the Application Itself</h3>
<p>This is the most satisfying verification step. Our Go server reports the architecture of the binary that's running. Let's ask it directly.</p>
<p>Use <code>kubectl port-forward</code> to create a secure tunnel from port 8080 on your local machine to port 8080 on the Deployment:</p>
<pre><code class="language-bash">kubectl port-forward deployment/hello-axion 8080:8080
</code></pre>
<p>This command stays running in the foreground — open a <strong>second terminal window</strong> and run:</p>
<pre><code class="language-bash">curl http://localhost:8080
</code></pre>
<p>You should see:</p>
<pre><code class="language-plaintext">Hello from freeCodeCamp!
Architecture : arm64
OS           : linux
Pod hostname : hello-axion-7b8d9f-abc12
</code></pre>
<p><code>Architecture : arm64</code>. That's our Go binary confirming that it was compiled for ARM64 and is executing on an ARM64 CPU. The single image tag we built does the right thing automatically.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/114ff82d-950f-4059-a1fa-89baffb90b6c.png" alt="Terminal output of curl http://localhost:8080 showing the four-line response: Hello from freeCodeCamp, Architecture: arm64, OS: linux, and the pod hostname." style="display:block;margin:0 auto" width="1042" height="292" loading="lazy">

<h3 id="heading-the-bonus-see-the-manifest-list-in-action">The Bonus: See the Manifest List in Action</h3>
<p>Want to see the multi-arch image indexing at work? Stop the port-forward, then run:</p>
<pre><code class="language-bash">docker buildx imagetools inspect \
  us-central1-docker.pkg.dev/PROJECT_ID/multi-arch-repo/hello-axion:v1
</code></pre>
<p>Replace <code>PROJECT_ID</code> with your actual Google Cloud project ID.</p>
<p>You'll see four entries in the manifest list. Two are real images — <code>Platform: linux/amd64</code> and <code>Platform: linux/arm64</code>. The other two show <code>Platform: unknown/unknown</code> with an <code>attestation-manifest</code> annotation. These are <strong>build provenance records</strong> that Docker Buildx automatically attaches to every image — a supply chain security feature (SLSA attestation) that proves how and where the image was built.</p>
<p>You may notice that if you check the image digest recorded in a running pod:</p>
<pre><code class="language-bash">kubectl get pod POD_NAME \
  -o jsonpath='{.status.containerStatuses[0].imageID}'
</code></pre>
<p>Replace <code>POD_NAME</code> with one of the pod names from earlier.</p>
<p>The digest returned matches the <strong>top-level manifest list digest</strong>, not the <code>arm64</code>-specific one. This is expected behaviour. Modern Kubernetes (using containerd) records the manifest list digest, not the resolved platform digest. The platform resolution already happened when the node pulled the correct image variant.</p>
<p>The definitive proof that the right binary is running is what you already have: the node labeled <code>kubernetes.io/arch=arm64</code> and the application reporting <code>Architecture: arm64</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/7dffe0c8-28cf-4a5d-8459-1e8db3da7dc0.png" alt="top-level manifest list digest" style="display:block;margin:0 auto" width="2302" height="1000" loading="lazy">

<h2 id="heading-step-10-cost-savings-and-tradeoffs">Step 10: Cost Savings and Tradeoffs</h2>
<p>The hands-on work is done. Let's talk about why any of this is worth the effort.</p>
<h3 id="heading-the-cost-math">The Cost Math</h3>
<p>At the time of writing, here's how ARM compares to equivalent x86 machines on Google Cloud (prices are approximate and change over time — check the <a href="https://cloud.google.com/compute/vm-instance-pricing">official pricing page</a> before making decisions):</p>
<table>
<thead>
<tr>
<th>Instance</th>
<th>vCPU</th>
<th>Memory</th>
<th>Approx. $/hour</th>
</tr>
</thead>
<tbody><tr>
<td><code>n2-standard-4</code> (x86)</td>
<td>4</td>
<td>16 GB</td>
<td>~$0.19</td>
</tr>
<tr>
<td><code>t2a-standard-4</code> (Tau ARM)</td>
<td>4</td>
<td>16 GB</td>
<td>~$0.14</td>
</tr>
<tr>
<td><code>c4a-standard-4</code> (Axion)</td>
<td>4</td>
<td>16 GB</td>
<td>~$0.15</td>
</tr>
</tbody></table>
<p>That's a raw 25–30% reduction in compute cost per node. Factor in Google's published claim of up to 65% better price-performance for Axion on relevant workloads — meaning you may need fewer nodes to handle the same traffic — and the savings compound further.</p>
<p>Here's how that looks at scale, for a service running 20 nodes continuously for a year:</p>
<ul>
<li><p>20 × <code>n2-standard-4</code> × \(0.19 × 8,760 hours = <strong>\)33,288/year</strong></p>
</li>
<li><p>20 × <code>t2a-standard-4</code> × \(0.14 × 8,760 hours = <strong>\)24,528/year</strong></p>
</li>
</ul>
<p>That's roughly <strong>$8,760 saved annually</strong> on compute, before committed use discounts (which further widen the gap).</p>
<h3 id="heading-when-arm-is-the-right-choice">When ARM Is the Right Choice</h3>
<p>ARM works best for:</p>
<ul>
<li><p><strong>Stateless API servers and web applications</strong> — like the app we built. ARM excels at high-throughput, low-latency network workloads.</p>
</li>
<li><p><strong>Background workers and queue processors</strong> — long-running services that don't depend on x86-specific binaries.</p>
</li>
<li><p><strong>Microservices written in Go, Rust, or Python</strong> — these languages have excellent ARM64 support and are built cross-platform by default.</p>
</li>
</ul>
<h3 id="heading-when-to-proceed-carefully">When to Proceed Carefully</h3>
<ul>
<li><p><strong>Native library dependencies</strong> — some older C libraries, proprietary SDKs, or compiled ML model-serving runtimes don't have ARM64 builds. Always audit your dependency tree before migrating.</p>
</li>
<li><p><strong>CI pipelines need ARM too</strong> — your automated tests should run on ARM, not just x86. An image that silently fails only on ARM is harder to debug than one that never claimed ARM support.</p>
</li>
<li><p><strong>Profile before optimizing</strong> — the cost savings are real, but measure your actual workload behavior on ARM before committing. Not every workload benefits equally.</p>
</li>
</ul>
<h2 id="heading-cleanup">Cleanup</h2>
<p>When you're done, clean up to avoid ongoing charges:</p>
<pre><code class="language-bash"># Remove the Kubernetes resources from the cluster
kubectl delete -f k8s/

# Delete the ARM node pool
gcloud container node-pools delete axion-pool \
  --cluster=axion-tutorial-cluster \
  --zone=us-central1-a

# Delete the cluster itself
gcloud container clusters delete axion-tutorial-cluster \
  --zone=us-central1-a

# Delete the images from Artifact Registry (optional — storage costs are minimal)
gcloud artifacts docker images delete \
  us-central1-docker.pkg.dev/PROJECT_ID/multi-arch-repo/hello-axion:v1
</code></pre>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Let's recap what you built and why each part matters.</p>
<p>You started with a Go application, a Dockerfile, and a <code>docker buildx build</code> command that produced two images — one for x86, one for ARM64 — wrapped in a single Manifest List tag. Any server that pulls that tag gets the right binary automatically, without you maintaining separate pipelines or separate tags.</p>
<p>You provisioned a GKE cluster with two node pools running different CPU architectures, then used <code>nodeSelector</code> to make sure your ARM-optimized workload lands only on the ARM Axion nodes — not on x86 by accident. The result is a deployment that's both architecture-correct and cost-efficient.</p>
<p>The patterns you practiced here don't stop at this demo. The same Dockerfile technique works for any language with cross-compilation support. The same <code>nodeSelector</code> approach works for any workload you want to pin to ARM. As more teams migrate services to ARM over the coming years, having these skills will be a real asset.</p>
<p><strong>Where to go from here:</strong></p>
<ul>
<li><p>Add a GitHub Actions workflow that runs <code>docker buildx build --platform linux/amd64,linux/arm64</code> on every push, automating this entire process in CI.</p>
</li>
<li><p>Audit one of your existing stateless services for ARM compatibility and try migrating it.</p>
</li>
<li><p>Explore <strong>Node Affinity</strong> as a softer alternative to <code>nodeSelector</code> for workloads that can run on either architecture but prefer ARM.</p>
</li>
<li><p>Look into <strong>GKE Autopilot</strong>, which now supports ARM nodes and handles node pool management automatically.</p>
</li>
</ul>
<p>Happy building.</p>
<h2 id="heading-project-file-structure">Project File Structure</h2>
<pre><code class="language-plaintext">hello-axion/
├── app/
│   ├── main.go          — Go HTTP server
│   ├── go.mod           — Go module definition
│   └── Dockerfile       — Multi-stage Dockerfile
└── k8s/
    ├── deployment.yaml  — Deployment with nodeSelector and probes
    └── service.yaml     — LoadBalancer Service
</code></pre>
<p>All source files for this tutorial are available in the companion GitHub repository: <a href="https://github.com/Amiynarh/multi-arch-docker-gke-arm">https://github.com/Amiynarh/multi-arch-docker-gke-arm</a></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Self-Host Your Own Server Monitoring Dashboard Using Uptime Kuma and Docker ]]>
                </title>
                <description>
                    <![CDATA[ As a developer, there's nothing worse than finding out from an angry user that your website is down. Usually, you don't know your server crashed until someone complains. And while many SaaS tools can  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/self-host-uptime-kuma-docker/</link>
                <guid isPermaLink="false">69d4185f40c9cabf44851652</guid>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ self-hosted ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ monitoring ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Ubuntu ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Abdul Talha ]]>
                </dc:creator>
                <pubDate>Mon, 06 Apr 2026 20:32:31 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/ea068a20-bc19-400a-a42e-1bbb7e492da8.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>As a developer, there's nothing worse than finding out from an angry user that your website is down. Usually, you don't know your server crashed until someone complains.</p>
<p>And while many SaaS tools can monitor your site, they often charge high monthly fees for simple alerts.</p>
<p>My goal with this article is to help you stop paying those expensive fees by showing you a powerful, free, open-source alternative called Uptime Kuma.</p>
<p>In this guide, you'll learn how to use Docker to deploy Uptime Kuma safely on a local Ubuntu machine.</p>
<p>By the end of this tutorial, you'll have set up your own private server monitoring dashboard in less than 10 minutes and created an automated Discord alert to ping your phone if your website goes offline.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-step-1-update-packages-and-prepare-the-firewall">Step 1: Update Packages and Prepare the Firewall</a></p>
</li>
<li><p><a href="#heading-step-2-create-the-docker-compose-file">Step 2: Create the Docker Compose File</a></p>
</li>
<li><p><a href="#heading-step-3-start-the-application">Step 3: Start the Application</a></p>
</li>
<li><p><a href="#heading-step-4-access-the-dashboard">Step 4: Access the Dashboard</a></p>
</li>
<li><p><a href="#heading-step-5-use-case-monitor-a-website-and-send-discord-alerts">Step 5: Use Case – Monitor a Website and Send Discord Alerts</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you start, make sure you have:</p>
<ul>
<li><p>An Ubuntu machine (like a local server, VM, or desktop).</p>
</li>
<li><p>Docker and Docker Compose installed.</p>
</li>
<li><p>Basic knowledge of the Linux terminal.</p>
</li>
</ul>
<h2 id="heading-step-1-update-packages-and-prepare-the-firewall">Step 1: Update Packages and Prepare the Firewall</h2>
<p>First, you'll want to make sure your system has the newest updates. Then, you'll install the Uncomplicated Firewall (UFW) and open the network "door" (port) that Uptime Kuma uses for the dashboard. You'll also need to allow SSH so you don't lock yourself out.</p>
<p>Run these commands in your terminal:</p>
<ol>
<li>Update your packages:</li>
</ol>
<pre><code class="language-shell">sudo apt update &amp;&amp; sudo apt upgrade -y
</code></pre>
<ol>
<li>Install the firewall:</li>
</ol>
<pre><code class="language-shell">sudo apt install ufw -y
</code></pre>
<ol>
<li>Allow SSH and open port 3001:</li>
</ol>
<pre><code class="language-shell">sudo ufw allow ssh
sudo ufw allow 3001/tcp
</code></pre>
<ol>
<li>Enable the firewall:</li>
</ol>
<pre><code class="language-shell">sudo ufw enable
sudo ufw reload
</code></pre>
<h2 id="heading-step-2-create-the-docker-compose-file">Step 2: Create the Docker Compose File</h2>
<p>Using a <code>docker-compose.yml</code> file is the professional way to manage Docker containers. It keeps your setup organised in one single place.</p>
<p>To start, create a new folder for your project and enter it:</p>
<pre><code class="language-shell">mkdir uptime-kuma &amp;&amp; cd uptime-kuma
</code></pre>
<p>Then create the configuration file:</p>
<pre><code class="language-shell">nano docker-compose.yml
</code></pre>
<p>Paste the following code into the editor:</p>
<pre><code class="language-yaml">services:
  uptime-kuma:
    image: louislam/uptime-kuma:2
    restart: unless-stopped
    volumes:
      - ./data:/app/data
    ports:
      - "3001:3001"
</code></pre>
<p><strong>Note</strong>: The <code>./data:/app/data</code> line is very important. It saves your database in a normal folder on your machine, making it easy to back up later.</p>
<p>Finally, save and exit: Press <code>CTRL + X</code>, then <code>Y</code>, then <code>Enter</code>.</p>
<h2 id="heading-step-3-start-the-application">Step 3: Start the Application</h2>
<p>Now, tell Docker to read your file and start the monitoring service in the background.</p>
<pre><code class="language-shell">docker compose up -d
</code></pre>
<p><strong>How to verify:</strong> Docker will download the files. When it finishes, your terminal should print <code>Started uptime-kuma</code>.</p>
<h2 id="heading-step-4-access-the-dashboard">Step 4: Access the Dashboard</h2>
<p>To access the dashboard, first open your web browser and go to <code>http://localhost:3001</code> (or your machine's local IP address).</p>
<p>When asked to choose the database, select <strong>SQLite</strong>. It's simple, fast, and requires no extra setup.</p>
<p>Then create an account and choose a secure admin username and password.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6729b04417afd6915f5c2e3e/02913589-020e-4a8a-aa7a-1bf70a9244c6.png" alt="02913589-020e-4a8a-aa7a-1bf70a9244c6" style="display:block;margin:0 auto" width="908" height="851" loading="lazy">

<h2 id="heading-step-5-use-case-monitor-a-website-and-send-discord-alerts">Step 5: Use Case – Monitor a Website and Send Discord Alerts</h2>
<p>Now you'll put Uptime Kuma to work by monitoring a live website and setting up an alert. Just follow these steps:</p>
<ol>
<li><p>Click Add New Monitor.</p>
</li>
<li><p>Set the Monitor Type to <code>HTTP(s)</code>.</p>
</li>
<li><p>Give it a Friendly Name (e.g., "My Blog") and enter your website's URL.</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/6729b04417afd6915f5c2e3e/74567f1e-acc4-480f-b969-7883e01aa459.png" alt="74567f1e-acc4-480f-b969-7883e01aa459" style="display:block;margin:0 auto" width="1918" height="867" loading="lazy">

<h3 id="heading-pro-tip-how-to-fix-down-errors-bot-protection">Pro-Tip: How to Fix "Down" Errors (Bot Protection)</h3>
<p>If your site uses strict security, it might block Uptime Kuma and say your site is "Down" with a 403 Forbidden error.</p>
<p><strong>The Fix:</strong> Scroll down to Advanced, find the User Agent box, and paste this text to make Uptime Kuma look like a normal Chrome browser:</p>
<p><code>Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36</code></p>
<h3 id="heading-add-a-discord-alert">Add a Discord Alert</h3>
<p>To get a message on your phone when your site goes down:</p>
<ol>
<li><p>On the right side of the monitor screen, click Setup Notification.</p>
</li>
<li><p>Select Discord from the dropdown list.</p>
</li>
<li><p>Paste a Discord Webhook URL (you can create one in your Discord server settings under Integrations).</p>
</li>
<li><p>Click Test to receive a test ping, then click Save.</p>
</li>
</ol>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Congratulations! You just took control of your server health. By deploying Uptime Kuma, you replaced an expensive SaaS subscription with a powerful, free monitoring tool that alerts you the second a project goes offline.</p>
<p><strong>Let’s connect!</strong> I am a developer and technical writer specialising in writing step-by-step guides and workflows. You can find my latest projects on my <a href="https://blog.abdultalha.tech/portfolio"><strong>Technical Writing Portfolio</strong></a> or reach out to me directly on <a href="https://www.linkedin.com/in/abdul-talha/"><strong>LinkedIn</strong></a>.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
